DOTNET 鉴权系列- 动态Claim应用

管理员给用户改了角色之后,都是在群里发消息,让人重新登录下系统然后生效角色的,有时候有的用户不太懂,导致没退出,就会说系统有问题,后面我们在DOTNET 鉴权系列- JWT 会话撤销与主动登出 把 JWT 的踢人做完之后,暂时解决了,管理员直接就在后台给指定用户全部退出了,其实仔细想想虽然加了主动登出还是有点不太合理,改了角色,为啥还要等用户重新登录?其实这一切是因为我们现在颁发的token中Claim存储了角色信息。然后有些业务都是取这个里面的角色信息做逻辑判断。

image

所以就面临9 点登录,Token 里 Role = Admin。10 点管理员把他改成普通用户。Token 还拿着,下午去调 [Authorize(Roles = "Admin")] 的接口,照样能过。就出现库已经改了,凭证没改,但是JwtBearer 鉴权时验的是Token,Token上写着 Admin。

一开始以为是 Identity 模块没更新。后来发现根本不是模块的关系。Token 里的Claim,进到 ASP.NET Core 之后,就会变成 HttpContext.User 身份。使用权限时 [Authorize(Roles)]User.IsInRole 读的都是它里面的内容,不读数据库。由于Token是旧的,而身份也是从token解析出来的,导致角色依然还是修改角色之前的内容。

如果你理解了上面描述的信息应该就能知道我开头说的,为什么改了角色,让人重新登录下,后续进行主动退出,后面参考了一些比较优秀的项目的思路和实现。捋清楚之后在这里我把 Dynamic Claims 分享出来。直接用比较通俗的大白话描述一下

Token 就像一张工卡,卡上写着你当时的身份,比如普通开发。但这张卡上的身份,只在发给你那一刻是准的,之后就算你升职了,卡上也不会自动更新。

所以每次你刷卡进门发请求时,门禁客户端不会傻乎乎地只看卡上写的什么,而是去查一下最新的员工信息,发现你已经升职成经理了,就直接按经理的权限放行。

如果既想角色立刻变,又想 Token 完全自包含、服务端永远不查库。立刻生效,这是不可能的。

1.Claim的作用

在没有做这个需求之前之前我对 JWT 和 User的理解比较浅,也没有很深入的去梳理他们的关系,我们颁发一个token,对应系统里面一个登录用户。其实 Jwt干的活很简单——当token验签通过了之后,就把 Payload 里的 Claim 对应成系统里面的一份 ClaimsPrincipal,然后挂到 HttpContext.User

image

可以这么理解:一个 Token 对应一个 Identity,但这个 Identity 不是 Token 自带的,而是每次请求时服务端重新构建的,下面是对应关系

