在开始之前,老周先祝各个次元的伙伴们新春快乐、生活愉快、万事如意。
在上一篇水文中,老周介绍了角色授权的一些内容。本篇咱们来聊一个比较实际的问题——把用于授权的角色名称放到外部配置,不要硬编码,以方便后期修改。
由于要配置的东西比较简单,咱们并不需要存在数据库,而是用 JSON 文件配置就可以了。将授权策略和角色列表关联起来。比如,老周这里有个 authorRoles.json 文件,它的内容如下:
{ "cust1": { "roles": ["admin", "supperuser"] }, "cust2": { "roles": ["user", "web", "logger"] } }
其中,cust1、cust2 是策略名称,所以上面就配置了两个授权策略。每个策略下有个 roles 属性,它的值是数组,这个数组用来指定此策略下允许的角色列表。故:cust1 策略下允许admin、supperuser两种角色的用户访问;cust2 策略下允许 user、web、logger 角色的用户访问。
在 WebApplicationBuilder 的配置中,咱们可以单独加载 authorRoles.json 文件中的内容,然后根据配置文件内容动态添加授权策略。
1、先把配置文件中的内容读出来。
// 配置文件名 const string roleConfigFile = "authorRoles.json"; // 单独加载配置 IConfigurationBuilder configBuilder = new ConfigurationBuilder(); // 添加配置源,此处是JSON文件 configBuilder.AddJsonFile(roleConfigFile); // 生成配置树对象 IConfiguration myconfig = configBuilder.Build();
此时,myconfig 变量中就包含了 authorRoles.json 文件的内容了。
2、动态添加授权策略。
var builder = WebApplication.CreateBuilder(args); // 根据配置文件的内容来设置授权策略 builder.Services.AddAuthorization(opt => { foreach (IConfigurationSection cc in myconfig.GetChildren()) { var policyName = cc.Key; opt.AddPolicy(policyName, pbd => { // 获取子节点 var roles = cc.GetSection("roles"); // 取出角色名称列表 string[]? roleslist = roles.Get<string[]>(); if (roleslist is not null) { // 添加角色 pbd.RequireRole(roleslist); // 关联验证架构 pbd.AddAuthenticationSchemes(CustAuthenticationSchemeDefault.SchemeName); } }); } });
在读配置的时候,GetChildren 方法会返回两个节点:cust1 和 cust2。然后用 GetSection 再读下一层,即 roles。接着用 Get 方法就能把字符串数组类型的角色列表读出来了。
这里关联了一个验证架构(或叫验证方案),这个验证架构是老周自己写的,主要是为了简单。老周这个示例是用 Web API 的形式呈现的,所以,不用 Cookie,而是用一个简单的 Token,调用时附加在 URL 的查询字符串中传递给服务器。
如果你的项目的 Token 只是在自己项目中用,不用遵守通用标准,你完全可以自己生成。生成方式你看着办,比如用随机字节什么的都行。在 Token 中不要带密码等安全信息。毕竟,Token 这种东西你说安全,也不见得多安全,别人只要拿到你的 Token 就可以代替你访问服务器。当然你会说,我把 Token 加密再传输。其实别人盗你的 Token 根本不需要知道明文,人家只要按照正确的传递方式(如 Query String、Cookies 等),把你加密后的 Token 放上去,也可以冒用你身份的。所以,很多开放平台都会分配给你 App Key 和密钥,并且强调你的密钥必须保管好,不能让别人知道。
下面看看老周自己写的验证。
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using System.Threading.Tasks; public class CustAuthenticationHandler : IAuthenticationHandler { #pragma warning disable CS8618 private HttpContext HttpContext { set; get; } private AuthenticationScheme Scheme { get; set; } #pragma warning restore CS8618 public Task<AuthenticateResult> AuthenticateAsync() { // 获取配置的Token IConfiguration appconfig = HttpContext.RequestServices.GetRequiredService<IConfiguration>(); string[]? tks = appconfig.GetSection("custAuthen:tokens").Get<string[]>(); if (tks != null && tks.Length > 0 && HttpContext.Request.Query.TryGetValue("token", out var reqToken)) { // 看看有没有效 if (!tks.Any(t => t == reqToken)) { return Task.FromResult(AuthenticateResult.Fail("未提供有效的Token")); } // 成功 var tickit = new AuthenticationTicket(HttpContext.User, Scheme.Name); return Task.FromResult(AuthenticateResult.Success(tickit)); } return Task.FromResult(AuthenticateResult.NoResult()); } public Task ChallengeAsync(AuthenticationProperties? properties) { HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } public Task ForbidAsync(AuthenticationProperties? properties) { HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; } public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) { if (context == null) throw new ArgumentNullException("context"); HttpContext = context; Scheme = scheme; // 看看验证架构是否一致 if (!scheme.Name.Equals(CustAuthenticationSchemeDefault.SchemeName, StringComparison.OrdinalIgnoreCase)) { throw new Exception("验证架构不一致"); } return Task.CompletedTask; } } public static class CustAuthenticationSchemeDefault { public readonly static string SchemeName = "CustToken"; }
这里老周没有用什么高级算法生成 Token,而是四个字符串(字符串也是随便输入的),表示四个 Token,只要有一个匹配就算是验证成功了。这些 Token 全写在 appsettings.json 里面。
{ "Logging": { …… } }, "AllowedHosts": "*", "custAuthen": { "tokens": [ "662CV08Y4GHXOP3", "BI4C68DLO2HOS0D", "7GSEJ0J8F0246K5", "O9FG6V974KWO9G8" ] } }
所以,访问这四个 Token 的配置路径就是 custAuthen:tokens。
在实现 ForbidAsync 和 ChallengeAsync 方法时,不要调用 HttpContext 的扩展方法 ForbidAsync、ChallengeAsync,因为这些扩展方法内部是通过调用 AuthenticationService 类的 ForbidAsync、ChallengeAsync 方法实现的。最终又会回过头来调用 CustAuthenticationHandler 类的 ChallengeAsync、ForbidAsync 方法。这等于转了一圈,到头来自己调用自己,易造成无限递归。所以这里我只设置一个 Status Code 就好了。
在服务容器上注册一下自定义的验证处理方案。
var builder = WebApplication.CreateBuilder(args); // 添加验证功能 builder.Services.AddAuthentication(opt => { // 注册验证架构(方案) opt.AddScheme<CustAuthenticationHandler>(CustAuthenticationSchemeDefault.SchemeName, displayName: null); });
所以,整个应用程序的初始化代码就是这样。
// 配置文件名 const string roleConfigFile = "authorRoles.json"; // 单独加载配置 IConfigurationBuilder configBuilder = new ConfigurationBuilder(); // 添加配置源,此处是JSON文件 configBuilder.AddJsonFile(roleConfigFile); // 生成配置树对象 IConfiguration myconfig = configBuilder.Build(); var builder = WebApplication.CreateBuilder(args); // 添加验证功能 builder.Services.AddAuthentication(opt => { // 注册验证架构(方案) opt.AddScheme<CustAuthenticationHandler>(CustAuthenticationSchemeDefault.SchemeName, displayName: null); }); // 根据配置文件的内容来设置授权策略 builder.Services.AddAuthorization(opt => { foreach (IConfigurationSection cc in myconfig.GetChildren()) { var policyName = cc.Key; opt.AddPolicy(policyName, pbd => { // 获取子节点 var roles = cc.GetSection("roles"); // 取出角色名称列表 string[]? roleslist = roles.Get<string[]>(); if (roleslist is not null) { // 添加角色 pbd.RequireRole(roleslist); // 关联验证架构 pbd.AddAuthenticationSchemes(CustAuthenticationSchemeDefault.SchemeName); } }); } }); builder.Services.AddControllers(); var app = builder.Build();
之后,是配置中间件管道。为了简单演示,老周没有写用于身份验证的 Web API,而是直接通过 URL 参数来提供当前访问者的角色。实际开发中不能这样做,而应该从数据库中根据用户查询出用户的角色。但此处是为了演示的简单,也是为了延长键盘寿命,就不建数据库了,不然完成这个示例需要一坤年的时间。
不过,咱们知道,授权是用 Claim 来收集信息的,所以,要在授权执行之前收集好信息。我这里用一个中间件,在授权和调用 API 之前执行。
app.Use((context, next) => { var val = context.Request.Query["role"]; string? role = val.FirstOrDefault(); if(role != null) { ClaimsIdentity id = new(new[] { new Claim(ClaimTypes.Role, role) }/*, CustAuthenticationSchemeDefault.SchemeName*/); ClaimsPrincipal p = new(id); context.User = p; } return next(); });
由于 WebApplication 对象默认帮我们调用了 UseRouting 和 UseEndpoints 方法。Web API 在访问时路由的是 MVC 控制器,直接走 End point 路线,会导致咱们上面的 Use 方法设置用户角色的中间件不执行。所以要重新调用 UseRouting 和 UseAuthorization 方法。
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
用一个名为 Demo 的控制器来做验证。
[Route("api/[controller]")] [ApiController] public class DemoController : ControllerBase { [HttpGet("backup")] [Authorize("cust1")] public string Backup() => "备份完成"; [HttpGet("hello/{name}")] [Authorize("cust2")] public string Hello(string name) { return $"你好,{name}"; } }
cust1、cust2 正是咱们前面配置里的节点名称,即策略名称。例如,调用 Hello 方法使用 cust2 授权策略,它配置的角色为 user、web、loggor。
在调用这些 API 时,URL需要携带两个参数:
1、role:用户角色;
2、token:用于验证。
用 http-repl 工具先测试 demo/backup 方法的调用。
get /api/demo/backup?role=web&token=O9FG6V974KWO9G8
上述调用提供的用户角色为 web,根据前面的配置,web 角色应使用 cust2 策略。但 Backup 方法应用的授权策略是 cust1,因此无权访问,返回 403。
咱们改一下,使用角色为 admin 的用户。
get /api/demo/backup?role=admin&token=O9FG6V974KWO9G8
此时,授权通过,返回 200。
访问 Hello 方法也一样,授权策略是 cust2,允许的角色是 user、web、logger。
get /api/demo/hello/小红?role=web&token=BI4C68DLO2HOS0D
授权通过,返回 200 状态码。
用配置文件来设置角色,算是一种简单方案。如果授权需要的角色有变化,只要修改配置文件中的角列表就行。当然,像 cust1、cust2 等策略名称要事先规划好,策略名称不随便改。
有大伙伴会说,干脆连MVC控制器或其方法上应用哪个授权策略也转到配置文件中,岂不美哉!好是好,但不好弄。可以要自己写个授权的 Filter,主要问题是自己写有时候没有官方内置的代码严谨,容易出“八阿哥”。
所以,综合复杂性与灵活性的平衡,在不扩展现有接口的前提下,咱们这个示例是比较好的,至少,咱们可以在配置文件中修改角色列表。