登录接口已经返回 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。issuer 和 audience 同样是信任边界的一部分。
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 类型映射。外部身份系统可能使用 roles、role 或自定义名称,应通过 RoleClaimType、NameClaimType 明确约定,否则 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 = Admin。UseAuthorization 会自动比较两边,业务代码不需要自己再写一次角色判断。
这解释了中间件顺序:
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 调用的 HandleRequirementAsync。HandleRequirementAsync 是 AuthorizationHandler 中受保护的重写方法,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 是签发时的快照 |
上一篇:登录与令牌签发