3. 认证方案与中间件
在 ASP.NET Core 中,认证不是一个“开关”,而是一条由方案(Scheme)、处理器(Handler)和中间件(Middleware)共同组成的流水线。很多初学者把 AddAuthentication、AddJwtBearer、UseAuthentication 三行代码当成固定咒语,却说不清它们各自在运行时扮演什么角色、谁先谁后、为什么顺序错了会拿到 401 而不是 403。本节把这条链路拆开讲清楚。
三个核心概念的分工
AuthenticationScheme(认证方案)是一个“名字 + 行为”的注册单元。它本身不做认证,而是把“我叫什么名字”和“由谁来处理我”绑定在一起。例如 Bearer、Cookies、Identity.Application 都是方案名。方案还携带一个 DisplayName,用于在 UI 或日志中展示。
AuthenticationHandler(认证处理器)是真正干活的类,继承自 AuthenticationHandler<TOptions>。它实现 HandleAuthenticateAsync(尝试从请求中解析凭据并构造 ClaimsPrincipal)、HandleChallengeAsync(未认证时如何响应,如返回 401 或跳转登录页)、HandleForbiddenAsync(已认证但无权限时如何响应,如返回 403)。JWT、Cookie、OAuth 各自有对应的内置 Handler。
UseAuthentication 中间件是执行入口。它不负责具体认证逻辑,而是在请求管道中调用 IAuthenticationService.AuthenticateAsync,把当前请求交给默认方案(或指定方案)的 Handler 去处理,并把结果写入 HttpContext.User。
注册:方案与处理器的绑定
注册发生在 Program.cs 的服务配置阶段。下面是一个同时启用 Cookie 与 JWT 的典型示例,注意方案名与默认方案的设置:
builder.Services.AddAuthentication(options =>
{
// 默认方案:未显式指定时使用
options.DefaultAuthenticateScheme = "Bearer";
options.DefaultChallengeScheme = "Bearer";
})
.AddCookie("Cookies", options =>
{
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Account/Denied";
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
})
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://demo.example.com",
ValidateAudience = true,
ValidAudience = "demo-api",
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("demo-secret-key-at-least-32-bytes!"))
};
});
这里 AddCookie("Cookies", ...) 和 AddJwtBearer("Bearer", ...) 各自完成两件事:注册一个方案名,并注册与之关联的 Handler 类型。方案名是后续 [Authorize(AuthenticationSchemes = "Bearer")] 引用的键。若省略方案名,会使用默认名(Cookie 为 Cookies,JWT 为 Bearer)。
UseAuthentication 在遇到需要认证的端点时可能抛出“No authenticationScheme was specified”异常。执行顺序:中间件管道中的位置
注册只是“登记”,真正执行发生在请求管道。中间件顺序是 ASP.NET Core 中最容易出错的地方,认证相关顺序必须满足:UseRouting → UseAuthentication → UseAuthorization → MapControllers。
原因在于:UseRouting 先解析出当前请求匹配的端点(Endpoint),端点元数据里可能带有 [Authorize] 指定的方案;UseAuthentication 据此选择方案并调用 Handler 填充 HttpContext.User;UseAuthorization 再基于已填充的 User 和端点上的策略做授权判断。如果 UseAuthentication 放在 UseRouting 之前,端点尚未确定,认证就不知道该用哪个方案;如果放在 UseAuthorization 之后,授权时 User 还是空的,所有请求都会被判为未认证。
运行时到底发生了什么
假设请求携带 Authorization: Bearer eyJhbGci... 访问一个标注了 [Authorize] 的接口,管道按以下顺序推进:
UseRouting匹配到该接口端点,端点元数据中包含AuthorizeAttribute,其中可能指定了AuthenticationSchemes = "Bearer"。UseAuthentication调用认证服务。认证服务根据端点元数据或默认方案确定使用Bearer方案,找到注册时绑定的JwtBearerHandler。JwtBearerHandler.HandleAuthenticateAsync读取请求头,解析并校验 JWT 的签名、签发者、受众、有效期。校验通过则构造ClaimsPrincipal,写入HttpContext.User,并返回AuthenticateResult.Success。UseAuthorization读取HttpContext.User,结合端点上的策略(如RequireAuthenticatedUser或自定义策略)判断是否放行。- 若认证失败,授权中间件触发
ChallengeAsync,由 Handler 的HandleChallengeAsync返回 401;若认证成功但策略不满足,触发ForbidAsync,返回 403。
多方案场景下的选择逻辑
当系统同时存在 Cookie 和 JWT 时,方案选择遵循“就近优先”原则:端点或控制器上显式指定的 AuthenticationSchemes 优先;未指定时使用 DefaultAuthenticateScheme;若默认方案也未设置,则尝试使用 DefaultScheme。这意味着同一个应用可以做到“网页端走 Cookie、API 端走 JWT”,只要在对应控制器上标注方案名即可:
[Authorize(AuthenticationSchemes = "Bearer")]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(new { user = User.Identity?.Name });
}
UseAuthentication 写在 UseRouting 之前又依赖端点元数据选择方案。此时端点尚未解析,认证只能依赖全局默认方案,容易在多方案应用中选错 Handler,表现为“明明带了 Token 却仍返回 401”。排查顺序问题的实用检查表
| 现象 | 优先检查 | 判断依据 |
|---|---|---|
| 所有请求 401 | UseAuthentication 是否在 UseAuthorization 之前 | 在授权中间件处打断点,观察 User 是否已填充 |
| 带了 Token 仍 401 | 方案名是否与注册名一致 | 对比 AddJwtBearer 的第一个参数与 Authorize 中的 Schemes |
| 返回 403 而非 401 | 认证已通过,问题在授权策略 | 检查 Claims 是否包含策略要求的类型与值 |
| 启动即抛异常 | 是否设置了默认方案 | 异常信息通常为 “No authenticationScheme was specified” |
把这三者的关系记成一句话:方案是名字,处理器是工人,中间件是调度员。注册阶段完成“名字—工人”的绑定,管道阶段由调度员按正确顺序把请求交给工人,工人产出的 ClaimsPrincipal 再交给授权环节消费。顺序一旦错位,整条链路就会在“未认证”状态上卡住,后续所有授权判断都失去意义。