☰
.NET Core认证模型全解:方案、处理器与认证授权边界
2026/10/14 6:12:38 网站建设 项目流程

先说句得罪人的话:很多写了两三年 .NET 的人,可能都没真正理解“认证管理”这四个字在框架里到底指什么。我见过不少项目,Program.cs 里AddAuthentication和AddAuthorization两行挨着写,后面跟着AddCookie或AddJwtBearer,至于为什么分两行、中间件顺序为什么不能换、默认方案到底默认了什么,问起来就含糊了。今天这篇文章,我想把 .NET Core 这套认证模型掰开揉碎讲清楚——它从 .NET Core 2.0 引入,到现在 .NET 8 时代,核心抽象基本没变过。如果你正准备做登录、被 401/302/403 这几个状态码折磨,或者在多方案并存的项目里调试到崩溃,这篇文章应该能帮你省下不少时间。

1. 先厘清边界:认证管的是“你是谁”,授权才是“能做什么”

1.1 被混为一谈的两件事

我经常在交流群里看到有人把认证和授权当成一回事,甚至在一段代码里既做登录校验又做权限判断,最后改起来痛不欲生。其实这两件事的边界非常清晰:认证(Authentication)回答“你是谁”,授权(Authorization)回答“你能做什么”。

举个例子:你进公司大门要刷工卡,门禁系统确认这张卡属于张三,这是认证;张三刷卡后只能进办公区,不能进机房和财务室,这是授权。对应到 Web 系统里,用户输入用户名密码登录成功后,系统知道“当前操作者是张三”,这是认证;但张三能不能打开用户管理页面、能不能调用删除接口,需要看他的角色和权限,这是授权。

在 .NET Core 中,[Authorize]特性其实同时牵动这两个环节:请求未认证时,触发的是 Challenge,也就是认证环节要你登录;请求已认证但角色或策略不满足时,触发的是 Forbid,也就是授权环节把你挡住。搞清这个顺序,后面定位问题会顺很多。

1.2 框架为什么要分开注册

看一段最常见的启动代码:

builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }).AddCookie(); builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin")); });

两行注册各自负责一块:AddAuthentication注册的是“有多少种证明身份的方式”,AddAuthorization注册的是“有了身份之后,什么样的请求才被放行”。框架刻意把它们分开,好处是两者可以独立演进。你今天用 Cookie 登录,明天想换成 JWT,授权策略代码完全不用动;反过来,你新增了一条授权策略,也不需要去碰认证方案。

这也是我总跟身边同事强调的一点:不要因为AddAuthentication和AddAuthorization长得像,就把它们当成同一个东西。前者是“门禁系统”,后者是“房间权限表”。

1.3 中间件顺序背后是一条完整流水线

请求管道里的中间件顺序不是随便排的:

app.UseRouting(); app.UseAuthentication(); // 解析凭证,生成 HttpContext.User app.UseAuthorization(); // 根据 User 判断能否访问 app.UseEndpoints(...);

UseAuthentication的核心工作是把请求里携带的凭证翻译成身份主体,也就是设置HttpContext.User;而UseAuthorization和后续的授权过滤器,都是在消费这个User。如果顺序倒了,授权环节拿到的User永远是空,任何带[Authorize]的接口都会被视为未认证,表现就是接口 401,或者页面被反复重定向到登录页。

我见过一个真实案例:同事把UseAuthorization写到了UseAuthentication前面,结果登录页刷了十几分钟都没进去,浏览器一直 302 循环。后来调整顺序,问题立刻消失。所以这条顺序基本可以当铁律记下来:Routing → Authentication → Authorization → Endpoints。

2. 认证方案与认证处理器:框架里最核心的抽象

2.1 方案是一次注册,处理器才是真正干活的人

如果你去翻框架源码,会发现AuthenticationScheme这个类本身很“轻”,它只是存了方案名称、显示名称和处理器的类型。真正做校验逻辑的是AuthenticationHandler<TOptions>这个抽象类。

