.NET CORE 授权进阶-角色、策略与动态权限实现

前两篇我们聊过认证鉴权模块,分别介绍了 Cookie、JWT 怎么接入及扩展,并且还分析了认证在请求链路里是怎么跑起来的。那么接下来进入 .NET Core 授权(Authorization)。简单来说就是这篇分享的所有东西,都建立在用户已经登录、Claims 已经在手的前提上,不要串台了。

很好理解的区分:

认证(Authentication) 确认 你是谁授权(Authorization) 决定 你能做什么,而在 HTTP 状态码上它们两也有对应关系:

场景 状态码 含义
没登录 / 身份无效 401 Unauthorized 不知道你是谁,先认证
已登录,但不允许操作 403 Forbidden 知道你是谁,但没权限

很多小伙伴有过这种体验:没登录访问接口 → 401,登录了再访问 → 403。这说明身份已经确认,但 授权没通过


授权在实际业务里的两个层面"你能做什么"通常拆成两层,而且有先后顺序:认证通过 → 功能权限 → 资源权限

功能权限回答的是,你能不能调用这个接口 / 能不能使用这个功能。在 Web 服务中一般对应端点(Endpoint),也就是具体的 API,比如:

  • 有没有查看考勤列表这个菜单/接口的权限
  • 有没有导出列表这个按钮对应的接口权限

.NET Core 框架提供了基于角色 Role 和基于策略 Policy 的授权,是两种非常基础常见的访问控制方式,相信很多小伙伴应该接触或者使用过,都是在接口上标记特性,它们会在进入 Action 之前就拦截。

没登录 → 401 ,登录了,但角色/策略没有使用这个 功能 的权限 → 403。我们这篇的内容主要分享功能权限如何实现以及扩展,会结合一些实际用到的例子来说明。


1. 基于角色授权

基于角色授权规定当前用户必须拥有指定的角色如 Admin 或 User 才能访问资源,如果用逗号隔开 2 个,它们的关系是"或",表示用户只要拥有其中任意一个角色即可通过授权。

使用时只需要在接口中标记 Authorize 特性就能集成,下面表示只有管理员才能使用这个接口:

csharp 复制代码
[HttpGet]
[Authorize(Roles = "Admin")]
public IActionResult GetList()
{
    return Ok(new { approach = "simple-native-roles", data = Books });
}

2. 基于策略授权

基于策略授权稍微灵活一点,但要先在 Program 里一个个注册。策略由一个或者多个 IAuthorizationRequirement 组成。在程序启动的时候,要将策略通过 AddPolicy() 方法注册到授权服务配置中:

csharp 复制代码
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("User.Delete", policy => policy.RequireRole("supAdmin"));
});

上面就是定义一个删除数据的策略,只有超级管理员才能使用,然后在接口中使用这个策略:

csharp 复制代码
[Authorize(Policy = "User.Delete")]
public IActionResult Delete(int id)
{
    return Ok(new { user = "sa", message = $"已删除{id}" });
}

配置可以理解为往类似下面的字典表中加入 key(策略名)和 value(权限规则),系统运行时框架就会拿着这个 key 去找对应的 value:


3. 自定义动态权限逻辑

用过或者仔细看过上面代码的小伙伴,估计都发现了,不管是基于角色还是策略的授权,这代码其实都写得太死了。说白了,就跟个玩具一样,在简单的小项目里练练手还行,但只要项目稍微大一点、业务稍微复杂点,问题立马就暴露出来了。要是你觉得我说得有点虚,那咱们就脑补几个真实的业务场景,看看是怎么让人难受的:

场景一:

改个按钮权限,还得走一遍发版流程。假设你的系统已经上线了,之前"导出报表"这个按钮是只有管理员才能点的。突然有一天,产品跑来找你说:这个按钮以后不能所有管理员都点了,得单独控制一下。

你一听,行,那就加个权限点呗。于是你跑到 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 不查字典,而是不管什么策略名,都现场给它造一个策略出来?

造出来的策略都挂一个查数据库进行校验的 Handler,Handler 拿着策略名也就是权限编码,去数据库里查当前用户有没有这个权限。具体思路看下面的图:

