ASP.NET Core 认证与授权(三):请求认证与权限校验

登录接口已经返回 Access Token。接下来客户端带着它调用订单接口:

http 复制代码
GET /api/orders/1001
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

这一串字符不会自动变成当前用户。ASP.NET Core 要先选择认证方案,再由 JwtBearerHandler 验证 Token,生成 ClaimsPrincipal(用户身份对象),最后根据 Endpoint 上的授权规则决定是否执行 Controller。

text 复制代码
Bearer Access Token
    -> Authentication
    -> HttpContext.User
    -> Authorization
    -> Controller

先区分认证和授权

服务器收到请求后依次回答两个问题:

text 复制代码
第一问:你是谁
    Token 缺失、损坏或过期 -> 401

第二问:你能不能访问这个 Endpoint
    身份成立,但权限不满足 -> 403
阶段 职责 结果
Authentication 验证凭证,建立用户身份 HttpContext.User
Authorization 用用户身份匹配 Endpoint 规则 继续、Challenge(要求认证)或 Forbid(拒绝访问)

401 不是权限不足,而是身份没有成立;403 表示已经知道是谁,但不允许访问。


JWT 不是认证过程

JWT 只是 Access Token 的一种格式:

text 复制代码
Header.Payload.Signature

真正执行认证的是认证处理器。对于 Bearer JWT,默认处理器是 JwtBearerHandler

text 复制代码
AuthenticationMiddleware
    -> IAuthenticationService
    -> 根据 Scheme 找 AuthenticationHandler
    -> JwtBearerHandler.HandleAuthenticateAsync()
    -> 验证 Access Token

JWT 的签发和验证发生在不同阶段。用户登录成功时,登录服务负责签发 JWT;后续请求带着 JWT 访问业务接口时,JwtBearerHandler 负责验证它。

因此,AddJwtBearer() 做的是注册验证 JWT 所需的处理器和配置,不会生成 Token。

如果把登录功能拆成独立服务,可以这样理解:

text 复制代码
登录服务
    -> 验证用户名和密码
    -> 生成 Claims
    -> 使用私钥签发 Access Token
    -> 管理 Refresh Token

订单服务
    -> 接收 Authorization: Bearer <access-token>
    -> AddJwtBearer 验证签名、过期时间和 audience
    -> 建立 HttpContext.User
    -> [Authorize] 判断权限

如果登录服务只有允许匿名访问的登录、刷新接口,仅为了签发 Token,确实不需要 AddJwtBearer()。但登录服务通常还有修改密码、退出登录、查看登录设备、吊销会话等受保护接口;这些接口也会接收 Access Token,因此登录服务仍可能需要 AddJwtBearer()

判断标准不是它叫登录服务还是订单服务,而是:这个应用是否需要接收并验证 Bearer Access Token。

在非对称签名方案中,登录服务保存私钥用于签发,订单服务只需要公钥用于验证。订单服务不应拿到签发私钥,否则它也具备伪造 Token 的能力。


Authentication Scheme(认证方案)是什么

一个应用可能同时支持多种凭证:

text 复制代码
Bearer     -> Web API 的 JWT
Cookies    -> 浏览器 Cookie
ApiKey     -> 合作方 API Key

Scheme 是认证方式的名字。框架通过它决定本次请求应该交给哪个 Handler(处理器):

csharp 复制代码
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();

这里把 Bearer 设为默认认证方案。因此接口只写 [Authorize] 时,框架默认找 JwtBearerHandler 验证请求里的 JWT。

若一个应用同时支持 Cookie 和 Bearer,需要先把两种处理器都注册进去:

csharp 复制代码
builder.Services
    .AddAuthentication(options =>
    {
        // 没有明确指定时,默认使用 Bearer
        options.DefaultAuthenticateScheme =
            JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme =
            JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer()  // 注册 Bearer -> JwtBearerHandler
    .AddCookie();    // 注册 Cookies -> CookieAuthenticationHandler

此时有三个接口:

csharp 复制代码
// 没有明确写 Scheme,使用上面配置的默认 Bearer
[Authorize]
[HttpGet("api/orders")]
public IActionResult GetOrders() => Ok();

// 明确要求 Bearer Access Token
[Authorize(AuthenticationSchemes =
    JwtBearerDefaults.AuthenticationScheme)]
[HttpGet("api/mobile/orders")]
public IActionResult GetMobileOrders() => Ok();

// 明确要求浏览器登录 Cookie
[Authorize(AuthenticationSchemes =
    CookieAuthenticationDefaults.AuthenticationScheme)]
[HttpGet("web/profile")]
public IActionResult GetProfile() => Ok();

AuthenticationSchemes 的意思是:当前接口应该调用哪个认证处理器。它不是再注册一次认证,而是从已经注册的 Bearer 和 Cookies 中选择一个。

如果很多接口都要求同一种方案,每个地方重复写 AuthenticationSchemes 会很长,可以把选择封装成 Policy(授权策略):

csharp 复制代码
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("BearerOnly", policy =>
    {
        policy.AddAuthenticationSchemes(
            JwtBearerDefaults.AuthenticationScheme);
        policy.RequireAuthenticatedUser();
    });

    options.AddPolicy("CookieOnly", policy =>
    {
        policy.AddAuthenticationSchemes(
            CookieAuthenticationDefaults.AuthenticationScheme);
        policy.RequireAuthenticatedUser();
    });
});