打个比方:方案名就像餐厅菜单上的菜名,“宫保鸡丁”;处理器就是后厨里真正炒这道菜的师傅。你注册一个名为Cookie的方案,等于告诉框架“菜单上有宫保鸡丁这道菜”,而CookieAuthenticationHandler就是那个负责出菜的厨师。请求进来时,框架根据方案名找到对应的处理器,调用它的AuthenticateAsync方法,然后处理器开始解析请求里的 Cookie、Token 或 Header。

内置方案里最常见的名字是Cookie和Bearer,它们对应的处理器分别是CookieAuthenticationHandler和JwtBearerHandler。一个应用里可以同时注册多个方案,互不干扰。

2.2 请求经过认证中间件后发生了什么

一条带凭证的请求进入认证中间件后,大致经历这几个步骤:

  1. 框架从认证选项中读取DefaultAuthenticateScheme,确定本次请求默认使用哪个方案。
  2. 根据方案名找到对应的认证处理器。
  3. 调用处理器内部的HandleAuthenticateAsync方法,这个方法负责从请求中提取凭证并验证。
  4. 处理器返回一个AuthenticateResult,表示认证成功、失败或无结果。
  5. 认证成功时,处理器会返回一个AuthenticationTicket,里面装着ClaimsPrincipal和AuthenticationProperties。
  6. 框架把ClaimsPrincipal赋给HttpContext.User,后续代码就能通过User.Identity.IsAuthenticated、User.IsInRole(...)等方式读取身份信息。

整个过程对业务代码几乎是透明的。你用[Authorize]或者User.Identity.Name时,背后都是这套机制在工作。

2.3 AuthenticateResult 的三种归属

刚开始写自定义认证逻辑的人,很容易把认证结果想得太简单:成功或失败。但框架里其实有三种结果:

  • Success:请求携带了凭证,且凭证有效。
  • Fail:请求携带了凭证,但凭证无效,比如签名错误、过期、篡改。
  • NoResult:请求压根没携带该方案认识的凭证。

NoResult和Fail在框架语义上有本质区别。NoResult 表示“我没参与”,Fail 表示“我参与了但被拒绝了”。这个区别在多方案并存时尤其重要:一个方案返回 NoResult,授权策略还会继续尝试其他方案;如果一个方案明确 Fail,通常就直接判定认证失败。

我之前给一个内部网关做排障时,就是靠区分这两种结果定位问题的:日志里大量 Fail 说明凭证确实带了但验证不过,要去看密钥和过期时间;如果全是 NoResult,说明请求可能走了别的认证通道,要检查默认方案配置。

2.4 默认方案值:四个默认值各管一段逻辑

AuthenticationOptions里有一组默认值,每个都影响不同的行为阶段:

属性控制场景常见用法
DefaultScheme兜底默认值,其他未设置时回退到这里设置主要的认证方案
DefaultAuthenticateScheme认证中间件判断“当前用户是谁”与 DefaultScheme 一致
DefaultChallengeScheme未认证时触发挑战,决定返回 401 还是重定向登录页API 项目设为 Bearer
DefaultForbidScheme已认证但授权拒绝时触发 Forbid通常与 Challenge 相同

另外还有DefaultSignInScheme和DefaultSignOutScheme,主要用于SignInAsync、SignOutAsync这类登录登出操作,Cookie 方案最常见。

很多人写AddAuthentication("Bearer").AddJwtBearer(),其实就是把DefaultAuthenticateScheme和DefaultChallengeScheme同时设成了 Bearer。如果你只设了认证方案没设 Challenge,未登录请求访问受保护接口时,框架会直接抛异常,错误信息是No authenticationScheme was specified, and there was no DefaultChallengeScheme found。看到这句话别慌,去AddAuthentication的 options 里补上DefaultChallengeScheme就行。

3. Cookie认证与JWT Bearer认证:两种主流方案的取舍

3.1 Cookie认证:适合传统Web应用

Cookie 认证的运作流程是:用户登录成功后,服务端生成一个加密票据,通过SignInAsync写入 Cookie;后续请求带着 Cookie 进来,认证处理器解密票据、校验有效性,再把里面的ClaimsPrincipal还原出来。票据本身是加密的,框架用 Data Protection 机制完成加解密和防篡改。

登录代码长这样:

var claims = new List<Claim> { new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(ClaimTypes.Role, role) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = true } );

