认证是一个确定请求访问者真实身份的过程,与认证相关的还有其他两个基本操作——登录和注销。ASP.NET Core利用AuthenticationMiddleware中间件完成针对请求的认证,并提供了用于登录、注销以及“质询”的API,本篇文章利用它们使用最简单的代码实现这些功能。(本文提供的示例演示已经同步到《ASP.NET Core 6框架揭秘-实例演示版》)
一、 认证票据
二、基于Cookie的认证
三、 强制认证
四、登录与注销
要真正理解认证、登录和注销这三个核心操作的本质,就需要对ASP.NET采用的基于“票据”的认证机制有基本的了解。ASP.NET Core应用的认证实现在AuthenticationMiddleware的中间件中,该中间件在处理分发给它的请求时会按照指定的认证方案(Authentication Scheme)从请求中提取能够验证用户真实身份的信息,我们一般将此信息称为安全令牌(Security Token)。ASP.NET Core应用下的安全令牌被称为认证票据(Authentication Ticket),它采用基于票据的认证方式。该中间件实现的整个认证流程涉及图1所示的三种针对认证票据的操作,即认证票据的“颁发”、“检验”和“撤销”。我们将这三个操作所涉及的三种角色称为票据颁发者(Ticket Issuer)、验证者(Authenticator)和撤销者(Ticket Revoker),在大部分场景下这三种角色由同一个主体来扮演。
图1 基于票据的认证
颁发认证票据的过程就是登录(Sign In)操作。用户试图通过登录来获取认证票据时需要提供可用来证明自身身份的凭证(Credential),最常见的用户凭证类型是“用户名 + 密码”。认证方在确定对方真实身份之后,会颁发一个认证票据,该票据携带着与该用户有关的身份、权限及其他相关的信息。
一旦拥有了由认证方颁发的认证票据,客户端就可以按照双方协商的方式(比如通过Cookie或者报头)在请求中携带该认证票据,并以此票据声明的身份执行目标操作或者访问目标资源。认证票据一般都具有时效性,一旦过期将变得无效。如果希望在过期之前就让认证票据无效,这就是注销(Sign Out)操作。
ASP.NET的认证系统旨在构建一个标准的模型,用来完成针对请求的认证以及与之相关的登录和注销操作。按照惯例,在介绍认证模型的架构设计之前,需要通过一个简单的实例来演示如何在一个ASP.NET应用中实现认证、登录和注销的功能。
我们会采用ASP.NET提供的基于Cookie的认证方案。该认证方案采用Cookie来携带认证票据。为了使读者对基于认证的编程模式有深刻的理解,我们演示的这个应用将从一个空白的ASP.NET应用开始搭建。这个应该会呈现两个页面,认证用户访问主页会呈现一个“欢迎”页面,匿名请求则会重定向到登录页面,我们将这两个页面的呈现实现在如下这个IPageRenderer服务中,PageRenderer类型为该接口的默认实现。
public interface IPageRenderer { IResult RenderLoginPage(string? userName = null, string? password = null, string? errorMessage = null); IResult RenderHomePage(string userName); } public class PageRenderer : IPageRenderer { public IResult RenderHomePage(string userName) { var html = @$" <html> <head><title>Index</title></head> <body> <h3>Welcome {userName}</h3> <a href='Account/Logout'>Sign Out</a> </body> </html>"; return Results.Content(html, "text/html"); } public IResult RenderLoginPage(string? userName, string? password, string? errorMessage) { var html = @$" <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>"; return Results.Content(html, "text/html"); } }
我们采用“用户名+密码”的认证方式,密钥验证实现的如下这个IAccountService接口的Validate方法中。在实现的AccountService类型中,我们预创建了三个密码为“password”的账号(“foo”、“bar”和“baz”)。
public interface IAccountService { bool Validate(string userName, string password); } public class AccountService: IAccountService { private readonly Dictionary<string, string> _accounts = new(StringComparer.OrdinalIgnoreCase) { { "Foo", "password"}, { "Bar", "password"}, { "Baz", "password"} }; public bool Validate(string userName, string password) =>_accounts.TryGetValue(userName, out var pwd) && pwd == password; }
我们即将创建的这个ASP.NET应用主要处理四种类型的请求。主页需要在登录之后才能访问,所以针对主页的匿名请求会被重定向到登录页面。在登录页面输入正确的用户名和密码之后,应用会自动重定向到主页,该页面会显示当前认证用户名并提供注销的链接。我们按照如下所示的方式注册了四个对应的终结点,其中登录和注销采用的是约定的路径“Account/Login”与“Account/Logout”。
using App; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using System.Security.Claims; using System.Security.Principal; var builder = WebApplication.CreateBuilder(); builder.Services .AddSingleton<IPageRenderer, PageRenderer>() .AddSingleton<IAccountService, AccountService>() .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); var app = builder.Build(); app.UseAuthentication(); app.Map("/", WelcomeAsync); app.MapGet("Account/Login", Login); app.MapPost("Account/Login", SignInAsync); app.Map("Account/Logout", SignOutAsync); app.Run(); Task WelcomeAsync () => throw new NotImplementedException(); IResult Login(IPageRenderer renderer) => throw new NotImplementedException(); Task SignInAsync()=> throw new NotImplementedException(); Task SignOutAsync() => throw new NotImplementedException();
上面的演示程序调用UseAuthentication扩展方法注册了AuthenticationMiddleware中间件,它所依赖服务是通过调用AddAuthentication扩展方法进行注册。在调用该方法时,我们还设置了默认采用的认证方案,静态类型CookieAuthenticationDefaults的AuthenticationScheme属性返回的就是Cookie认证方案的默认方案名称。我们在上面定义的两个服务也在这里进行了注册。图2所示就是作为应用的主页在浏览器上呈现的效果。
图2 应用主页
演示实例的主页是通过如下所示的WelcomeAsync方法来呈现的,该方法注入了当前HttpContext上下文、代表当前用户的ClaimsPrincipal对象和IPageRenderer对象。我们利用ClaimsPrincipal对象确定用户是否经过人证,认证用户请求将呈现正常的欢迎页面,匿名请求直接调用HttpContext上下文的ChallengeAsync方法进行处理。基于Cookie的认证方案会自动将匿名请求重定向到登录页面,由于我们指定的登录和注销路径是Cookie的认证方案约定的路径,所以调用ChallengeAsync方法时根本不需要指定重定向路径。
Task WelcomeAsync(HttpContext context, ClaimsPrincipal user, IPageRenderer renderer) { if (user?.Identity?.IsAuthenticated ?? false) { return renderer.RenderHomePage(user.Identity.Name!).ExecuteAsync(context); } return context.ChallengeAsync(); }
针对登录页面所在地址的请求由两种类型,针对GET请求的Login方法会登录页面呈现出来,针对POST请求的SignInAsync方法检验输入的用户名和密码,并在验证成功后实施“登录”。如下面的代码片段所示,SignInAsync方法中注入了当前HttpContext上下文、代表请求的HttpRequest对象和额外两个服务。从请求表单将用户和密码提取出来后,我们利用IAccountService对象进行验证。在验证通过的情况下,我们会根据用户名创建代表当前用户的ClaimsPrincipal对象,并将它作为参数调用HttpContext上下文的SignInAsync扩展方法实施登录, 该方法最终会自动重定向到初始方法的路径,也就是我们的主页。
IResult Login(IPageRenderer renderer) => renderer.RenderLoginPage(); Task SignInAsync(HttpContext context, HttpRequest request, IPageRenderer renderer,IAccountService accountService) { var username = request.Form["username"]; if (string.IsNullOrEmpty(username)) { return renderer.RenderLoginPage(null, null, "Please enter user name.").ExecuteAsync(context); } var password = request.Form["password"]; if (string.IsNullOrEmpty(password)) { return renderer.RenderLoginPage(username, null, "Please enter user password.").ExecuteAsync(context); } if (!accountService.Validate(username, password)) { return renderer.RenderLoginPage(username, null, "Invalid user name or password.").ExecuteAsync(context); } var identity = new GenericIdentity(name: username, type: "PASSWORD"); var user = new ClaimsPrincipal(identity); return context.SignInAsync(user); }
如果用户名或者密码没有提供或者不匹配,登录页面会以图3所示的形式再次呈现出来,并保留输入的用户名和错误消息。ChallengeAsync方法会将当前路径(主页路径“/”,经过编码后为“%2F”)存储在一个名为ReturnUrl的查询字符串中,SignInAsync方法正是利用它实现对初始路径的重定向的。
图3 登录页面
既然登录可以通过调用当前HttpContext上下文的SignInAsync扩展方法来完成,那么注销操作对应的自然就是SignOutAsync扩展方法。如下面的代码片段所示,SignOutAsync扩展方法正是调用这个方法来注销当前登录状态的。我们在完成注销之后将应用重定向到主页。
async Task SignOutAsync(HttpContext context) { await context.SignOutAsync(); context.Response.Redirect("/"); }