接口只引用 Policy 名称:

csharp 复制代码
[Authorize(Policy = "BearerOnly")]
public IActionResult GetOrders() => Ok();

[Authorize(Policy = "CookieOnly")]
public IActionResult GetProfile() => Ok();

两种写法最终做的是同一件事:

text 复制代码
AuthenticationSchemes = Bearer
    -> 当前接口直接指定使用 Bearer

Policy = BearerOnly
    -> Policy 内部指定使用 Bearer
    -> 还可以继续添加角色、Claim 等其他要求

仅使用 JWT 的普通 Web API 不需要把事情做复杂,AddAuthentication(Bearer).AddJwtBearer()[Authorize] 就够了。只有一个应用同时支持多种认证方式时,才需要明确选择 Scheme。


JwtBearerHandler 验证什么

管道走到认证中间件时,流程可以简化为:

text 复制代码
读取 Authorization Header
    -> 必须是 Bearer <token>
    -> 解析 JWT 格式
    -> 验证签名
    -> 验证 exp / nbf
    -> 验证 issuer
    -> 验证 audience
    -> 成功后生成 ClaimsIdentity 和 ClaimsPrincipal

常见验证项:

项目 回答的问题
Signature Token 是否由可信签发方签发,内容是否被修改
exp 是否已经过期
nbf 是否尚未到生效时间
iss 是否来自允许的签发方
aud 是否签给当前 API

签名正确只代表 Token 没被篡改,不代表它就适用于当前 API。issueraudience 同样是信任边界的一部分。


ClaimsPrincipal(用户身份对象)如何进入 HttpContext.User

验证成功后,Handler 把 Token 中的 Claims 组成身份对象:

text 复制代码
ClaimsPrincipal
    └── ClaimsIdentity
          ├── sub = 10001
          ├── name = zhang
          ├── role = Admin
          └── permission = order:read

认证中间件把它放到:

csharp 复制代码
HttpContext.User

Controller 中的 User 就是对它的便捷访问:

csharp 复制代码
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var isAdmin = User.IsInRole("Admin");

[Authorize(Roles = "Admin")] 能识别角色,是因为登录签发时写入了 Role Claim,验证后又映射到了 ClaimsPrincipal

需要注意 Claim 类型映射。外部身份系统可能使用 rolesrole 或自定义名称,应通过 RoleClaimTypeNameClaimType 明确约定,否则 Token 里明明有角色,IsInRole 仍可能返回 false。


授权规则从哪里来

Routing 匹配请求后会得到 Endpoint。Controller 或 Action 上的 Attribute 会成为 Endpoint Metadata:

csharp 复制代码
[Authorize]
[HttpGet("{id}")]
public IActionResult Get(int id) { ... }
text 复制代码
Endpoint
    RoutePattern: GET /api/orders/{id}
    Metadata: [Authorize]
    RequestDelegate: MVC

UseAuthorization() 读取 Metadata,组合出 Policy,然后用当前的 HttpContext.User 做判断:

text 复制代码
Endpoint Metadata
    + HttpContext.User
    -> AuthorizationPolicy
    -> Requirements(授权要求)
    -> AuthorizationHandlers(授权处理器)
    -> Success / Challenge / Forbid

完整过程是:

text 复制代码
1. UseRouting
   找到当前请求对应的 Endpoint
   把 Endpoint 放进 HttpContext

2. UseAuthentication
   验证 Access Token
   把 Claims 组成 User,放进 HttpContext.User

3. UseAuthorization
   从 HttpContext 取出 Endpoint
   从 Endpoint.Metadata 取出 [Authorize]、Roles、Policy
   再用 HttpContext.User 自动验证这些要求

4. 验证成功
   才执行 Endpoint,也就是进入 Controller

例如接口标记:

csharp 复制代码
[Authorize(Roles = "Admin")]

Routing 得到的 Metadata 里就有必须是 Admin 的规则;Authentication 得到的 User.Claims 里可能有 role = AdminUseAuthorization 会自动比较两边,业务代码不需要自己再写一次角色判断。

这解释了中间件顺序:

csharp 复制代码
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
  • Routing 先选出 Endpoint,授权才能读取 Metadata
  • Authentication 先建立 User,授权才能判断身份
  • Authorization 通过后,Endpoint 才执行

认证本身并非绝对不能放在 Routing 前,但 Endpoint 授权需要 Routing 的结果;采用上述顺序最清楚,也符合常规模板。


三种常见授权方式

只要求登录

csharp 复制代码
[Authorize]

默认要求 User.Identity.IsAuthenticated == true

按角色

csharp 复制代码
[Authorize(Roles = "Admin")]

适合角色稳定、规则简单的场景。角色过多或业务权限变化频繁时,不应把所有判断都塞进 Roles。

按 Policy

csharp 复制代码
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanDeleteOrder", policy =>
        policy.RequireClaim("permission", "order:delete"));
});
csharp 复制代码
[Authorize(Policy = "CanDeleteOrder")]

Policy(授权策略)是 Requirements(授权要求)的集合;Handler(授权处理器)负责判断 Requirement 是否满足:

text 复制代码
Policy
    -> PermissionRequirement("order:delete")
    -> PermissionAuthorizationHandler
    -> 检查 User.Claims

资源级授权:光看 Claims 还不够

有些权限取决于当前资源:

text 复制代码
用户可以修改自己的订单
客服可以查看所属区域的订单
管理员可以查看全部订单

普通 [Authorize] 在进入 Controller 前执行。此时它知道当前用户是谁,也知道接口要求什么,但还不知道数据库里的 Order 是谁创建的:

text 复制代码
UseAuthorization 执行时:
User.Id = 10001              已知
接口要求用户已登录           已知
订单 5001 的 OwnerUserId     未知,还没有查数据库

因此 [Authorize] 可以判断是否登录、是否有 Admin 角色,却无法自动判断当前用户是不是这张订单的所有者。必须先进入 Action 查询订单。

最直接的写法:在 Action 里判断

csharp 复制代码
var order = await _repository.FindAsync(id);
var userId = User.FindFirstValue(
    ClaimTypes.NameIdentifier);

var isOwner = order.OwnerUserId.ToString() == userId;
var isAdmin = User.IsInRole("Admin");

if (!isOwner && !isAdmin)
    return Forbid();

这段代码没有问题。如果规则只在一个接口使用,直接写 if 反而最清楚。

问题出现在修改订单、删除订单、退款订单等多个接口都需要相同判断时。每个 Controller 复制一遍,规则修改时容易漏掉。因此可以把这段 if 抽进自定义 Handler,统一交给 ASP.NET Core 授权系统调用。

AuthorizeAsync 和 Handler 各负责什么

先看两者的分工:

text 复制代码
AuthorizeAsync
    ASP.NET Core 提供的统一入口
    Controller 调用它

EditOrderAuthorizationHandler
    项目自定义的订单验证逻辑
    框架找到它并调用

HandleRequirementAsync
    Handler 内部的回调方法
    由框架调用,不由 Controller 调用

IAuthorizationService 只有 AuthorizeAsync 这样的统一入口,没有供 Controller 调用的 HandleRequirementAsyncHandleRequirementAsyncAuthorizationHandler 中受保护的重写方法,Controller 也访问不到。

框架调用链是:

text 复制代码
Controller 调用 AuthorizeAsync
    -> 框架根据 Policy 找到 Requirement
    -> 框架从 DI 找到匹配的 Handler
    -> 框架调用 Handler
    -> Handler 基类再调用 HandleRequirementAsync
    -> 框架汇总结果并返回 AuthorizationResult

注册自定义订单规则

先声明 Policy,表示 CanEditOrder 需要满足 EditOrderRequirement

csharp 复制代码
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanEditOrder", policy =>
        policy.AddRequirements(
            new EditOrderRequirement()));
});

builder.Services.AddScoped<
    IAuthorizationHandler,
    EditOrderAuthorizationHandler>();

Requirement 只是这条要求的名称和类型:

csharp 复制代码
public sealed class EditOrderRequirement
    : IAuthorizationRequirement
{
}

Handler 里放刚才从 Controller 抽出来的 if