如果这样一改,AddPolicy() 就彻底不需要了,在接口上写 [Authorize(Policy = "随便什么编码")]PolicyProvider 都能支持,Handler 都会去库里查,使权限和代码完全解耦。

整个方案就改动几个小文件,它们的关系如下面图,先有个整体印象后面逐个实现:


4. 动态权限实现落地

4.1 自定义 Requirement

自定义 Requirement 实现 IAuthorizationRequirement,上面写着这个接口要求用户拥有哪个权限编码。它本身没有逻辑,只负责携带信息,理解为包装编码的对象。

额,IAuthorizationRequirement 其实是个空接口,纯粹就是个标记。它的作用就是让框架能识别这是一个授权 Requirement,然后帮你找到对应的 Handler。

csharp 复制代码
public class PermissionCodeRequirement : IAuthorizationRequirement
{
    public PermissionCodeRequirement(string permissionCode)
    {
        PermissionCode = permissionCode;
    }

    public string PermissionCode { get; }
}

4.2 定义查库服务

定义查库服务模拟 user_permissions 表,真实项目里就是数据库里的一张 user_permissions 表,记录哪个用户拥有哪些权限编码。我这里为了演示,用内存字典模拟一下,真实项目中可以换成 EF Core 查库、查 Redis 缓存都是一样的道理。

csharp 复制代码
public interface IUserPermissionService
{
    // 模拟数据库 按用户 ID 查询是否拥有某权限编码
    Task<bool> UserHasPermissionAsync(string userId, string permissionCode);
    Task<IReadOnlyList<string>> GetUserPermissionsAsync(string userId);
}

这一步是动态的关键步骤,想给张三加权限的话,往这张表插条数据就行,不用改代码重启。然后把权限编码抽成常量,避免到处写魔法值:

csharp 复制代码
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 + 权限编码去查库,用户有没有这个权限,如果有就放行。

csharp 复制代码
public class PermissionCodeHandler : AuthorizationHandler<PermissionCodeRequirement>
{
    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 个细节:

1. context.User.FindFirst(ClaimTypes.NameIdentifier) 这个 userId 是认证阶段登录时写进 Cookie 的 Claim,如果你是 jwt 也是一样。你登录时会构造身份信息生成 token,认证时会解析 token,生成票据写到上下文,授权阶段直接从上下文取出来用。认证和授权就是这么串起来了------认证负责往里塞身份信息,授权取出来判断。

2. context.Succeed(requirement) 只会在通过时调用,如果不调,框架就会认为这个 requirement 不符合,返回 403。不 Succeed 就是拒绝,突然想起有点像 linux 返回空白就是成功的这么个意思......

4.4 写动态 PolicyProvider

前面咱们其实还是实现原生 Requirement + Handler 的套路。真正动态起来的是这一步------我们要替换掉框架默认的 IAuthorizationPolicyProvider,让它不再依赖 AddPolicy() 注册的字典,而是按策略名字直接构建 Policy。

就可以说明策略压根不是提前注册的,而是每次请求现场造的。所以你想加多少权限编码都行。

csharp 复制代码
public class PolicyCodePolicyProvider : IAuthorizationPolicyProvider
{
    private readonly DefaultAuthorizationPolicyProvider _fallback;

    public PolicyCodePolicyProvider(IOptions<AuthorizationOptions> options)
    {
        // 保留默认 Provider 兜底
        _fallback = new DefaultAuthorizationPolicyProvider(options);
    }