JWT Payload 系统内部Identity 备注
subunique_name `User.Identity.Name 当前是谁
role: Admin User.IsInRole("Admin") 判断“是不是管理员
session_id User.FindFirst("session_id") 取出会话编号

对比之后我们知道Token 里的 Role和应用里的身份默认是同一份数据。[Authorize(Roles = "Admin")] 不是去用户表里问他现在是不是 Admin,是问 这次请求的 Principal 上有没有这条 Claim。

上面的名字只是为了看懂流程。具体哪个 Claim 映射成 Identity.Name、哪个算 Role,由 NameClaimTypeRoleClaimType 和 Jwt 的映射配置决定。项目里一旦定好就别来回换,否则很容易出现 Token 里明明有 roleUser.IsInRole 却返回 false以及其他的诡异Bug。

下面我们重新认识下比较容易混乱的关键字:

  • Claim:一条身份数据,例如 name=adminrole=Admin

  • ClaimsIdentity:一组 Claim,再加认证类型、Name/Role ClaimType 等信息,代表一份具体身份。

  • ClaimsPrincipal:Identity 的容器。ASP.NET Core 最终把它放到 HttpContext.User

    public abstract class HttpContext
    {
      public abstract ClaimsPrincipal User { get; set; }
    }
    

所以从原理上来说,动态更新角色不是修改token的Claim,而是修改当前Token请求 ClaimsPrincipal 里的 ClaimsIdentity。先删旧 角色Claim,再加最新 角色 Claim。

在会话撤销的那篇分享说过,JWT 验签证明的是签发方、有没有过期、有没有被改。token中带的 Claim,证明的只是签发那一刻服务端认为他是 Admin以及这些claim有没有被中途篡改。不证明现在他还是不是 Admin。

可能你会和我有一样的疑问。既然会过时,那为啥还要把角色塞进token里呢?后面我也查了相关资料和这么做的理由,主要如下。

  • 鉴权无状态Token 里塞角色,用空间换时间。把用户身份信息缓存在 Token 里,这样网关、API 拿到 Token 就能直接判断权限,不用每次都去查数据库。
  • .NET Core 的整个鉴权管道就是围绕 Claims 设计的,[Authorize(Roles = "Admin")]User.IsInRoleIPrincipal 都读 Claim,压根没有去数据库查角色这个动作。中间件负责把 Token 解析成 ClaimsIdentity,后续所有鉴权逻辑都只认 Claims。
  • 微服务模式下合同、订单服务往往没有用户表,只能信 Token 里面的身份。

所以角色放进 Identity中,是 Cookie / JWT 的默认做法。但是代价就是角色变了,Token中的内容不会自己变。

2.为什么不直接查库?

其实就算我也认同上面的理由能站得住脚,还是在内心仍然觉得,可以使用最直接的写法在每个业务接口里自己查

var roles = await roleRepository.GetRolesAsync(userId);
if (roles.Contains("Admin"))
{
  // to do search workorder 
}

这样肯定能用,因为我曾经在前司做用户中心的时候,就是这样的写法,但我们将每个接口都得重复查角色、写判断的逻辑封装 为接口给到各个微服务调用了,那时候没有考虑到框架设计的特性 , 准确的说,根本没用上框架提供的鉴权认证的这个架构,系统中出现了两套身份体系,现在回想就是4不像

  • 框架认为他是 Admin,因为 Principal 上还是旧 Role。
  • 业务代码认为他不是 Admin,因为刚从数据库查到的是 User。

可以说明最麻烦的地方,不是能不能查库,是不能让框架和业务各认一套身份。而Dynamic Claims 的处理方式是:

在授权之前统一查一次,把最新角色写回当前 ClaimsIdentity。这样 Controller 不用自己查,原来的 [Authorize(Roles)]User.IsInRole、权限 Provider 也不用换用法,使用同一个 HttpContext.User

尽量不要Controller 各自查库,各自判断,框架里的 User 还是旧的,Dynamic Claims做法是统一查库然后覆盖 ClaimsIdentity 整个授权链路上都看到的是新的身份。

所以实际业务中的可以把它们配合起来:

  1. Token 里的 Claim 提供登录快照,让系统完成认证并拿到 userId
  2. 对角色每次查源,再覆盖当前请求里的 Claim。

3.动手覆盖

捋清楚对应关系,下面如何落地呢?如何配合起来,看下面这个交互流程

image

下面动手。登录Token该怎么颁发还怎么颁发,Role也在一开始颁发时写进去作为快照参考,反正真正这次请求他是什么角色是以这次请求的 Identity为准。

Demo中用内存字典作为角色表,生产可以换成数据库,前面再挂一层缓存就行了。定义两个简单方法GetRoles 读取、SetRoles 更新。管理员改角色就是改这里。后面覆盖身份的代码就是调用这个

public class DemoUserClaimStore
{
    private readonly ConcurrentDictionary<string, string[]> _roles = new(StringComparer.OrdinalIgnoreCase);

    public DemoUserClaimStore()
    {
        _roles["admin"] = ["Admin", "User"];
    }

    public string[] GetRoles(string username) =>
        _roles.TryGetValue(username, out var roles) ? roles : [];

    public void SetRoles(string username, string[] roles) =>
        _roles[username] = roles;
}

1. 覆盖本次请求的 Role

对于已认证的请求,会把Token 里面对应的旧 Role 全部去掉,按查出的最新角色再挂一遍。

public class DynamicClaimsMiddleware
{
    private readonly RequestDelegate _next;
    private readonly DemoUserClaimStore _store;

    public DynamicClaimsMiddleware(RequestDelegate next, DemoUserClaimStore store)
    {
        _next = next;
        _store = store;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.User.Identity?.IsAuthenticated != true)
        {
            await _next(context);
            return;
        }

        OverlayRoles(context.User);
        await _next(context);
    }

    private void OverlayRoles(ClaimsPrincipal principal)
    {
        var identity = principal.Identities.FirstOrDefault();
        var username = identity?.Name;
        if (identity is null || string.IsNullOrEmpty(username))
            return;

        foreach (var claim in identity.FindAll(ClaimTypes.Role).ToList())
            identity.RemoveClaim(claim);

        foreach (var role in _store.GetRoles(username))
            identity.AddClaim(new Claim(ClaimTypes.Role, role));
    }
}

中间件里面做的事,是改内存里这一次请求的 Identity,不是改 JWT,也不是重新签发。RemoveClaimAddClaim 改的就是 HttpContext.User 上那份对象,也不需要再换一个 Principal 写回去。如果需要部门、租户要更新,也可以用这个方法,角色表存的是什么,这里就往这次请求的身份上贴什么。

要注意中间件放在认证和鉴权的中间,顺序不能放反。先 UseAuthentication(),JwtBearer 把 Token 快照映射到 HttpContext.User 上,中间件再覆盖这份 Identity,最后 UseAuthorization() 看到的才是新角色。

app.UseAuthentication();
app.UseMiddleware<DynamicClaimsMiddleware>();
app.UseAuthorization();

image

2. 改角色,Token 不用换

管理员改的是角色表,不是 Token。同一个 Bearer 再打一次仅 Admin 的接口,立刻 403。


[HttpPut("users/{username}/roles")]
public IActionResult SetRoles(string username, [FromBody] string[] roles)
{
    _claimStore.SetRoles(username, roles);
    return Ok(new { username, roles });
}

[Authorize(Roles = "Admin")]
[HttpGet("admin-only")]
public IActionResult AdminOnly() =>
    Ok(new { message = "你当前仍是 Admin", user = User.Identity?.Name });

1.POST /login,拿到 Token,里面是 Admin + User。

2.带着 Token 调 GET /admin-only,200。

3.PUT /users/admin/roles,body 改成 ["User"]

4.同一个 Token 再调 GET /admin-only,403。

5.把 Token 解开看看,里面还是 Admin。但是角色表和内存里的 Identity。

按照这样实现管理员 10 点收回 Admin角色,但是用户还是 9 点那张票,下次请求 Identity 上的 Role 已经被覆盖成普通用户,[Authorize(Roles = "Admin")] 直接就会返回 403。这样没有偏离 .NET CORE中 ClaimsPrincipal 的授权设计,又把旧的快照在真正授权之前改对了,注意要把它和权限点分开,不然容易搅在一起。如果你用的是权限名授权,比如 [Authorize("Books.Create")],权限值存在权限表 / 缓存里,按 userId 查,本来就不依赖 Token 里的 Role,这条路通常不会Token 角色过期。咱们当前这种主要针对解决的是:

User.IsInRoleCurrentUser.Roles

[Authorize(Roles = "...")]

3.还有从 Principal 上获取的角色再去拿权限的 Provider

posted @ 2026-09-09 19:29  yuyuyui  阅读(11)  评论(0)    收藏  举报