Skip to content
Go back

ASP.NET Core 角色授权实战:JWT + Minimal API + .NET 10

角色是怎么从数据库走到端点的

认证回答”你是谁”,授权回答”你能做什么”。在上一篇 JWT 认证文章 里已经实现了用户登录拿 token,现在要做的就是把不同角色的人限制在不同端点前。

角色检查涉及三个地方,它们必须保持一致:

  1. Identity 存储 — ASP.NET Core Identity 在 AspNetUsersAspNetRoles 表中维护用户和角色(演示项目用了内存数据库,不必搭 SQL Server)。
  2. JWT — 登录时从 Identity 读出用户角色,写入 token 的 claims,一个角色一条 claim。
  3. ClaimsPrincipal — 每次请求,JWT bearer 中间件验证 token 并从 claims 重建用户对象。RequireRoleUser.IsInRole() 检查的是这个对象,不是数据库。

角色从 Identity 存储到 JWT Claim 再到 ClaimsPrincipal 的流转

由此产生两个容易被忽略的事实:

演示 API 的前置条件

演示项目基于 .NET 10,NuGet 包 Microsoft.AspNetCore.Authentication.JwtBearer 10.0.0 和 Scalar.AspNetCore 2.13.18。完整源码在 GitHub

场景是一个产品库存 API,三个角色、三个种子用户:

邮箱密码角色
admin@codewithmukesh.comAdmin123!Admin, Manager
manager@codewithmukesh.comManager123!Manager
user@codewithmukesh.comUser123!User

注意 Admin 同时持有 Admin 和 Manager 两个角色,后面讲 AND 语义时会用到。

角色名放在一个静态类里,避免拼写错误。角色名作为 claim 值后是大小写敏感的,"admin""Admin" 是两个不同角色:

// Entities/Roles.cs
public static class Roles
{
    public const string Admin = "Admin";
    public const string Manager = "Manager";
    public const string User = "User";
}

把角色写入 JWT

登录时从 Identity 读取用户角色,每个角色一条 role claim:

// Auth/TokenService.cs
var claims = new List<Claim>
{
    new(JwtRegisteredClaimNames.Sub, user.Id),
    new(JwtRegisteredClaimNames.Email, user.Email!),
    new(JwtRegisteredClaimNames.Name, $"{user.FirstName} {user.LastName}"),
    new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

// 每个角色一条 "role" claim,RequireRole 后面要读的就是它
claims.AddRange(roles.Select(role => new Claim("role", role)));

把 manager 的 token 拿到 jwt.io 解码,payload 里直接就是 "role": "Manager"。Admin 的 token 里是 "role": ["Admin", "Manager"] — 同类型多条 claim 自动折叠为数组。

关键认知:角色只是一条名字约定好的 claim,不是什么独立于 JWT 之外的机制。记住这句话,它是理解后续 claims-based 授权的基础。

接收端需要告诉 ASP.NET Core 用哪个 claim 名字存放角色,也就是 RoleClaimType

// Program.cs
.AddJwtBearer(options =>
{
    // 保持 claim 名字原样,不要让中间件做 legacy 重映射
    options.MapInboundClaims = false;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        // ... issuer, audience, signing key 照常配 ...
        NameClaimType = JwtRegisteredClaimNames.Name,
        // RoleClaimType 必须和 TokenService 写入角色的 claim 名一致
        // 这两行不一致,所有角色检查返回 403
        RoleClaimType = "role"
    };
});

写入用 "role",读取也用 "role"。一半以上”角色不生效”的 bug 就是这两行没对齐。后面有专门的排错章节。

用 RequireRole 保护端点

角色在 token 里、RoleClaimType 也配好了,保护端点只需要一行代码。下面是 Minimal API 风格的产品接口:

// Endpoints/ProductEndpoints.cs
public static void MapProductEndpoints(this IEndpointRouteBuilder app)
{
    // 组级 RequireAuthorization():/api/products 下所有端点都要有效 token
    var group = app.MapGroup("/api/products")
        .WithTags("Products")
        .RequireAuthorization();

    // 任何已认证用户都能看产品列表,不需要特定角色
    group.MapGet("/", (ProductStore store) =>
        Results.Ok(store.GetAll()));

    // 只有 Admin 能删除。Manager 调用会收到 403
    group.MapDelete("/{id:int}", (int id, ProductStore store) =>
            store.Delete(id) ? Results.NoContent() : Results.NotFound())
        .RequireAuthorization(policy =>
            policy.RequireRole(Roles.Admin));
}

两层控制:组级 RequireAuthorization() 说”必须登录才能碰 /api/products 下面的东西”,端点级 RequireRole 说”而且对这个端点,你还得是 Admin”。没 token 的人收到 401,已登录的 Manager 调 DELETE 收到 403

401403 的区别值得记住:401 是”我不知道你是谁”,403 是”我知道你是谁,答案是拒绝”。

如果用的是 Controller 而非 Minimal API,同样的检查是经典的 attribute 写法:

[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public IActionResult Delete(int id) { ... }

另外别忘了 app.UseAuthentication() 必须在 app.UseAuthorization() 前面。认证先搞清楚你是谁,授权再决定你能做什么。顺序反了,所有受保护调用全部失败。

多个角色:OR 还是 AND

这个问题几乎所有人都搞反过一次,因为回答取决于你怎么写。

一次调用里传多个角色 = OR

RequireRole("Admin", "Manager") 表示 Admin Manager,满足任意一个就能过:

// Admin 或 Manager 都能创建产品
group.MapPost("/", (CreateProductRequest request, ProductStore store) =>
    {
        var product = store.Add(request);
        return Results.Created($"/api/products/{product.Id}", product);
    })
    .RequireAuthorization(policy =>
        policy.RequireRole(Roles.Admin, Roles.Manager));

Controller 的 [Authorize(Roles = "Admin,Manager")] 行为相同 — Admin OR Manager。逗号读起来像”和”,语义上却是”或”,所以开发者经常搞反。

链式调用 = AND

链式写两个 RequireRole,调用者必须同时持有两个角色:

// 同时需要 Admin 和 Manager 两个角色
group.MapGet("/audit", () =>
        Results.Ok("Stock audit report. You hold both the Admin and Manager roles."))
    .RequireAuthorization(policy => policy
        .RequireRole(Roles.Admin)
        .RequireRole(Roles.Manager));

Controller 里堆叠 attribute 实现 AND:

[Authorize(Roles = "Admin")]
[Authorize(Roles = "Manager")]  // 必须同时满足两个 attribute

在演示项目里,种子 Admin 同时持有 Admin 和 Manager,所以 /audit 能过。Manager 只有一个角色,返回 403

给角色检查起个名字:命名 Policy

内联 RequireRole 写一两个端点还行,三个端点需要同样的检查就该给它一个名字,集中定义一次。.NET 7 引入的 AddAuthorizationBuilder() 是 .NET 10 的标准写法:

// Program.cs
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("ManagerOnly", policy =>
        policy.RequireRole(Roles.Manager));

端点直接用名字引用:

group.MapPut("/{id:int}/restock", (int id) =>
        Results.Ok($"Product {id} restocked."))
    .RequireAuthorization("ManagerOnly");

改一处,“ManagerOnly” 的含义在所有引用的端点上同时生效。这已经是 policy-based 授权的雏形了 — 在底层,就连内联 RequireRole 其实也会变成 policy。完整的 policy 体系(requirements、handlers、自定义规则)是这个系列的第三篇文章。

在代码里用 IsInRole 做分支

不是每个角色判断都是”放行或拒绝”。有些时候同一个端点要对不同角色返回不同数据。注入 ClaimsPrincipal,用 IsInRole() 做分支:

group.MapGet("/dashboard", (ClaimsPrincipal user) =>
{
    var greeting = $"Hello {user.Identity?.Name}.";

    if (user.IsInRole(Roles.Admin))
    {
        return Results.Ok(
            $"{greeting} Full dashboard: sales, inventory, and user management.");
    }

    return user.IsInRole(Roles.Manager)
        ? Results.Ok($"{greeting} Manager dashboard: inventory and restock queue.")
        : Results.Ok($"{greeting} Your orders and saved items.");
});

IsInRole("Admin") 只是在问:这个 principal 有没有一条值为 Admin 的 role claim。数据还是那套数据,RoleClaimType 还是那套规则,区别只是在代码里命令式地判断而非声明式地要求。

一个经验法则:答案是非黑即白的放行/拒绝,用 RequireRole;答案是”给他们看不一样的东西”,用 IsInRole。如果发现自己写了一长串 IsInRole 的 if-else 来守卫访问,那这逻辑应该挪到 policy 里去。

验证:从头跑一遍

克隆 GitHub 仓库,运行 dotnet run --project RoleBasedAuth.Api,打开 Scalar UI(/scalar/v1)或用 requests.http 文件。

按这个顺序验证:

角色为什么不生效?排查指南

token 里明明有角色,但每个角色检查都返回 403。绝大多数情况是 claim 类型映射的问题。

旧的 JWT handler 有一个遗留兼容行为:当 MapInboundClaimstrue(历史默认值),传入的 claim 名会被翻译成长长的 SOAP 风格 URI。你的 role claim 到了 ClaimsPrincipal 里变成了 http://schemas.microsoft.com/ws/2008/06/identity/claims/role。然后 RequireRoleRoleClaimType 的值去找 role — 什么都没找到,因为 claim 挂在另一个名字下面。检查静默失败:没有异常,没有日志,只有 403

解决方案是让三样东西对齐:

  1. token 创建时写入的 claim 名 — 本教程用 "role"
  2. MapInboundClaims = false — 让中间件不在传入时做任何重命名
  3. RoleClaimType = "role" — 让角色检查去读你实际写入的那个 claim

调试时加一个诊断端点,直接看服务端实际拿到了什么 claims:

group.MapGet("/debug/claims", (ClaimsPrincipal user) =>
        Results.Ok(user.Claims.Select(c => new { c.Type, c.Value })))
    .RequireAuthorization();

用出问题的 token 调它。如果 role claim 的 Type 是一个长 URL 而不是 "role",bug 就找到了。上线前记得删掉这个端点 — 它暴露的信息比你愿意公开的多。

另外三个快速排查点:

什么时候角色检查开始不好用了

角色适合宽泛的用户分组。当你开始为单个能力发明角色名时,就该换工具了。

一开始总是很干净:AdminManagerUser。然后业务要求”Manager 能补货但不能新增产品”,于是你加了 SeniorManager。然后是”客户管理员不能碰订单”,于是有了 CustomerAdminOrderAdmin。作者在生产系统里见过角色从 3 个膨胀到 15 个,端点上的检查变成了 RequireRole("Admin", "OrderAdmin", "SeniorManager", "RegionalLead"),而且没人能回答”RegionalLead 到底能做什么”而不去读完整个代码库。

角色描述的是”某人是谁”。业务反复追问的是”某人能做什么”,这是两个不同的问题。

经验法则:当你的访问模型稳定在 5 个以内分组,规则是”这个组能,那个组不能”时,角色授权是正确的选择。一旦你开始为一个单独的能力发明角色名 — 这意味着你在用角色名编码 claims — 就该换到 claims-based 授权 了。而当 claims 也不够表达时,policy + 自定义 requirement 接上。

这也是这个系列的后两篇文章。

如果你关注 .NET 开发、Web API 工程实践和 AI 辅助编程,可以关注 Aide Hub。这里会继续分享能落地的工具教程、技术观察和项目经验。

参考


Tags


Previous

C# Memento 模式:什么时候该用、什么时候不该用

Next

GitHub Copilot Token 效率优化:缓存、工具搜索与传输层改进