这里有个超高频的坑:ClaimsIdentity构造函数的第二个参数(authenticationType)不能为空。如果漏传,User.Identity.IsAuthenticated永远是 false,[Authorize]怎么都不认账。我见过有人排查了半天,最后发现是这里少传了一个参数。

常见的 Cookie 配置项包括:

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/Account/Login"; options.AccessDeniedPath = "/Account/Forbidden"; options.Cookie.Name = "AppSession"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.ExpireTimeSpan = TimeSpan.FromHours(2); options.SlidingExpiration = true; });

SlidingExpiration是滑动过期:用户持续操作时,票据会自动续期,适合内部管理系统这种长期在线的场景。登出调用HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme)即可。

3.2 JWT Bearer认证:适合API与前后端分离

JWT Bearer 的流程是:认证服务签发一个 Token,客户端每次请求放在Authorization: Bearer <token>头里;服务端解析 Token、验证签名和声明,然后构建身份。

配置核心是TokenValidationParameters:

builder.Services.AddAuthentication("Bearer") .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = config["Jwt:Issuer"], ValidAudience = config["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(config["Jwt:Key"])) }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { // SignalR / WebSocket 场景:Token 可能放在 query string 里 var token = context.Request.Query["access_token"]; if (!string.IsNullOrEmpty(token) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) { context.Token = token; } return Task.CompletedTask; }, OnTokenValidated = async context => { // Token 本身有效,但还需要从数据库加载最新角色和权限 var userService = context.HttpContext.RequestServices .GetRequiredService<IUserService>(); var userId = context.Principal.FindFirstValue(ClaimTypes.NameIdentifier); var user = await userService.GetByIdAsync(userId); if (user == null) { context.Fail("user not exists"); return; } var extraIdentity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, user.Role), new Claim("dept", user.Department) }); context.Principal.AddIdentity(extraIdentity); } }; });

OnTokenValidated是一个很实用的扩展点。JWT 本身是静态声明,用户角色变了、被禁用了,Token 在过期前都不会感知。要解决这个问题,可以在每次请求验证 Token 通过后,再从数据库或缓存加载最新信息,挂到请求身份上。

还有一个总被忽略的参数:ClockSkew,默认值是 5 分钟。也就是说,Token 明明过期了,但在过期时间后 5 分钟内依然可能被接受。测试时如果发现“过期 Token 还能用”,先别怀疑人生,去检查ClockSkew是不是没设成TimeSpan.Zero。

3.3 怎么选:一张对比表加三条建议

我把两种方案的核心差异整理成了一张表:

对比项Cookie 认证JWT Bearer
凭证形态服务端加密 Cookie,浏览器自动携带客户端持有 Token,请求头手动携带
服务端状态认证本身无状态,但常配合会话存储无状态,Token 自带声明
常见场景MVC、Blazor Server、传统多页应用Web API、SPA、移动端、微服务
注销/吊销服务端清除票据、浏览器删 Cookie 即可过期前难吊销,常需黑名单机制
CSRF 风险需要单独防 CSRF请求头携带 Token,相对不敏感,但仍需防 XSS
跨域/跨服务受 SameSite、跨域限制影响更适合跨服务传播身份

选型建议我只有三条:

  • 内部管理系统、后台管理系统,优先用 Cookie,开发量小,防 CSRF 的负担可控。
  • 对外提供 API、小程序后端、前后端完全分离,优先用 JWT,客户端接入方便。
  • 一个应用里可以混用:页面登录用 Cookie,接口鉴权用 JWT,两者共享同一份登录态。

别为了“无状态”这三个字就无脑上 JWT。内部系统里 Cookie 方案往往更省心,JWT 的真正优势在于跨服务传播和客户端无关性。

4. 自定义认证处理器:对接内部凭证与老系统

4.1 什么场景值得写一个自定义Handler

不是所有凭证都是标准用户名密码或 Bearer Token。现实中常见这些情况:

  • 内部服务 A 调用服务 B,用共享请求头X-Api-Key做校验。
  • 老系统改造,前端带一个旧的 Ticket,需要解密后才能确认身份。
  • 网关在入口处完成认证后,把用户信息放进请求头,下游服务直接信任这些 Header。

这些场景下,写一个自定义认证处理器是最优雅的做法。认证模型把“凭证从哪来、怎么验证、如何生成身份”封装在一个 Handler 里,业务代码只需要[Authorize],完全不用关心底层凭证长什么样。

4.2 从AuthenticationHandler继承到注册启用

先定义选项类,用于接收自定义配置:

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { public string AllowedKey { get; set; } = string.Empty; }

再写处理器:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions> { protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue("X-Api-Key", out var providedKey)) { return AuthenticateResult.NoResult(); } var key = providedKey.ToString(); if (!string.Equals(key, Options.AllowedKey, StringComparison.Ordinal)) { return AuthenticateResult.Fail("invalid api key"); } var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "internal-service"), new Claim(ClaimTypes.Role, "service"), new Claim("client", "partner-a") }, Scheme.Name); var ticket = new AuthenticationTicket( new ClaimsPrincipal(identity), Scheme.Name); return AuthenticateResult.Success(ticket); } }