csharp 复制代码
public sealed class EditOrderAuthorizationHandler
    : AuthorizationHandler<EditOrderRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        EditOrderRequirement requirement,
        Order order)
    {
        var userId = context.User.FindFirstValue(
            ClaimTypes.NameIdentifier);

        var isOwner =
            order.OwnerUserId.ToString() == userId;
        var isAdmin =
            context.User.IsInRole("Admin");

        if (isOwner || isAdmin)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

Controller 不直接认识这个 Handler,只调用框架入口:

csharp 复制代码
var order = await _repository.FindAsync(id);

var result = await _authorizationService.AuthorizeAsync(
    User,             // 当前用户
    order,            // 当前订单
    "CanEditOrder");  // 要执行的 Policy

if (!result.Succeeded)
    return Forbid();

最终过程:

text 复制代码
AuthorizeAsync(User, order, "CanEditOrder")
    -> CanEditOrder Policy
    -> EditOrderRequirement
    -> EditOrderAuthorizationHandler
    -> HandleRequirementAsync(User, Order)
    -> 是订单本人或管理员:成功
    -> 否则:失败

自定义 Handler 就是把散落在多个 Controller 里的验证逻辑统一封装。AuthorizeAsync 的价值是让 Controller 只依赖 ASP.NET Core 的统一授权入口,不直接依赖某个 Handler;一个 Policy 以后还可以组合多个 Requirement 和多个 Handler。


401 和 403 是怎样产生的

授权失败不只是简单返回一个布尔值:

text 复制代码
需要认证,但 User 未认证
    -> Challenge(要求客户端先完成认证)
    -> Bearer Handler 返回 401
    -> 通常附带 WWW-Authenticate: Bearer

User 已认证,但 Requirement 不满足
    -> Forbid(身份已成立,但拒绝访问)
    -> 返回 403

因此:

  • Access Token 缺失、过期、签名错误,访问受保护接口通常是 401
  • Access Token 有效,但缺角色或权限,通常是 403
  • 未标 [Authorize] 的 Endpoint 默认允许匿名,除非配置 Fallback Policy

不要在认证失败时返回 403,也不要把所有异常统一改成 401,否则客户端无法判断是应刷新 Token,还是应提示权限不足。


Token 里的权限不是实时数据

JWT 验证通常不查数据库,所以 Token 中的角色和权限是签发时的快照:

text 复制代码
10:00 签发 Token,role = Admin
10:05 数据库把用户改为普通用户
10:10 旧 Token 仍可能带 role = Admin

直到 Access Token 过期或被额外的失效机制拦截,旧 Claims 仍有效。这也是 Access Token 应短命的原因之一。

对权限变更要求立即生效的系统,可选择:

  • 短 Access Token + Refresh 时重新加载 Claims
  • 在 Token 中加入版本号,API 查询缓存中的当前版本
  • 对高风险接口实时查询权限
  • 使用引用令牌或 Token Introspection(令牌内省,即向认证中心查询 Token 状态)

是否无状态不是越纯越好,应由权限实时性和性能要求决定。


一次受保护请求的完整过程

text 复制代码
GET /api/orders/1001
Authorization: Bearer <accessToken>
    -> Kestrel 解析请求
    -> Routing 匹配 Endpoint
    -> AuthenticationMiddleware
    -> JwtBearerHandler 验签、验期、验 iss / aud
    -> ClaimsPrincipal 写入 HttpContext.User
    -> AuthorizationMiddleware 读取 [Authorize] Metadata
    -> Policy / Requirement / Handler 判断
    -> 未认证:Challenge -> 401
    -> 已认证但无权限:Forbid -> 403
    -> 成功:执行 Controller -> 200

容易混淆的地方

问题 回答
JWT 等于认证吗 不等,JWT 是凭证格式,Handler 才执行认证
HttpContext.User 谁赋值 Authentication Middleware 在 Handler 成功后赋值
[Authorize] 谁执行 Authorization Middleware 读取 Endpoint Metadata
401 和 403 有什么区别 401 身份未成立,403 身份已成立但权限不足
Role 从哪里来 登录签发时写入 Claim,认证后进入 User
Policy 和 Role 怎么选 简单固定角色用 Role,业务规则用 Policy
为什么先 Routing 授权规则位于 Endpoint Metadata
为什么数据库改权限后旧 Token 仍可用 JWT Claims 是签发时的快照

上一篇:登录与令牌签发

下一篇:Refresh Token 与会话失效

相关推荐
她说彩礼65万1 天前
Asp.net core 返回类型
数据库·后端·asp.net
yuyuyui1 天前
.NET CORE 动态扩展Options-配置运行时热更新
.net core
她说彩礼65万2 天前
Asp.net core返回类型总结
后端·asp.net
她说彩礼65万2 天前
Asp.net core ContenResult
后端·asp.net
步步为营DotNet4 天前
借助 Microsoft.Extensions.AI 与 ASP.NET Core 10 实现智能 Web 应用故障预测
人工智能·microsoft·asp.net
品尚公益团队4 天前
C#在asp.net网页开发中的带cookie登录实现
前端·c#·asp.net
time展天15 天前
Dddify:给 ASP.NET Core 项目一套轻量、清晰、可落地的 DDD 基础设施
数据库·后端·asp.net
拉长的苗条5 天前
细说 ASP.NET Cache 及其高级用法
后端·asp.net
醉熏的斑马5 天前
ASP.Net MVC3 】使用Moq让单元测试变得更简单
单元测试·log4j·asp.net