    public async Task<AuthorizationPolicy?> 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 Task<AuthorizationPolicy> GetDefaultPolicyAsync() 
        => _fallback.GetDefaultPolicyAsync();

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() 
        => _fallback.GetFallbackPolicyAsync();
}

4.5 封装语法糖特性

每次写 [Authorize(Policy = "User.Delete")] 有点啰嗦,而且 Policy = 这个写法看不出来它是个权限编码。我们自己封装一个特性,搞个语法糖。

特性继承自原有的 Authorize,然后把传进来的权限编码给 Policy 属性。本质上和 [Authorize(Policy = "xxx")] 一毛一样,只是写起来变成了 [RequirePermissionCode("xxx")],一看就知道这是在配权限,不是在配策略。

csharp 复制代码
[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,我们把它换掉,而不是加一个,否则不清楚用哪个。

csharp 复制代码
public static class PolicyCodeAuthorizationExtensions
{
    public static IServiceCollection AddPolicyCodeAuthorization(this IServiceCollection services)
    {
        services.AddScoped<IUserPermissionService, InMemoryUserPermissionService>();
        services.AddScoped<IAuthorizationHandler, PermissionCodeHandler>();

        // 用我们自己的 Provider 替换掉框架默认的 IAuthorizationPolicyProvider
        services.Replace(ServiceDescriptor.Singleton<IAuthorizationPolicyProvider, PolicyCodePolicyProvider>());

        return services;
    }
}

4.7 控制器中使用

基本的框架就搭好了,然后在控制器里用起来。有小伙伴可能会问,控制器上标了 [Authorize(AuthenticationSchemes = "Cookie")] 有什么区别?就是为了保证进来的是登录用户,方法上的 [RequirePermissionCode] 才是授权,判断这个登录用户有没有对应权限。

csharp 复制代码
[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 权限去删用户:

1. user 先登录,认证阶段往 Cookie 里写了 NameIdentifier = "yuxl" 这个 Claim。

2. 带 Cookie 请求删除接口。

3. 认证中间件解开 Cookie,确认是登录用户,Claims 拿到手。

4. 授权中间件看到方法上 Authorize(Policy = "User.Delete"),拿着 "User.Delete" 去问我们的 PolicyCodePolicyProvider。

5. Provider 发现 Program 没注册过它,于是现场构造了 PermissionCodeRequirement("User.Delete") 的策略。

6. 框架执行策略,调 PermissionCodeHandler。Handler 从 Claim 取出 "yuxl",查数据库 "yuxl" 是否有 User.Delete,而库里 "yuxl" 只有 User.View,没有 User.Delete,Handler 就不会 Succeed。

7. 框架判断不通过返回 403。

完整时序图:


5. 总结

自己实现的这套非常简单的demo动态权限,其实就是使用原生授权的一个扩展点 IAuthorizationPolicyProvider。原生默认 Provider 只能获取 AddPolicy() 注册过的策略,我们把它换成一个自己的通用 Provider,就可以把权限从编译时期写死的代码变成了运行时查数据库了。不用改代码发版,插条数据就行。

整个方案就几个步骤和文件,它适合权限直接绑定到用户、没有复杂权限树和权限管理 UI 的场景,也没有角色批量授权,也没有权限的动态注册。后续会结合 abp vnext 框架中的权限继续分享它是如何完整实现企业级授权系统的。

不过我们现在应该进一步知道了认证和授权是分开的,这篇分享的所有内容,必须都建立在上一篇认证已经把 userId 写进 Claim 的基础上。开头说过认证解决你是谁,授权解决你能干嘛,两个中间件先后配合起来才可以完成一套完整的鉴权流程。

相关推荐
时代的狂6 天前
ASP.NET Core 认证与授权(二):登录与令牌签发
后端·asp.net·.net core·web api·认证与授权
时代的狂6 天前
ASP.NET Core 认证与授权(三):请求认证与权限校验
asp.net·.net core·web api·认证与授权
yuyuyui7 天前
.NET CORE 动态扩展Options-配置运行时热更新
.net core
yuyuyui15 天前
.NET CORE认证模块-注册方案与请求认证探究
认证·.net core
yuyuyui20 天前
.NET CORE 认证模块-Cookie、JWT 与自定义 Scheme
认证·.net core
hez201021 天前
在 .NET 上构建超大托管数组
c#·.net·.net core·gc·clr
.NET修仙日记1 个月前
Scrutor:.NET 依赖注入自动化的优雅实现
c#·.net·.net core·微软技术·依赖注入·scrutor
刚子编程2 个月前
.NET 8 Web开发入门(七):安全门禁——JWT 身份验证与授权实战
jwt·授权·接口安全·.net 8·身份验证·json web token·bearer token
CSharp精选营2 个月前
.NET 8 Web开发入门(七):安全门禁——JWT 身份验证与授权实战
jwt·授权·接口安全·身份验证·json web token·bearer token·.net 8 security