Scheme.Name是注册方案时用的名字,这里把它作为ClaimsIdentity的 authenticationType,保证IsAuthenticated为 true。返回NoResult表示“我不知道这个凭证”,返回Fail则明确拒绝。

注册也很简单:

builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = "ApiKey"; options.DefaultChallengeScheme = "ApiKey"; }) .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>( "ApiKey", "API Key Authentication", options => { options.AllowedKey = configuration["Auth:ApiKey"]; });

配置完成后,控制器接口直接[Authorize]就能用。如果希望更精细,还可以定义一个授权策略,限定只有特定角色能调用:

builder.Services.AddAuthorization(options => { options.AddPolicy("InternalService", policy => { policy.AddAuthenticationSchemes("ApiKey"); policy.RequireRole("service"); }); });

控制器上改成[Authorize(Policy = "InternalService")]即可。

4.3 自定义Challenge行为与统一错误体

默认情况下,AuthenticationHandler的挑战行为通常是返回 401 状态码。但对 API 项目来说,只返回一个光秃秃的状态码不够友好,前端希望拿到统一格式的错误信息。这时可以重写HandleChallengeAsync:

protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { Response.StatusCode = StatusCodes.Status401Unauthorized; await Response.WriteAsJsonAsync(new { code = 401, message = "凭证缺失或无效" }); }

同样的道理,授权失败时默认返回 403,也可以重写HandleForbidAsync输出统一的 403 错误体。这样前后端联调时,错误格式稳定,处理起来会舒服很多。

4.4 多方案并存:默认方案与授权策略如何配合

一个服务里同时存在 JWT 和 API Key 两种认证方式很常见:前端用 JWT,内部服务用 API Key。这时有个关键认知:认证中间件只会按照 DefaultAuthenticateScheme 尝试一种方案。你不能简单地把两个方案都设为默认。

正确的做法是把多种方案交给授权策略去组合:

builder.Services.AddAuthorization(options => { options.AddPolicy("ApiOrInternal", policy => { policy.AddAuthenticationSchemes("Bearer", "ApiKey"); policy.RequireAuthenticatedUser(); }); });

使用AddAuthenticationSchemes声明多个方案后,授权阶段会逐个尝试这些方案,只要其中一个认证成功,就认为用户已认证。这个机制特别适合“对内对外两套认证方式、但接口逻辑完全一致”的场景。

5. 常见定位过程:现象、排查链路与修复

5.1 登录后反复跳回登录页

现象:用户登录成功后跳转首页,结果马上又被 302 弹回登录页,浏览器里看网络请求,发现登录请求是成功的,但后续每个页面请求都带了 Cookie,却一直回到登录页。

排查链路:先在浏览器开发者工具里确认请求带上了认证 Cookie,且 Cookie 的 HttpOnly 属性不影响发送。然后看服务器日志,重点排查两方面:一是登录时ClaimsIdentity的 authenticationType 是否填写,二是 Data Protection 密钥是否一致。多实例部署时,如果每台服务器各有各的密钥环,A 服务器签发的 Cookie 拿到 B 服务器上解密失败,用户就会被反复踢回登录页。

修复:开发环境重启后 Cookie 失效是正常现象,因为密钥默认存在本地;生产多实例环境需要配置持久化密钥存储,让所有实例共享同一套密钥环。这个坑在部署到负载均衡后面时非常典型。

