ASP.NET Core Cookie身份验证从零到实战:选型、票据流转与安全排错
发布时间:2026/10/17 13:29:00 锦皓数字建站

如果你最近要接一个内部管理系统或者一个传统的多页面 Web 应用那「用户登录状态怎么保存」这个问题大概率会第一个跳出来。不少人被 JWT 的理念洗脑上来就想上 Token但我在几个真实的 ASP.NET Core 项目里折腾了一圈之后发现Cookie 身份验证不但不老土反而是这一类场景里最不容易翻车的方案。这篇内容不讲身份框架全家桶而是从零开始在 ASP.NET Core 中实现一套可用的 Cookie 身份验证从选型理由、最小配置到登录注销的票据流转、授权策略再到生产环境最容易翻车的细节和对应的排查思路。如果你刚接触 .NET或者想把手里的 Identity 项目拆开看看底层发生了什么这篇文章都值得你读完。1. 选 Cookie 方案前先弄清它和 Token 认证的边界到底在哪1.1 两种方案的本质差别很多人在做选型的时候脑子里只有一句话「JWT 是趋势Cookie 是老古董」。这个判断至少对 ASP.NET Core 的常规 Web 应用来说是错的。要理解为什么你只需要抓住一个核心区别认证状态到底存在谁手里。Cookie 身份验证是服务端状态模型。用户登录成功后服务端把一组声明Claims打包成一个认证票据用数据保护组件加密后写进浏览器的 Cookie。浏览器每次请求自动带上这个 Cookie服务端负责解密、校验、重建用户身份。服务端随时可以推翻这个票据——比如在数据库里标记用户被禁用然后让校验逻辑去检查。Token 认证比如 JWT Bearer是无状态模型。客户端把 Token 存下来每次主动放进请求头里。服务端只验签名不存状态。问题在于一旦 Token 发出去你就很难在到期前把它作废。你想踢一个人下线要么等它自己过期要么维护一个黑名单这又等于引回了服务端状态。我常用一个类比Cookie 认证像酒店房卡客人每次开门刷卡前台都能立刻知道这张卡还能不能用酒店想作废随时作废JWT 像一张盖了章的贵宾券任何柜台只看章是真的就放行等到活动结束才失效你想收回也收不回来。这个差别直接决定了你的需求落在哪边。1.2 哪些场景适合 Cookie哪些场景别硬选结合我实际经手的项目我一般按下面这个表快速判断场景特点推荐方案原因同域名下的 MVC / Razor Pages 多页面应用Cookie浏览器自动携带页面跳转天然无缝服务端可控需要「封号立即失效」「改密码后旧会话作废」Cookie 安全标记每次请求都能检查状态手机 App 调用后端 APIToken原生客户端没有「浏览器自动带 Cookie」的便利第三方开放平台、跨域 API 接入Token调用方不固定Cookie 的域限制太麻烦前后端分离且同站部署的 SPACookie 或 Token 都行Cookie 要额外处理 CSRFToken 要处理刷新机制高并发纯无状态网关分发Token每个节点不共享状态水平扩展最省心这里有一个容易纠结的灰色地带SPA 能不能用 Cookie能而且 SameSiteLax 加合理的 CSRF 防护安全性并不比 Token 差。但很多人嫌 CSRF 麻烦就直接上 Bearer。如果你的前端就是自己家的、域名也是同一个Cookie 方案维护成本反而更低至少不用担心 Token 存 localStorage 被 XSS 一锅端的问题。1.3 和 ASP.NET Core Identity 是什么关系这里必须说清楚ASP.NET Core Identity 这个全家桶底层用的就是 Cookie 认证。它只是帮你把用户表、密码哈希、锁定、找回密码、两因素认证这些杂活全包了。如果你手头是一个轻量业务用户本来就在自己报表里或者你已经有了一套独立的用户中心那完全可以不引入 Identity直接在这套 Cookie 机制上自己搭一层很薄的用户校验。理解底层之后你甚至能更从容地决定哪些功能要自己写、哪些功能不值得重复造轮子。2. 从空项目到能跑Program.cs 中的最小配置链路2.1 前置条件框架版本和依赖在 .NET 6 之后的统一宿主模型里做 Cookie 身份验证不需要额外安装任何第三方包。框架自带了一组认证中间件注册入口就在服务配置里。你只需要确认项目文件用的是 Web SDK也就是.csproj里写的 Sdk 是Microsoft.NET.Sdk.Web而不是控制台应用的Microsoft.NET.Sdk。这个前提很多人忽略了结果照着网上教程加代码编译出来一堆类型找不到。我下面的示例以 .NET 8 的WebApplication写法为主.NET 6、.NET 7 的代码结构几乎一样只有少数 API 命名有微调照着抄问题不大。2.2 一段最小可用配置逐行看它做了什么新建一个空项目之后你的Program.cs里只需要加这几行var builder WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options { options.LoginPath /Account/Login; options.AccessDeniedPath /Account/Denied; options.ExpireTimeSpan TimeSpan.FromHours(2); options.SlidingExpiration true; options.Cookie.Name MyApp.Auth; options.Cookie.HttpOnly true; options.Cookie.SecurePolicy CookieSecurePolicy.SameAsRequest; options.Cookie.SameSite SameSiteMode.Lax; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapDefaultControllerRoute(); app.Run();这段配置里每一行都有它的用途说几个关键点。AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)是注册认证服务的入口参数指定默认认证方案。这个方案名本质是一个字符串常量值就是Cookies。它像一把钥匙把后面的AddCookie、登录时的SignInAsync、中间件的UseAuthentication全部串起来。如果你喜欢完全可以自定义一个方案名比如CustomAuth但需要保证所有地方保持一致默认值少一步出错的机会。AddCookie里面的几个选项是生产环境最容易调整的LoginPath未登录用户访问受保护资源时中间件会把请求重定向到这个地址。需要注意这里配的是路径不是完整 URL。AccessDeniedPath已登录但没有权限时跳转的地址。ExpireTimeSpan和SlidingExpiration控制票据的有效时长和是否滑动续期后面会展开讲。Cookie.Name一定要改成自己网站的唯一标识。如果不改所有使用默认配置的 ASP.NET Core 应用在同域名下都会抢同一个 Cookie 名A 应用写一次B 应用再写一次互相覆盖登录态时好时坏。2.3 中间件顺序错了代码全部白写在 ASP.NET Core 里中间件是管道式执行的请求从第一个中间件进依次往后走然后原路返回。认证链路里有一句经验法则UseAuthentication必须在UseAuthorization之前并且两者都要放在UseRouting之后、终点路由执行之前。我见过最典型的错误是把UseAuthorization写在UseAuthentication前面。这样执行到授权检查时HttpContext.User还是空的中间件根本没来得及读取 Cookie、重建用户身份授权检查看到的就是一个未认证用户于是一律 403 或者被弹回登录页。这种问题表面上看起来跟配置完全无关因为你确实调用了AddAuthentication和AddCookie登录接口也正常返回了但受保护页面就是进不去。如果你用的是较新模板自动生成的代码可能不会显式看到UseRouting因为终结点中间件在多数情况下会被隐式加入。但为了清晰可控我建议你还是把这三行按UseRouting→UseAuthentication→UseAuthorization的顺序显式写出来放在Map端点之前。调试和爬坑的时候少一层隐式行为就是少一个谜团。3. 登录/注销背后的票据流转从 Claims 到加密 Cookie 的全过程3.1 先校验密码再构造身份声明写登录 Action 之前脑子里要有一个硬性原则Claims 是关于用户的事实陈述不是给用户的特权的购物清单。密码、密码哈希这些严禁放进去。真正放进票据里的应该是服务端每次构造身份都需要的稳定信息。先看一段完整的登录处理代码[HttpPost] public async TaskIActionResult Login(LoginViewModel model, string returnUrl) { var user _userRepo.GetByUsername(model.Username); if (user null || !_passwordHasher.Verify(user.PasswordHash, model.Password)) { ModelState.AddModelError(, 用户名或密码错误); return View(model); } var claims new ListClaim { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.Role, user.Role), new Claim(DisplayName, user.DisplayName) }; var identity new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal new ClaimsPrincipal(identity); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties { IsPersistent model.RememberMe, ExpiresUtc DateTimeOffset.UtcNow.AddHours(2), RedirectUri returnUrl }); return Redirect(returnUrl ?? Url.Action(Index, Home)); }密码校验这一步如果你没有用 Identity也绝对不能明文比对。至少要选一个标准的密码哈希方案比如 PBKDF2、bcrypt 或 argon2。市面上有成熟的库可以直接用不要自己发明哈希算法这是认证系统里最没有价值却最容易翻车的自研点。3.2 ClaimsIdentity 的 AuthenticationType 为什么不能省注意new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)的第二个参数。很多人随手传了null或者不传结果登录之后User.Identity.IsAuthenticated一直是false百思不得其解。这个参数叫authenticationType认证类型。在 .NET 的声明模型中只有当ClaimsIdentity.AuthenticationType是非空值时系统才认为这组身份是经过认证建立的。它是框架判断你有没有通过身份认证的核心依据。把它设为 Cookie 方案名就是说这组声明是通过 Cookie 认证机制确立的。少了它你的身份只是一个匿名的声明集合授权中间件不会认为你已登录。3.3 SignInAsync 之后浏览器里到底发生了什么HttpContext.SignInAsync不是一个单纯的「写 Cookie」操作。它的完整流程是把你传入的ClaimsPrincipal和AuthenticationProperties打包成一个认证票据Ticket用数据保护组件加密然后通过响应头里的Set-Cookie写进浏览器。这个过程有几个用户感知不到、但排查时必须知道的细节第一加密用的密钥来自 ASP.NET Core 的数据保护系统Data Protection。这是很多部署问题的根源后面专门讲。第二Cookie 不是明文很多人一听到Cookie 认证就想查用户密码这是误解。加密后的内容没有你的数据保护密钥根本解不开。第三浏览器对单个 Cookie 的大小限制一般在 4KB 左右。如果 Claims 太多、太长整个票据加密后超过限制认证处理器会启动分块逻辑把内容拆成多个 Cookie名字以 C1、C2 结尾分发。分块能做到不代表你可以为所欲为分块越多请求头越大每次请求都在消耗带宽而且某些代理服务器也会限制请求头大小。我的经验是基本信息保持精简头像地址、个性签名这类展示型数据不要在 Claims 里带登录后按用户 ID 再查缓存即可。3.4 注销就是让票据失效注销的代码比登录简单得多[HttpPost] [Authorize] public async TaskIActionResult Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return RedirectToAction(Index, Home); }SignOutAsync会让认证处理器向浏览器下发一个过期的Set-Cookie从而让浏览器清掉这个 Cookie。同时在服务端这一侧并没有一个会话表需要清理因为默认情况下服务端不保存会话状态。这也意味着如果你的业务逻辑里曾经用Session存过用户相关数据注销时要记得一并Session.Clear()否则会出现「人已经退出了但服务端 Session 里还是上一个人的信息」的尴尬局面。4. 保护资源不是只有 [Authorize]策略、角色和自定义校验4.1 控制器、Razor 页面、Minimal API 的三种写法认证和授权是两回事认证回答你是谁授权回答你能干什么。在前面的配置就绪之后保护资源最直接的方式就是[Authorize]。先看控制器和 Action 粒度[Authorize] public class AccountController : Controller { [Authorize(Roles Admin)] public IActionResult AdminPanel() View(); } [Authorize] [AllowAnonymous] public IActionResult Login() View();这段代码表达了三层含义整个AccountController需要登录AdminPanel进一步要求管理员角色Login是登录页任何人包括未登录用户都能访问所以显式加了[AllowAnonymous]覆盖类级别的限制。Razor Pages 的写法是在 PageModel 类上打同样的特性。Minimal API 则是在路由映射后追加RequireAuthorization()粒度可以精确到每个接口。4.2 用 Policy 替代散落各处的角色判断如果你只在控制器里写[Authorize(Roles Admin)]短期内很爽但一旦权限规则变复杂比如「管理后台需要管理员角色且必须属于工程部门」你就要把所有用到这个判断的地方都改一遍。Policy 就是干这个的把校验规则集中注册控制器只声明用哪个策略。builder.Services.AddAuthorization(options { options.AddPolicy(EngineeringManager, policy policy.RequireRole(Manager) .RequireClaim(Department, Engineering)); });控制器里的用法[Authorize(Policy EngineeringManager)] public IActionResult TeamDashboard() View();Policy 的好处是业务规则和控制器解耦。规则变了只改Program.cs里的这一个注册点不用动业务代码。尤其在项目大了以后你会感谢当初没有把权限判断写成一片if (User.IsInRole(...))反复拷贝。4.3 自定义 Requirement 与 Handler处理「资源归属」这类复杂规则角色和部门这种简单断言用 Policy 内置的 API 就够了但真实项目里总有更复杂的规则比如「只有资源的创建者本人能编辑这张单据」。这种规则需要引用资源实例单个[Authorize]特性拿不到资源信息必须写自定义 Requirement 和 Handler。public class IsOwnerRequirement : IAuthorizationRequirement { } public class IsOwnerHandler : AuthorizationHandlerIsOwnerRequirement { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, IsOwnerRequirement requirement) { var userId context.User.FindFirstValue(ClaimTypes.NameIdentifier); if (userId null) return Task.CompletedTask; if (context.Resource is OrderEntity order order.OwnerId.ToString() userId) { context.Succeed(requirement); } return Task.CompletedTask; } }使用前要把 Handler 注册进容器并把策略和 Requirement 关联起来builder.Services.AddSingletonIAuthorizationHandler, IsOwnerHandler(); builder.Services.AddAuthorization(options { options.AddPolicy(IsOwner, policy policy.Requirements.Add(new IsOwnerRequirement())); });然后在 Action 里把资源对象传进授权上下文public async TaskIActionResult Edit(int id) { var order await _orderRepo.GetById(id); if (order null) return NotFound(); var result await _authService.AuthorizeAsync(User, order, IsOwner); if (!result.Succeeded) { return Forbid(); } return View(order); }这套机制需要一点学习成本但它才是授权扩展的正确姿势。写业务系统时间长了你会发现权限规则永远是够用一段时间就会长出新需求提前学会这层抽象后面省的是大把改历史代码的时间。4.4 在视图中根据登录状态做界面适配除了保护接口你还经常需要在页面上根据不同身份显示不同内容。Razor 视图里可以直接用User属性if (User.Identity.IsAuthenticated) { p你好User.Identity.Name/p } if (User.IsInRole(Admin)) { a asp-actionAdminPanel进入管理后台/a }User.IsInRole(Admin)检查的就是 Claims 集合里ClaimTypes.Role类型的声明。这里有个最常见的坑如果你在登录时用的声明类型是自定义字符串Role而不是框架常量ClaimTypes.Role值其实是http://schemas.microsoft.com/ws/2008/06/identity/claims/role那么IsInRole永远返回False。这种问题从代码表面看不出毛病因为两边写的看起来都是Role实际却是两个完全不同的字符串。建议养成习惯角色声明一律用ClaimTypes.Role自定义业务属性再考虑自定义类型。5. Cookie 与票据的安全细节过期策略、Secure/SameSite 与多实例部署5.1 过期时间与滑动续期是一把双刃剑登录的时候设置了ExpireTimeSpan TimeSpan.FromHours(2)这个值定义的是票据的有效时长。到期后即使用户的浏览器还装着那个 Cookie发送到服务端也会被判定为无效然后被弹去登录页。SlidingExpiration true的作用是如果用户在一个有效会话内持续操作并且剩余的存活时间已经不足总时长的一半认证处理器会自动刷新票据重新下发一个有效期延长的新 Cookie。用我的话说它像一个活跃用户自动续命机制。对用户体验来说这很棒刷着刷着不会突然被踢下线。但它也有代价如果你的系统对安全要求极高滑动续期意味着一个被偷走的 Cookie 只要持续「活跃」理论上可以无限续期。这时你就要考虑把SlidingExpiration关掉改用固定时长强制重新登录。也可以两者结合比如绝对过期时间限制为 8 小时滑动续期只管活跃用户超过 8 小时无论如何都要重新登录。具体取舍取决于你的业务愿意在「用户体验」和「安全」之间投哪边。5.2 生产环境必须盯紧的四个 Cookie 属性Cookie 本身是明文存留在浏览器的安全属性全靠那几行配置。我逐个说。HttpOnly默认是true意味着 JavaScript 无法通过document.cookie读取这个 Cookie。这是防 XSS 窃取票据的第一道防线。千万不要为了某些前端耍小聪明需要读 Cookie 就把它改成false那是用最危险的方式换便利。SecurePolicy三个取值None、SameAsRequest、Always。开发环境用SameAsRequest方便你用 http 调试生产环境我建议直接Always并且站点全程 HTTPS。如果生产环境用SameAsRequest且某次用户通过 http 访问浏览器按非 Secure 存储 Cookie之后换成 https 时某些浏览器行为可能不一致令人摸不着头脑。既然生产都是 HTTPS不如就写死Always。SameSite这是防 CSRF 的重要工具。当前 ASP.NET Core 的默认是Lax它允许顶层导航请求带上 Cookie比如从邮件链接进入你的站点但不允许跨站 POST 或 iframe 内部请求携带兼顾用户体验和基础防 CSRF。Strict更严格但会破坏一些第三方跳转场景。None则必须配Secure且意味着你明确要跨站携带 Cookie这是最后的选择不是默认选择。Domain和Path不显式设置时Cookie 只属于当前站点当前根路径。如果你有多个子域需要共享登录态要去翻Cookie.Domain的配置但要注意子域共享等于扩大了攻击面别为了省事随便设成父域。5.3 多实例部署数据保护密钥不能各玩各的这是生产环境掉线率最高的一个坑尤其发生在你上了负载均衡、多副本部署之后。前面说过Cookie 里的认证票据是加密的加密解密都基于数据保护系统的密钥。默认情况下这套密钥会存在每台实例本地而且在一定时间后自动轮换。于是会出现这种情况用户第一次请求打到了实例 AA 用 A 的密钥加密票据下一次请求被负载均衡分到实例 BB 用 B 的密钥解密发现解不开直接当无效票据处理把人弹回登录页。用户的表现就是登录完过几秒就掉了反复登录反复掉。解决办法是让所有实例共享同一套密钥builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(/shared/secrets/aspnet-data-protection)) .SetApplicationName(MyApp);这里把密钥文件持久化到一个所有实例都能读到的共享目录比如挂载的共享存储、对象存储等同时用SetApplicationName固定应用名。SetApplicationName也很关键即使在同一台机器上如果两个不同的应用名数据保护系统也会把它们当成互不相干的租户密钥互不可见。如果你在同一个进程里同时跑多个 HTTP 应用或者同一台服务器部署着多个站点一定要给每个站点设置独立且稳定的应用名。另外切换密钥存储位置时老用户手里的 Cookie 怎么办数据保护系统会保留一部分过期密钥用于解密旧票据新签发的票据统一用新密钥。所以只要不是删掉全部旧密钥用户不会集体掉线。但如果是把密钥存储迁移到新位置且没带旧密钥文件那就相当于全站强制重新登录发布前要评估好时机。5.4 Claims 是「票据内容」不是「万能缓存」最后再敲一下这个点Cookie 里的每一条 Claim都是每次请求都要随请求头发送的数据。你塞一个 500 字符的备注字段进去等于每个请求多背 500 字符走一个来回。当你有大量并发时这会在网关、代理、服务器三层消耗真实资源。更实际的问题是超过浏览器限制之后分块逻辑会让你查问题查到怀疑人生。我一般把握的原则是只放认证和授权直接依赖的字段比如用户 ID、用户名、角色、部门编码。其余展示型信息一律用用户 ID 去缓存或数据库现取。6. 实战排错登录跳转死循环、票据失效与配置未生效6.1 登录成功却一直跳回登录页先查这四件事这个现象可能是 ASP.NET Core Cookie 认证里最常见、也最让人崩溃的问题明明账号密码是对的登录接口也返回了结果页面还是弹回登录页有时候甚至出现刷屏式重定向循环。我排查的顺序固定如下第一看中间件顺序。确认UseAuthentication在UseAuthorization之前。这一步错一切都是白搭。第二看多实例密钥。如果项目是负载均衡多副本部署先排查数据保护密钥是否共享。最简单的验证方式关到只剩一个实例如果问题消失基本就是密钥不一致。第三看 HTTPS 和 SecurePolicy 的组合。如果SecurePolicy是Always而用户通过http://访问网站Cookie 写不进去自然反复未登录。这个在本地调试时最容易忽略因为你明明把服务跑起来了浏览器也弹出了登录成功的提示但下一页一刷新就回去。第四看服务器时间。认证票据的过期时间是 UTC如果服务器时钟不准可能出现票据签发时间在未来或已过期时间判断错误的情况。我遇到过一台没做时间同步的测试机登录后立刻失效排查到最后是时间偏差了几分钟。用 NTP 同步服务器时间是零成本的好习惯。6.2 多实例和重启导致的「突然全体掉线」还有一种很隐蔽的掉线单体应用部署时没问题但你重启一次服务器所有用户都掉了。这是因为默认数据保护密钥虽然在本地持久化但如果你部署环境是容器每次发布重启容器可能连带把密钥目录一起冲掉了。容器实例是全新的密钥也是全新的旧票据自然全部作废。解决方案就是前面说的把密钥持久化到容器外部的共享卷或对象存储。这里要特别提醒如果你们公司用的是容器编排平台别忘了同时检查密钥目录是不是随容器一起被重建。密钥持久化配置写了却挂不上宿主机目录等于没写。6.3 同域多个应用互相覆盖同名 Cookie有一个项目曾经出现「登录态时灵时不灵」的诡异现象用户在小明系统登录后访问同一个域名下的另一个应用就会把小明系统的登录态顶掉。原因就是两个应用都用默认的 Cookie 名互相覆盖。这个问题在你把 Cookie 名字改成MyApp.Auth之后就消失了大半。但如果同一个域名下应用很多还是要养成一个习惯每个应用不仅要有独立的 Cookie 名还要在配置里明确Cookie.Path默认/意味着全站可发多应用共存时就容易眼花。最稳妥的是每个应用有自己的唯一 Cookie 名必要时配合不同的Cookie.Path。6.4 改密码、封号之后旧票据仍然有效怎么破这是 Cookie 认证机制本身的一个特性而不是 Bug因为服务端默认不保存会话状态票据里的信息在到期之前始终被信任。用户改密码后他开始登录时签发的那个 Cookie 依然能通过校验直到过期或被服务端主动拒绝。如果业务允许这种改完密码后旧会话还能撑一会儿那你可以不管。但如果要求严格比如支付系统改密码必须全部失效就需要引入一个「安全标记」的概念。ASP.NET Core Identity 里的 SecurityStamp 就是这个思路用户表里存一个随机值改密码或封号时重新生成它登录时把它作为 Claim 放进票据然后在每次请求的验证事件里去数据库对比当前值不一致就拒绝。实现要点是在AddCookie的Events.OnValidatePrincipal里做额外校验或者写一个ISecurityStampValidator的简化版。代价是每次请求多一次数据库查询或缓存查询但换来了实时撤销能力。具体要不要做取决于你对旧票据残留时间的容忍度。我个人的建议是大多数内部系统没必要上这个机制因为它会让每个请求都背上额外负担但涉及资金、权限变更敏感的模块这个成本值得付。如果你对这个机制有兴趣可以先从短过期时间入手比如把ExpireTimeSpan压到 15 分钟同时使用滑动续期。这样既不牺牲活跃用户的体验又能在密码改动后最多 15 分钟内让旧票据自然淘汰属于低成本 可接受延后的折中方案。6.5 一个我养成的调试习惯最后分享一个实际项目中帮我省过很多时间的习惯在认证相关的事件里打结构化日志。AddCookie的Events里有一堆钩子比如OnSigningIn、OnValidatePrincipal、OnRedirectToLogin、OnRedirectToAccessDenied。刚开始排查认证问题的时候你面对的是一个黑盒不知道中间件有没有读到 Cookie不知道票据有没有通过校验不知道用户有没有被重定向。在这些事件里各打一行日志把用户标识、时间、是否成功、目标地址记下来几乎能一眼定位到问题出在加密、过期、配置还是重定向逻辑上。更实用的一招是针对 API 请求调整重定向逻辑。默认情况下未登录的请求访问受保护资源会被 302 到登录页这对页面请求没问题但对 API 请求很烦人——客户端想要的明明是 401结果收到一个 HTML 跳转。给OnRedirectToLogin加一个分支让它对/api开头的路径直接返回 401options.Events.OnRedirectToLogin context { if (context.Request.Path.StartsWithSegments(/api)) { context.Response.StatusCode StatusCodes.Status401Unauthorized; return Task.CompletedTask; } context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; };这个小改动在前后端分离项目里几乎必做否则前端 fetch 拿到重定向响应时会一脸懵。这也是我每次写 Cookie 认证配置都会顺手加上的标配代码。从选型到配置从登录注销到授权策略从安全细节到排错思路这套流程我在这两年反复走了好几遍踩的坑基本都在上面了。你在实际项目中如果还遇到过别的诡异表现大概率能从这几个方向里找到线索。认清楚自己到底需要的是会话还是票据想明白用户状态变化时你打算怎么处理旧凭证Cookie 身份验证在 ASP.NET Core 里就是一套相当趁手的方案。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。