.NET CORE 授权进阶-角色、策略与动态权限实现
.NET Core 授权Authorization。简单来说就是这篇分享的所有东西都建立在用户已经登录、Claims 已经在手的前提上不要串台了。很好理解的区分认证Authentication 确认 你是谁授权Authorization 决定 你能做什么而在 HTTP 状态码上它们两也有对应关系场景 状态码 含义没登录 / 身份无效 401 Unauthorized 不知道你是谁先认证已登录但不允许操作 403 Forbidden 知道你是谁但没权限很多小伙伴有过这种体验没登录访问接口 → 401登录了再访问 → 403。这说明身份已经确认但 授权没通过。授权在实际业务里的两个层面你能做什么通常拆成两层而且有先后顺序认证通过 → 功能权限 → 资源权限授权整体流程功能权限回答的是你能不能调用这个接口 / 能不能使用这个功能。在 Web 服务中一般对应端点Endpoint也就是具体的 API比如有没有查看考勤列表这个菜单/接口的权限有没有导出列表这个按钮对应的接口权限.NET Core 框架提供了基于角色 Role 和基于策略 Policy 的授权是两种非常基础常见的访问控制方式相信很多小伙伴应该接触或者使用过都是在接口上标记特性它们会在进入 Action 之前就拦截。没登录 → 401登录了但角色/策略没有使用这个 功能 的权限 → 403。我们这篇的内容主要分享功能权限如何实现以及扩展会结合一些实际用到的例子来说明。基于角色授权基于角色授权规定当前用户必须拥有指定的角色如 Admin 或 User 才能访问资源如果用逗号隔开 2 个它们的关系是或表示用户只要拥有其中任意一个角色即可通过授权。使用时只需要在接口中标记 Authorize 特性就能集成下面表示只有管理员才能使用这个接口[HttpGet][Authorize(Roles “Admin”)]public IActionResult GetList(){return Ok(new { approach “simple-native-roles”, data Books });}2. 基于策略授权基于策略授权稍微灵活一点但要先在 Program 里一个个注册。策略由一个或者多个 IAuthorizationRequirement 组成。在程序启动的时候要将策略通过 AddPolicy() 方法注册到授权服务配置中builder.Services.AddAuthorization(options {options.AddPolicy(“User.Delete”, policy policy.RequireRole(“supAdmin”));});上面就是定义一个删除数据的策略只有超级管理员才能使用然后在接口中使用这个策略[Authorize(Policy “User.Delete”)]public IActionResult Delete(int id){return Ok(new { user “sa”, message $“已删除{id}” });}配置可以理解为往类似下面的字典表中加入 key策略名和 value权限规则系统运行时框架就会拿着这个 key 去找对应的 value策略字典示意自定义动态权限逻辑用过或者仔细看过上面代码的小伙伴估计都发现了不管是基于角色还是策略的授权这代码其实都写得太死了。说白了就跟个玩具一样在简单的小项目里练练手还行但只要项目稍微大一点、业务稍微复杂点问题立马就暴露出来了。要是你觉得我说得有点虚那咱们就脑补几个真实的业务场景看看是怎么让人难受的场景一改个按钮权限还得走一遍发版流程。假设你的系统已经上线了之前“导出报表”这个按钮是只有管理员才能点的。突然有一天产品跑来找你说这个按钮以后不能所有管理员都点了得单独控制一下。你一听行那就加个权限点呗。于是你跑到 Program 里老老实实地加了一行 AddPolicy()然后走测试、走审批、重新打包发版。就为了这么个小小的权限调整你硬生生走了一整套上线流程。这效率简直让人吐槽。场景二权限点一多代码变成屎山。可能有人会说发版就发版呗能解决问题就行。行那咱们让系统再跑个一两年。等权限点从最初的 10 个涨到 100 个的时候再回头看看你的Program 文件里面的 AddPolicy() 就堆成一座大s山了。应该没人还敢轻易去动它了不然改一行代码把别的业务给搞崩了就得不偿失了。场景三角色写死了想临时给个人开个权限? 最让人绝望的还在后头。如果你的权限是用 RequireRole(“Admin”) 这种硬编码写死的把角色和权限死死绑在一起。这时候产品又提需求了能不能给小王临时开个权限让他今天也能点这个按钮这时候你绝对傻眼因为你的代码里根本没有“把权限单独绑给某个人”的逻辑总不能为了他一个人再去加个 RequireUser(“小王”) 吧那以后是不是还得加 RequireUser(“阿彬”)这代码还怎么往下写现在应该能体会到原生 AddPolicy() 最要命的地方就是它把权限在编译期就给焊死了。想加个权限点或者调一下权限就得改代码、重新编译。但在企业真实的业务里权限这东西是活的必须得支持在运行时随时动态调整。今天给小王开个口子明天把阿彬的权限收回来这种日常操作不能每次都让开发去改代码。那有没有什么好办法能让加权限这事儿彻底摆脱 Program 文件再也不用重新发版顺手把那一坨坨 AddPolicy() 的模板代码也给干掉思路其实很简单让策略名直接等同于权限编码把谁有什么权限这个归属关系统统扔进数据库里等程序跑起来的时候再去查。 这样一来以后想给谁加权限直接往库里插一条数据就ok了代码都不用动才是真正的动态配置来先把整个需求的思路捋顺一下1.开发接口的时候给每个接口定好一个权限编码。2.程序一启动就把这些编码自动同步加载到数据库里。3.管理员在后台就能灵活操作了想给哪个用户或者角色分配权限直接分配这个编码就行。4.等用户真正发请求访问接口时系统去查一下数据库看看他有没有这个编码。有放行。没有直接返回 403 拒绝访问。要实现这套逻辑通常有两条路可以走1.自定义实现。最常见的做法就是自己写个过滤器或者中间件在请求经过管道的时候去抓取接口的元数据然后再去查库验权。2.扩展 .NET Core 的授权系统。咱们重点要聊的就是第二种方式。因为把它和 .NET Core 原生的授权系统结合在一起代码写出来最优雅也最符合 .NET Core 平台的风格。不过得先说下我可能不会在这里把每一步的代码都敲出来。毕竟不管是单体架构还是微服务真要把这套东西完美落地都是个不小的挑战。这篇内容咱们就做一件事怎么优雅地去扩展这个授权系统。另外稍微提醒一下接下来的内容可能会稍微有点绕。最好对 .NET Core 授权模块的底层运行原理有一定的了解或者至少实际用过这样看的时候才不会觉得云里雾里。要是你之前没怎么接触过强烈建议先去翻翻官方文档。我会尽量说清楚但如果看完还是觉得哪里不清晰就留言下一起讨论吧原生 [Authorize(Policy “User.Delete”)] 框架内部流程是这样的请求进来框架看到接口上面的 [Authorize] 上写了 Policy “User.Delete”就会拿着 User.Delete 这个名字去找 IAuthorizationPolicyProvider 的接口问它这个策略是什么样的。默认的 PolicyProvider 就会在 Program 中 AddPolicy() 注册过的字典里查查到了就返回查不到就报错。拿到策略后执行策略里挂的 Requirement交给对应的 Handler 再判断通不通过。原生授权流程最大的问题就是 PolicyProvider 只认先注册好的策略那我们能不能自己写一个 PolicyProvider 不查字典而是不管什么策略名都现场给它造一个策略出来造出来的策略都挂一个查数据库进行校验的 HandlerHandler 拿着策略名也就是权限编码去数据库里查当前用户有没有这个权限。具体思路看下面的图动态授权思路如果这样一改AddPolicy() 就彻底不需要了在接口上写 [Authorize(Policy “随便什么编码”)]PolicyProvider 都能支持Handler 都会去库里查使权限和代码完全解耦。整个方案就改动几个小文件它们的关系如下面图先有个整体印象后面逐个实现方案整体关系动态权限实现落地4.1 自定义 Requirement自定义 Requirement 实现 IAuthorizationRequirement上面写着这个接口要求用户拥有哪个权限编码。它本身没有逻辑只负责携带信息理解为包装编码的对象。额IAuthorizationRequirement 其实是个空接口纯粹就是个标记。它的作用就是让框架能识别这是一个授权 Requirement然后帮你找到对应的 Handler。public class PermissionCodeRequirement : IAuthorizationRequirement{public PermissionCodeRequirement(string permissionCode){PermissionCode permissionCode;}public string PermissionCode { get; }}4.2 定义查库服务定义查库服务模拟 user_permissions 表真实项目里就是数据库里的一张 user_permissions 表记录哪个用户拥有哪些权限编码。我这里为了演示用内存字典模拟一下真实项目中可以换成 EF Core 查库、查 Redis 缓存都是一样的道理。public interface IUserPermissionService{// 模拟数据库 按用户 ID 查询是否拥有某权限编码Task UserHasPermissionAsync(string userId, string permissionCode);TaskIReadOnlyList GetUserPermissionsAsync(string userId);}这一步是动态的关键步骤想给张三加权限的话往这张表插条数据就行不用改代码重启。然后把权限编码抽成常量避免到处写魔法值public static class PolicyCodePermissionNames{public const string UserView “User.View”;public const string UserDelete “User.Delete”;public const string OrderCreate “Order.Create”;}4.3 写 Handler写 Handler它是真正做判断的地方。它拿到 Requirement 后从当前登录用户的 Claims 里把 userId 拿出来再拿着 userId 权限编码去查库用户有没有这个权限如果有就放行。public class PermissionCodeHandler : AuthorizationHandler{private readonly IUserPermissionService _permissionService;public PermissionCodeHandler(IUserPermissionService permissionService) { _permissionService permissionService; } protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionCodeRequirement requirement) { // 从 Claims 里取 userId认证登录时写进去的 var userId context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) { return; // 没取到 userId直接不通过说明都没登录 } // 拿 userId 权限编码去查数据库或者缓存有就放行 if (await _permissionService.UserHasPermissionAsync(userId, requirement.PermissionCode)) { context.Succeed(requirement); } }}这里有 2 个细节context.User.FindFirst(ClaimTypes.NameIdentifier) 这个 userId 是认证阶段登录时写进 Cookie 的 Claim如果你是 jwt 也是一样。你登录时会构造身份信息生成 token认证时会解析 token生成票据写到上下文授权阶段直接从上下文取出来用。认证和授权就是这么串起来了——认证负责往里塞身份信息授权取出来判断。context.Succeed(requirement) 只会在通过时调用如果不调框架就会认为这个 requirement 不符合返回 403。不 Succeed 就是拒绝突然想起有点像 linux 返回空白就是成功的这么个意思……4.4 写动态 PolicyProvider前面咱们其实还是实现原生 Requirement Handler 的套路。真正动态起来的是这一步——我们要替换掉框架默认的 IAuthorizationPolicyProvider让它不再依赖 AddPolicy() 注册的字典而是按策略名字直接构建 Policy。就可以说明策略压根不是提前注册的而是每次请求现场造的。所以你想加多少权限编码都行。public class PolicyCodePolicyProvider : IAuthorizationPolicyProvider{private readonly DefaultAuthorizationPolicyProvider _fallback;public PolicyCodePolicyProvider(IOptionsAuthorizationOptions options) { // 保留默认 Provider 兜底 _fallback new DefaultAuthorizationPolicyProvider(options); } public async TaskAuthorizationPolicy? GetPolicyAsync(string policyName) { if (string.IsNullOrWhiteSpace(policyName)) return await _fallback.GetPolicyAsync(policyName); // 1. 在 Program 里手动 AddPolicy() 注册的优先用原生的 var registered await _fallback.GetPolicyAsync(policyName); if (registered is not null) return registered; // 2. 构造用户直接查数据库的 Requirement return new AuthorizationPolicyBuilder() .AddRequirements(new PermissionCodeRequirement(policyName)) .Build(); } public TaskAuthorizationPolicy GetDefaultPolicyAsync() _fallback.GetDefaultPolicyAsync(); public TaskAuthorizationPolicy? GetFallbackPolicyAsync() _fallback.GetFallbackPolicyAsync();}4.5 封装语法糖特性每次写 [Authorize(Policy “User.Delete”)] 有点啰嗦而且 Policy 这个写法看不出来它是个权限编码。我们自己封装一个特性搞个语法糖。特性继承自原有的 Authorize然后把传进来的权限编码给 Policy 属性。本质上和 [Authorize(Policy “xxx”)] 一毛一样只是写起来变成了 [RequirePermissionCode(“xxx”)]一看就知道这是在配权限不是在配策略。[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple true)]public class RequirePermissionCodeAttribute : AuthorizeAttribute{public RequirePermissionCodeAttribute(string permissionCode){Policy permissionCode;}}4.6 注册到容器最后把服务注册到容器Handler 和 Provider 都需要注册进去。IAuthorizationPolicyProvider 要使用 Replace 而不是 Add。框架启动时默认注册了一个 DefaultAuthorizationPolicyProvider我们把它换掉而不是加一个否则不清楚用哪个。public static class PolicyCodeAuthorizationExtensions{public static IServiceCollection AddPolicyCodeAuthorization(this IServiceCollection services){services.AddScopedIUserPermissionService, InMemoryUserPermissionService();services.AddScopedIAuthorizationHandler, PermissionCodeHandler();// 用我们自己的 Provider 替换掉框架默认的 IAuthorizationPolicyProvider services.Replace(ServiceDescriptor.SingletonIAuthorizationPolicyProvider, PolicyCodePolicyProvider()); return services; }}4.7 控制器中使用基本的框架就搭好了然后在控制器里用起来。有小伙伴可能会问控制器上标了 [Authorize(AuthenticationSchemes “Cookie”)] 有什么区别就是为了保证进来的是登录用户方法上的 [RequirePermissionCode] 才是授权判断这个登录用户有没有对应权限。[ApiController][Route(“api/policy-code”)][Authorize(AuthenticationSchemes CookieAuthenticationDefaults.AuthenticationScheme)]public class PolicyCodeUserController : ControllerBase{[HttpGet][RequirePermissionCode(PolicyCodePermissionNames.UserView)]public IActionResult GetUsers(){return Ok(new { data new[] { new { Id 1, Name “小王” } } });}[HttpDelete({id:int})] [Authorize(Policy PolicyCodePermissionNames.UserDelete)] public IActionResult DeleteUser(int id) { return Ok(new { message $已删除用户 {id} }); }}4.8 完整流程串讲我们串起来完整流程看一次请求比如 user 只有 User.View 权限去删用户user 先登录认证阶段往 Cookie 里写了 NameIdentifier “yuxl” 这个 Claim。带 Cookie 请求删除接口。认证中间件解开 Cookie确认是登录用户Claims 拿到手。授权中间件看到方法上 [Authorize(Policy “User.Delete”)]拿着 “User.Delete” 去问我们的 PolicyCodePolicyProvider。Provider 发现 Program 没注册过它于是现场构造了 PermissionCodeRequirement(“User.Delete”) 的策略。框架执行策略调 PermissionCodeHandler。Handler 从 Claim 取出 “yuxl”查数据库 “yuxl” 是否有 User.Delete而库里 “yuxl” 只有 User.View没有 User.DeleteHandler 就不会 Succeed。框架判断不通过返回 403。完整时序图完整时序图总结自己实现的这套非常简单的demo动态权限其实就是使用原生授权的一个扩展点 IAuthorizationPolicyProvider。原生默认 Provider 只能获取 AddPolicy() 注册过的策略我们把它换成一个自己的通用 Provider就可以把权限从编译时期写死的代码变成了运行时查数据库了。不用改代码发版插条数据就行。整个方案就几个步骤和文件它适合权限直接绑定到用户、没有复杂权限树和权限管理 UI 的场景也没有角色批量授权也没有权限的动态注册。后续会结合 abp vnext 框架中的权限继续分享它是如何完整实现企业级授权系统的。不过我们现在应该进一步知道了认证和授权是分开的这篇分享的所有内容必须都建立在上一篇认证已经把 userId 写进 Claim 的基础上。开头说过认证解决你是谁授权解决你能干嘛两个中间件先后配合起来才可以完成一套完整的鉴权流程。