5.2 JWT里的角色怎么突然不认了

现象:Token 里明明有role=admin,接口用[Authorize(Roles = "admin")]却一直 403,User.IsInRole("admin")返回 false。

排查链路:在OnTokenValidated里把所有 Claims 打出来,通常会发现role这个 Claim 确实存在,但它的 Claim Type 是短名"role",而IsInRole默认查找的是ClaimTypes.Role对应的长 URI。两边对不上,自然判定失败。JWT 签发端通常会用短声明名,而后端的 Roles 校验需要把短名映射到角色声明类型。

修复:在TokenValidationParameters里配置:

RoleClaimType = "role", NameClaimType = "name"

或者在签发 Token 时直接用ClaimTypes.Role作为 Claim Type。另外,如果发现某些声明被框架自动改成了长 URI 导致取值取不到,检查是否启用了默认的入站声明映射,必要时清空映射关系再取。

5.3 API接口返回302而不是401

现象:前后端分离的项目,前端用 JWT,未登录访问接口时,返回的不是 401 JSON,而是 302 重定向到登录页,前端拿到一整个 HTML 页面。

排查链路:先看启动代码里DefaultChallengeScheme指向谁。如果你同时注册了 Cookie 和 JWT,但默认 Challenge 被设置成了 Cookie,未认证请求会被CookieAuthenticationHandler处理,触发重定向到 LoginPath。也可能是 Cookie 的OnRedirectToLogin事件里做了 API 路径判断,但你的新接口前缀没被覆盖到。

修复:API 项目里把DefaultChallengeScheme显式设为 Bearer,或者重写 Cookie 的重定向事件,对/api开头的请求直接返回 401:

options.Events.OnRedirectToLogin = ctx => { if (ctx.Request.Path.StartsWithSegments("/api")) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } ctx.Response.Redirect(ctx.RedirectUri); return Task.CompletedTask; };

5.4 认证成功但接口仍然403

现象:用户已经登录,User.Identity.IsAuthenticated为 true,但访问某个带角色要求的接口时始终 403。

排查链路:这个阶段的问题通常不在认证,而在授权。先确认授权策略用的是RequireRole还是RequireClaim,再看用户的 Claims 里有没有对应声明。如果角色是通过OnTokenValidated后加的 Identity,要确认是否真的添加成功,以及RoleClaimType是否匹配。

修复:把最终生效的 User Claims 打印出来看一遍,基本能定位是“角色缺失”还是“声明类型不匹配”。403 意味着身份已经被确认,只是权限不够,不要再去翻认证管道的代码。

5.5 一套通用的排障流程

结合这些案例,我总结了一套自己的排障顺序。先抓 HTTP 层面的表现:浏览器 F12 里看请求带什么凭证、返回什么状态码、有没有重定向。如果状态码和预期不符,大概率是默认方案或 Challenge 配置问题。如果状态码正确但权限判断不对,就去打印 User 的所有 Claims,看声明类型和值。最后才去翻代码逻辑。

如果项目里注册了多个认证方案,想快速定位是哪个方案生效,可以在认证中间件之后临时加一个小中间件,遍历所有方案逐个调用AuthenticateAsync,把每个方案的结果打出来:

app.Use(async (context, next) => { var schemeProvider = context.RequestServices .GetRequiredService<IAuthenticationSchemeProvider>(); foreach (var scheme in await schemeProvider.GetAllSchemesAsync()) { var result = await context.AuthenticateAsync(scheme.Name); Console.WriteLine($"{scheme.Name}: {result.Succeeded}"); } await next(); });

这段代码只建议排障时临时使用,每次请求都会重复执行认证逻辑,有一定开销。但它能让你一眼看出每个方案到底返回 Success、Fail 还是 NoResult,比对着配置文件猜要高效得多。

最后说一个我自己的工作习惯。做认证相关排查时,我很少一开始就盯着代码看,而是先问三个问题:凭证从哪来、哪个处理器在验证、验证结果去了哪里。认证方案再多,HttpContext.User最终只有两种状态——有身份或者没有。你只需要沿着这条链路往下走,问题通常很快就会浮出水面。希望这篇文章能帮你少走几次弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询