资讯动态

.NET Core认证模型全解:方案、处理器与认证授权边界

发布时间:2026/10/14 6:12:44 来源:尧图企业网站定制
先说句得罪人的话很多写了两三年 .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这个类本身很“轻”它只是存了方案名称、显示名称和处理器的类型。真正做校验逻辑的是AuthenticationHandlerTOptions这个抽象类。打个比方方案名就像餐厅菜单上的菜名“宫保鸡丁”处理器就是后厨里真正炒这道菜的师傅。你注册一个名为Cookie的方案等于告诉框架“菜单上有宫保鸡丁这道菜”而CookieAuthenticationHandler就是那个负责出菜的厨师。请求进来时框架根据方案名找到对应的处理器调用它的AuthenticateAsync方法然后处理器开始解析请求里的 Cookie、Token 或 Header。内置方案里最常见的名字是Cookie和Bearer它们对应的处理器分别是CookieAuthenticationHandler和JwtBearerHandler。一个应用里可以同时注册多个方案互不干扰。2.2 请求经过认证中间件后发生了什么一条带凭证的请求进入认证中间件后大致经历这几个步骤框架从认证选项中读取DefaultAuthenticateScheme确定本次请求默认使用哪个方案。根据方案名找到对应的认证处理器。调用处理器内部的HandleAuthenticateAsync方法这个方法负责从请求中提取凭证并验证。处理器返回一个AuthenticateResult表示认证成功、失败或无结果。认证成功时处理器会返回一个AuthenticationTicket里面装着ClaimsPrincipal和AuthenticationProperties。框架把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 项目设为 BearerDefaultForbidScheme已认证但授权拒绝时触发 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 ListClaim { 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、验证签名和声明然后构建身份。配置核心是TokenValidationParametersbuilder.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 .GetRequiredServiceIUserService(); 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 : AuthenticationHandlerApiKeyAuthenticationOptions { protected override async TaskAuthenticateResult 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; }) .AddSchemeApiKeyAuthenticationOptions, 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 项目来说只返回一个光秃秃的状态码不够友好前端希望拿到统一格式的错误信息。这时可以重写HandleChallengeAsyncprotected 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 里明明有roleadmin接口用[Authorize(Roles admin)]却一直 403User.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开头的请求直接返回 401options.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 .GetRequiredServiceIAuthenticationSchemeProvider(); 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最终只有两种状态——有身份或者没有。你只需要沿着这条链路往下走问题通常很快就会浮出水面。希望这篇文章能帮你少走几次弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