在安全领域, 认证和授权是两个重要的主题. 认证是安全体系的第一道屏障, 是守护整个应用或者服务的第一道大门. 当访问者请求进入的时候, 认证体系通过验证对方的提供凭证确定其真实身份. 认证体系只有在证实了访问者的真实身份的情况下才会允许其进入. ASP.NET Core 提供了多种认证方式, 它们的实现都基于相同的认证模型. 本篇文章提供了一个极简的实例让读者体验如何在 ASP.NET Core 应用中实现认证, 登录和注销.
本篇文章节选自《ASP.NET Core 3 框架揭秘》(下册), 针对本书的限时 5 折优惠截至到今天 24 时, 有兴趣的朋友可以通过加入读者群进行购买. 入群方式: 扫描右方二维码添加 "博文小丸子(broadview002)", 并将本书书号 "38462" 作为验证信息. 源代码从这里下载.
一, 认证票据
认证是一个旨在确定请求访问者真实身份的过程, 与认证相关的还有其他两个基本操作 -- 登录与注销. 要真正理解认证, 登录与注销这 3 个核心操作的本质, 就需要对 ASP.NET Core 采用的基于 "票据" 的认证机制有基本的了解. ASP.NET Core 应用的认证实现在一个名为 AuthenticationMiddleware 的中间件中, 该中间件在处理分发给它的请求时会按照指定的认证方案 (Authentication Scheme) 从请求中提取能够验证用户真实身份的数据, 我们一般将该数据称为安全令牌(Security Token).ASP.NET Core 应用下的安全令牌被称为认证票据(Authentication Ticket), 所以 ASP.NET Core 应用采用基于票据的认证方式.
AuthenticationMiddleware 中间件实现的整个认证流程涉及下图所示的 3 种针对认证票据的操作, 即认证票据的颁发, 检验和撤销. 我们将这 3 个操作所涉及的 3 种角色称为票据颁发者 (Ticket Issuer), 验证者(Authenticator) 和撤销者(Ticket Revoker), 在大部分场景下这 3 种角色由同一个主体来扮演.
颁发认证票据的过程就是登录 (Sign In) 操作. 一般来说, 用户试图通过登录应用以获取认证票据的时候需要提供可用来证明自身身份的用户凭证(User Credential), 最常见的用户凭证类型是 "用户名 + 密码". 认证方在确定对方真实身份之后, 会颁发一个认证票据, 该票据携带着与该用户相关的身份, 权限及其他相关的信息.
一旦拥有了由认证方颁发的认证票据, 我们就可以按照双方协商的方式 (如通过 Cookie 或者报头) 在请求中携带该认证票据, 并以此票据声明的身份执行目标操作或者访问目标资源. 认证票据一般都具有时效性, 一旦过期将变得无效. 我们有的时候甚至希望在过期之前就让认证票据无效, 以免别人使用它冒用自己的身份与应用进行交互, 这就是注销 (Sign Out) 操作.
ASP.NET Core 应用的认证系统旨在构建一个标准的模型来完成针对请求的认证以及与之相关的登录和注销操作. 接下来我们就通过一个简单的实例来演示如何在一个 ASP.NET Core 应用中实现认证, 登录和注销的功能.
二, 基于 Cookie 的认证
我们会采用 ASP.NET Core 提供的基于 Cookie 的认证方案. 顾名思义, 该认证方案采用 Cookie 来携带认证票据. 为了使读者对基于认证的编程模式有深刻的理解, 我们演示的这个应用将从一个空白的 ASP.NET Core 应用开始搭建.
我们即将创建的这个 ASP.NET Core 应用主要处理 3 种类型的请求. 应用的主页需要登录之后才能访问, 所以针对主页的匿名请求会被重定向到登录页面. 在登录页面输入正确的用户名和密码之后, 应用会自动重定向到应用主页, 该页面会显示当前认证用户名并提供注销的链接. 我们按照如下所示的方式利用路由来处理这 3 种类型的请求, 其中登录和注销采用的是默认路径 "Account/Login" 与 "Account/Logout".
- public class Program
- {
- public static void Main()
- {
- Host.CreateDefaultBuilder()
- .ConfigurewebHostDefaults(builder => builder
- .ConfigureServices(svcs => svcs.AddRouting())
- .Configure(App => App
- .UseRouting()
- .UseEndpoints(endpoints =>{
- endpoints.Map(pattern: "/", RenderHomePageAsync);
- endpoints.Map("Account/Login", SignInAsync);
- endpoints.Map("Account/Logout", SignOutAsync);
- })))
- .Build()
- .Run();
- }
- public static async Task RenderHomePageAsync(HttpContext context)
- {
- throw new NotImplementedException();
- }
- public static async Task SignInAsync(HttpContext context)
- {
- throw new NotImplementedException();
- }
- public static async Task SignOutAsync(HttpContext context)
- {
- throw new NotImplementedException();
- }
- }
三, 应用主页
如下面的代码片段所示, 我们调用 IApplicationBuilder 接口的 UseAuthentication 扩展方法就是为了注册用来实现认证的 AuthenticationMiddleware 中间件. 该中间件的依赖服务是通过调用 IServiceCollection 接口的 AddAuthentication 扩展方法注册的. 在注册这些基础服务时, 我们还设置了默认采用的认证方案, 静态类型 CookieAuthenticationDefaults 的 AuthenticationScheme 属性返回的就是 Cookie 认证方案的默认方案名称.
- public class Program
- {
- public static void Main()
- {
- Host.CreateDefaultBuilder()
- .ConfigureWebHostDefaults(builder => builder
- .ConfigureServices(svcs => svcs
- .AddRouting()
- .AddAuthentication(options => options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)
- .AddCookie())
- .Configure(App => App
- .UseAuthentication()
- .UseRouting()
- .UseEndpoints(endpoints =>{
- endpoints.Map(pattern: "/", RenderHomePageAsync);
- endpoints.Map("Account/Login", SignInAsync);
- endpoints.Map("Account/Logout", SignOutAsync);
- })))
- .Build()
- .Run();
- }
- }
ASP.NET Core 提供了一个极具扩展性的认证模型, 我们可以利用它支持多种认证方案, 针对认证方案的注册是通过 AddAuthentication 方法返回的一个 AuthenticationBuilder 对象来实现的. 在上面提供的代码片段中, 我们调用 AuthenticationBuilder 对象的 AddCookie 扩展方法完成了针对 Cookie 认证方案的注册.
演示实例的主页是通过如下所示的 RenderHomePageAsync 方法来呈现的. 由于我们要求浏览主页必须是经过认证的用户, 所以该方法会利用 HttpContext 上下文的 User 属性返回的 ClaimsPrincipal 对象判断当前请求是否经过认证. 对于经过认证的请求, 我们会响应一个简单的 html 文档, 并在其中显示用户名和一个注销链接.
- public class Program
- {
- ...
- public static async Task RenderHomePageAsync(HttpContext context)
- {
- if (context?.User?.Identity?.IsAuthenticated == true)
- {
- await context.Response.WriteAsync(
- @"<HTML>
- <head><title>Index</title></head>
- <body>" +
- $"<h3>Welcome {context.User.Identity.Name}</h3>" +
- @"<a href='Account/Logout'>Sign Out</a>
- </body>
- </HTML>");
- }
- else
- {
- await context.ChallengeAsync();
- }
- }
- }
对于匿名请求, 我们希望应用能够自动重定向到登录路径. 从如上所示的代码片段可以看出, 我们仅仅调用当前 HttpContext 上下文的 ChallengeAsync 扩展方法就完成了针对登录路径的重定向. 前面提及, 注册的登录和注销路径是基于 Cookie 的认证方案采用的默认路径, 所以调用 ChallengeAsync 方法时根本不需要指定重定向路径. 下图所示就是作为应用的主页在浏览器上呈现的效果.
四, 登录
登录与注销分别实现在 SignInAsync 方法和 SignOutAsync 方法中, 我们采用的是针对 "用户名 + 密码" 的登录方式, 所以可以利用静态字段_accounts 来存储应用注册的账号. 在静态构造函数中, 我们添加密码均为 "password" 的 3 个账号(Foo,Bar 和 Baz).
- public class Program
- {
- private static Dictionary<string, string> _accounts;
- static Program()
- {
- _accounts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
- _accounts.Add("Foo", "password");
- _accounts.Add("Bar", "password");
- _accounts.Add("Baz", "password");
- }
- }
如下所示的代码片段是用于处理登录请求的 SignInAsync 方法的定义, 而 RenderLoginPageAsync 方法用来呈现登录页面. 如下面的代码片段所示, 对于 GET 请求, SignInAsync 方法会直接调用 RenderLoginPageAsync 方法来呈现登录界面. 对于 POST 请求, 我们会从提交的表单中提取用户名和密码, 并对其实施验证. 如果提供的用户名与密码一致, 我们会根据用户名创建一个代表身份的 GenericIdentity 对象, 并利用它创建一个代表登录用户的 ClaimsPrincipal 对象, RenderHomePageAsync 方法正是利用该对象来检验当前用户是否经过认证的. 有了 ClaimsPrincipal 对象, 我们只需要将它作为参数调用 HttpContext 上下文的 SignInAsync 扩展方法即可完成登录, 该方法最终会自动重定向到初始方法的路径, 也就是我们的主页.
- public class Program
- {
- public static async Task SignInAsync(HttpContext context)
- {
- if (string.Compare(context.Request.Method, "GET") == 0)
- {
- await RenderLoginPageAsync(context, null, null, null);
- }
- else
- {
- var userName = context.Request.Form["username"];
- var password = context.Request.Form["password"];
- if (_accounts.TryGetValue(userName, out var pwd) && pwd == password)
- {
- var identity = new GenericIdentity(userName, "Passord");
- var principal = new ClaimsPrincipal(identity);
- await context.SignInAsync(principal);
- }
- else
- {
- await RenderLoginPageAsync(context, userName, password, "Invalid user name or password!");
- }
- }
- }
- private static Task RenderLoginPageAsync(HttpContext context, string userName, string password, string errorMessage)
- {
- context.Response.ContentType = "text/html";
- return context.Response.WriteAsync(
- @"<HTML>
- <head><title>Login</title></head>
- <body>
- <form method='post'>" +
- $"<input type='text'name='username'placeholder='User name'value ='{userName}'/>" +
- $"<input type='password'name='password'placeholder='Password'value ='{password}'/>" +
- @"<input type='submit'value='Sign In'/></form>" +
- $"<p style='color:red'>{errorMessage}</p>" +
- @"</body>
- </HTML>");
}
}
如果用户提供的用户名与密码不匹配, 我们还是会调用 RenderLoginPageAsync 方法来呈现登录页面, 该页面会以下图所示的形式保留用户的输入并显示错误消息. 图 19-3 还反映了一个细节, 调用 HttpContext 上下文的 ChallengeAsync 方法会将当前路径 (主页路径 "/", 经过编码后为 "/") 存储在一个名为 ReturnUrl 的查询字符串中, SignInAsync 方法正是利用它实现对初始路径的重定向的.
五, 注销
既然登录可以通过调用当前 HttpContext 上下文的 SignInAsync 扩展方法来完成, 那么注销操作对应的自然就是 SignOutAsync 扩展方法. 如下面的代码片段所示, 我们定义在 Program 中的 SignOutAsync 扩展方法正是调用这个方法来注销当前登录状态的. 我们在完成注销之后将应用重定向到主页.
- public class Program
- {
- ...
- public static async Task SignOutAsync(HttpContext context)
- {
- await context.SignOutAsync();
- context.Response.Redirect("/");
- }
- }
来源: https://www.cnblogs.com/artech/p/authentication-sign-in-out.html