ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Blazor全栈开发中的认证与授权:从概念到实战

Blazor全栈开发中的认证与授权:从概念到实战 做 Blazor 全栈开发绕不开认证与授权。我在实际业务里见过太多把这两个词混在一起聊的同事——登录做完了就当权限没问题页面能打开就当数据安全了结果一上生产就出乱子。这篇文章我就把自己在 Blazor 项目里做认证与授权的完整经验整理出来包括 Server 和 WebAssembly 两种托管模型下的方案差异、具体落地步骤、常见坑和排查思路适合正在用 Blazor 做企业级应用的开发者参考。Blazor 全栈开发中的认证与授权不只是一个登录页面和几个角色判断。它关系到数据是否会被未授权访问、接口是否能被随意调用、用户在刷新页面之后是否还能保持身份。我平时接触过不少刚转过来的后端同事觉得前端只要把菜单藏起来就算做了权限也遇到过纯前端背景的同学以为只要后端接口返回数据前端就不用再管身份。这两种理解在 Blazor 这里都容易栽跟头。认证Authentication解决的是“你是谁”授权Authorization解决的是“你能做什么”。这两件事必须拆开设计混在一起后续一定改不动。后面我会把两种托管模型下的实现路径分开讲因为 Blazor Server 和 Blazor WebAssembly 在这件事上的处理方式差别非常大。1. 先理清概念认证和授权到底各解决什么问题1.1 认证证明“你是你”认证回答的是“你是谁”这个问题。现实里最形象的场景就是小区门禁你刷卡、刷脸或者输密码门禁系统确认你是登记过的住户然后放你进去。它只看“你是不是一个有效身份”并不关心你能进哪栋楼、能开哪扇门。在 Blazor 项目里认证的常见手段有这么几类Cookie 认证登录成功后由服务端在浏览器种下一个会话 Cookie之后的每次请求都自动携带服务端根据 Cookie 识别身份。Blazor Server 用得最多。JWT Token 认证登录成功后服务端签发一个包含用户信息和有效期的令牌客户端保存并在后续请求中带上。这是 Blazor WebAssembly 的标准做法。第三方 OAuth / OpenID Connect把身份验证外包给微信、企业微信、GitHub 等身份提供方适合企业内部账号体系未统一或需要联合登录的场景。这个选择本身没有绝对的对错要看你的托管模型和部署环境。我见过不少团队在项目初期直接上 IdentityServer结果光是证书、客户端、作用域这些概念就让前端同事困惑了很久。其实如果你只是做一个内部管理系统用 ASP.NET Core Identity 自带的 Cookie 登录基本就够用了真正需要外部身份源、多客户端统一认证时再去引入更重的方案也不迟。1.2 授权决定“你能干什么”授权回答的是“你能访问哪些资源、执行哪些操作”。还是用门禁类比刷卡进了小区大门只是通过了认证但单元门、电梯楼层、地下车库的闸机往往还要二次验证那一层才是授权。在 Blazor 应用里授权体现在几个层面路由层面某个页面只有登录用户才能访问某些页面只允许管理员访问。界面层面菜单项、按钮、工具栏根据用户权限显示或隐藏。数据层面接口只返回当前用户有权看到的数据。操作层面比如创建、编辑、删除操作需要特定权限。这里我要强调一个经验界面上的隐藏不等于真正的授权。菜单不显示“删除用户”按钮只意味着普通用户看不到入口但如果有人直接构造请求调用删除接口后端没有校验数据照样会被删掉。所以我做权限时永远把服务端当作最后一道防线前端只负责提升用户体验不负责保证安全。1.3 为什么一开始就把边界划清楚很多人做登录时顺手把角色写在 Session 里然后在每个页面判断角色是否等于 Admin。这个做法在小项目里能用但一旦权限规则变多比如“区域经理可以查看本区域的订单但不能改价”“财务专员只能导出本部门的报表”直接在页面里写死角色判断就会变成一团乱麻。我的建议是认证和授权分开建模账号体系归账号体系权限体系归权限体系。用户在登录时只做身份确认拿到身份之后系统根据用户的角色、声明、策略去判断权限。业务代码里尽量不出现“这个用户是 Admin”这种硬编码判断而是用策略名或权限代码去表达“这个操作需要什么能力”。这样以后增加角色、调整权限都不需要改一堆页面。还有一个小提醒授权规则必须尽早设计而不是写完二三十个页面之后再补。权限模型没定清楚后面每个页面都要回去改成本比一开始多花一天做设计要高得多。我参与过的最痛苦的一个项目就是上线之后被安全团队查出水平越权所有涉及“按用户过滤数据”的接口都要挨个补那感觉就像在已经封顶的楼里重新改水电。2. Blazor 两种托管模型下的认证差异Blazor 全栈开发里有一个特别容易让新人懵的地方同样一个组件在 Server 端和 WebAssembly 端写起来差不多但认证授权的内部机制完全是两套。如果你用默认模板新建项目会发现两者的配置代码几乎一样这是因为微软把很多细节封装掉了但它背后做的事情并不一样。搞清楚这两套机制排查问题时会省很多力气。2.1 Blazor Server服务端会话与 SignalRBlazor Server 模式下整个应用的 UI 逻辑跑在服务端浏览器只负责渲染和回传事件两者的通信走的是 SignalR本质上是 WebSocket 或长轮询。认证发生在 HTTP 请求管道里在你第一次请求页面、建立 SignalR 连接时就已经完成。实际项目里Blazor Server 一般搭配 Cookie 认证。用户访问登录页面、提交用户名密码服务端验证通过后建立认证 CookieSignalR 连接建立时服务端会基于这个认证状态来初始化整个 Circuit可以理解成一个服务于当前用户会话的状态容器。之后组件里读取 AuthenticationStateProvider拿到的是服务端维护的真实身份。这里有两点容易踩坑SignalR 连接是持久连接它不会像普通 HTTP 请求那样每次重新走认证管道。所以如果你的 Cookie 过期了、身份被篡改了已经建立起来的 Circuit 可能还会继续工作一段时间或者说重连时会出现身份不一致。在 Blazor Server 里所有用户代码都跑在服务端你可以在授权通过后放心地访问数据库、读取机密配置。这一点比 WebAssembly 省心很多。2.2 Blazor WebAssembly无状态与 TokenBlazor WebAssembly 模式下应用代码完全下载到浏览器里运行。这时没有服务端会话也没有 SignalR 替你维持状态。最常见的方案是用 JWT Token用户登录成功后服务端签发 Token客户端保存下来之后每次调用 API 时手动带上 Token。这也意味着WebAssembly 里组件读取到的 AuthenticationState严格来说只是客户端自己根据 Token 构建出来的一个身份视图。Token 本身是签名过的理论上可信但整个状态管理、保存、刷新、失效处理全部要自己写。很多团队第一次从 Server 转到 Wasm 时最大的感受就是怎么刷新一下页面就变成未登录了就是因为客户端没有把 Token 恢复成 AuthenticationState。2.3 托管模型的选型逻辑我在项目里选型时会先问一个问题这套系统重交互吗对实时性要求高不高如果是一个内部后台比如订单管理、报表查看、权限配置Blazor Server 开发效率高、认证实现简单非常合适。如果应用需要弱网环境使用、希望部署成纯静态资源或者有大量公开页面需要承受高并发那 Blazor WebAssembly 更合适代价就是要把认证授权完整做一遍。还有一个混合方案用 Blazor WebAssembly 做前端用 ASP.NET Core Web API 做后端。这是我个人最常用的一种形态也是后面几节我会展开讲的。不管是哪种模型都有一个核心原则身份状态必须放在一个统一的地方管理不要在组件里各自为政。后面我会展开讲自定义 AuthenticationStateProvider就是干这件事的。3. 在 Blazor Server 中落地认证从 Identity 到 CookieBlazor Server 的认证落地本质还是在 ASP.NET Core 的认证管道上做文章。理解这一点很多配置就不会觉得玄。3.1 引入 ASP.NET Core IdentityASP.NET Core Identity 是一套完整的账号体系用户注册、密码哈希、登录状态、用户 Claim、角色管理全都内置。对于大多数业务系统直接基于它做扩展就够了。在 Program.cs 里通常这样配置builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); builder.Services.AddIdentityApplicationUser, IdentityRole(options { options.Password.RequiredLength 8; options.Password.RequireNonAlphanumeric true; options.Lockout.MaxFailedAccessAttempts 5; }) .AddEntityFrameworkStoresAppDbContext() .AddDefaultTokenProviders(); builder.Services.ConfigureApplicationCookie(options { options.Cookie.Name .AspNetCore.Identity.Application; options.ExpireTimeSpan TimeSpan.FromHours(8); options.SlidingExpiration true; options.LoginPath /Account/Login; options.AccessDeniedPath /Account/Denied; options.Cookie.SameSite SameSiteMode.Lax; options.Cookie.SecurePolicy CookieSecurePolicy.SameAsRequest; });这段代码里有几个值得注意的点ExpireTimeSpan控制登录有效期。设为 8 小时加滑期过期SlidingExpiration用户持续操作就不会被踢下线空闲超过 8 小时才失效。LoginPath和AccessDeniedPath是授权失败时重定向的地址。SameSiteMode.Lax是为了兼容从外部链接跳转过来的场景如果完全不需要可以用 Strict 提升安全性。很多教程只用AddDefaultIdentity它会自动带上一套脚手架页面适合快速原型。但我实际做项目更倾向AddIdentity因为它把 Cookie 配置、密码策略都暴露出来可控性更强。你要是用默认脚手架项目改起这些配置还得多费一层工夫反而不如从一开始就自己接管。3.2 注册认证中间件和路由约束配置好服务之后HTTP 管道里要按顺序加中间件。顺序错了后面登录状态就取不到app.UseAuthentication(); app.UseAuthorization(); app.MapRazorComponentsApp() .AddInteractiveServerRenderMode();UseAuthentication负责把请求里的 Cookie 解析成用户身份UseAuthorization负责执行授权规则。必须放在UseRouting之后、执行 Endpoint 之前。接着在需要保护的地方加[Authorize][Authorize] public class IndexModel : PageModel { // 只有登录用户能访问 }对于服务端渲染的页面这套机制和传统 ASP.NET Core MVC 完全一致做过后端的人应该不陌生。有些新手会问是不是只在组件上加了[Authorize]就够了不是。组件属于渲染层页面的路由请求照样要先经过 HTTP 管道所以中间件注册是基础组件上的特性只是额外防护。3.3 登录/登出流程实现接下来是登录逻辑。用 Identity 的 SignInManager 几行就能搞定public async TaskIActionResult Login(LoginModel model) { var result await _signInManager.PasswordSignInAsync( model.UserName, model.Password, model.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { return RedirectToPage(/Index); } if (result.IsLockedOut) { ModelState.AddModelError(, 账号已锁定请稍后再试); return Page(); } ModelState.AddModelError(, 用户名或密码错误); return Page(); }RememberMe参数很关键。它为 True 时会生成持久化 Cookie浏览器关掉再打开仍然是登录状态为 False 时 Cookie 是会话级关闭浏览器就失效。企业内网后台通常两种需求都有所以登录页上建议放一个“记住我”的勾选框。登出时调用await _signInManager.SignOutAsync()然后重定向到首页。登出之后 Identity 会清掉当前登录 Cookie 和认证票据这一步不能省。有些系统只清 Session 不清 Cookie结果用户还在登录状态会被其他模块误判。我见过一个真实的项目登出后点浏览器后退还能回到管理页面就是因为后端没清干净认证状态前端靠缓存硬撑着。3.4 组件中获取当前用户在 Blazor Server 的组件里当前用户不是直接读HttpContext.User而是通过AuthenticationStateProvider获取。因为组件渲染发生在 SignalR 的 Circuit 里不是在 HTTP 请求内两者上下文完全不同。最简单的方式是注入AuthenticationStateProviderinject AuthenticationStateProvider AuthenticationStateProvider code { private string userName; protected override async Task OnInitializedAsync() { var state await AuthenticationStateProvider.GetAuthenticationStateAsync(); userName state.User.Identity?.Name; } }更常见的 UI 写法是用AuthorizeView它内置了三个模板AuthorizeView Authorized p欢迎回来context.User.Identity.Name/p /Authorized NotAuthorized p请先登录/p /NotAuthorized Authorizing p正在检查登录状态.../p /Authorizing /AuthorizeViewAuthorizing模板很容易被忽略但它在首次加载时会出现如果网络慢用户会看到一闪而过的空白或错误界面。写上这个模板体验会好很多。我在代码评审时基本都会特意问一句Authorizing 模板有没有处理大部分人都摇头。4. 在 Blazor WebAssembly 中实现 Token 认证到了 WebAssembly 这部分复杂度会明显上升核心原因是没有服务端会话一切状态都要客户端自己管。4.1 搭建认证 API 服务先不管 Blazor 的组件层把后端认证 API 做好。一个典型的登录接口用最小 API 就能实现app.MapPost(/api/auth/login, async (LoginRequest request, UserManagerApplicationUser userManager, IConfiguration config) { var user await userManager.FindByNameAsync(request.UserName); if (user null || !await userManager.CheckPasswordAsync(user, request.Password)) { return Results.Unauthorized(); } var roles await userManager.GetRolesAsync(user); var claims new ListClaim { new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.NameIdentifier, user.Id) }; claims.AddRange(roles.Select(r new Claim(ClaimTypes.Role, r))); var token GenerateJwtToken(claims, config); return Results.Ok(new { token, expire DateTimeOffset.UtcNow.AddHours(2) }); });JWT 的生成逻辑比较固定指定签名密钥、有效期、声明然后签发。密钥必须放在服务端配置里绝对不能写死在客户端代码中。有效期一般建议短一些比如 1 到 2 小时配合刷新 Token 使用。放太长一旦 Token 泄露相当于把大门钥匙直接交给了别人。这里我多说一句如果你做的是企业内部的 portal 类系统登录入口往往要跟统一身份平台打通。这种情况下后端 API 不要自己存一套密码而是把身份验证委托给认证平台你的服务只负责接收平台上回传的用户信息再签发自己的 Token。这样账号安全团队才敢把系统接进来也方便后续做统一审计。4.2 前端保存和携带 Token前端拿到 Token 后需要存到一个刷新浏览器后还能读到的地方。常见选择是localStorage优点是持久、简单缺点是任何在页面里执行的脚本都能读到它。如果担心 XSS 风险也可以用sessionStorage但一关浏览器就要重新登录。我的习惯是对于权限要求不高的后台系统用sessionStorage存 Token对于需要在关闭浏览器后仍然保持登录的应用用localStorage但同时加强输入校验和内容安全策略降低 XSS 风险。至于网上流传的“用内存变量存 Token”的做法只适合对安全性极其敏感的场景副作用就是页面一刷新Token就丢了要自己处理失效恢复。携带 Token 最优雅的办法是写一个DelegatingHandlerpublic class AuthMessageHandler : DelegatingHandler { private readonly ITokenStore _tokenStore; public AuthMessageHandler(ITokenStore tokenStore) { _tokenStore tokenStore; } protected override async TaskHttpResponseMessage SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var token await _tokenStore.GetTokenAsync(); if (!string.IsNullOrEmpty(token)) { request.Headers.Authorization new AuthenticationHeaderValue(Bearer, token); } return await base.SendAsync(request, cancellationToken); } }注册时把这个 Handler 加到 HttpClient 管道里业务代码里完全不需要关心 Token 怎么带builder.Services.AddScopedITokenStore, TokenStore(); builder.Services.AddScopedAuthMessageHandler(); builder.Services.AddHttpClient(Api, client { client.BaseAddress new Uri(https://api.example.com/); }).AddHttpMessageHandlerAuthMessageHandler();4.3 刷新 Token、安全存储与失效处理JWT 一旦到期客户端调用 API 会得到 401。如果每次都让用户重新登录体验很差所以需要一个刷新机制。简单做法是登录时同时返回 AccessToken 和 RefreshToken。AccessToken 短期有效RefreshToken 长期有效当 AccessToken 失效时客户端用 RefreshToken 去换取新的 AccessToken而不是让用户重新输密码。刷新 Token 本身也是一把双刃剑保管不好等于泄露了长期登录凭证。所以 RefreshToken 应该由服务端保存支持吊销并且只能通过 HTTPS 传输。每次刷新时最好把旧的 RefreshToken 一并作废降低重放风险。在实际项目里我会在 401 响应后额外做一次重试var response await _httpClient.SendAsync(request); if (response.StatusCode HttpStatusCode.Unauthorized) { await _refreshService.TryRefreshAsync(); // 用新 Token 重发原始请求 }这个逻辑要放在统一的 HTTP 拦截层里不要让每个业务方法都去处理 401。我见过很多团队在每个 Service 里复制粘贴刷新代码最后各个地方的实现还不一样排查起来特别痛苦。统一处理的另一个好处是以后如果要改成单点登录或者引入 OAuth2.0 的 PKCE 流程只需要改拦截层就够了。4.4 WASM 模式下最容易被忽略的坑WebAssembly 里最常见的一个问题页面刷新后身份丢失。原因很简单AuthenticationStateProvider 只是内存中的一个对象页面刷新后它重新创建内部没有任何用户信息。必须先自己从存储里读出 Token再还原成 AuthenticationState。自定义的 AuthenticationStateProvider 大概是这样的public class CustomAuthStateProvider : AuthenticationStateProvider { private readonly ITokenStore _tokenStore; public CustomAuthStateProvider(ITokenStore tokenStore) { _tokenStore tokenStore; } public override async TaskAuthenticationState GetAuthenticationStateAsync() { var token await _tokenStore.GetTokenAsync(); if (string.IsNullOrEmpty(token)) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } var identity new ClaimsIdentity(ParseClaims(token), jwt); var user new ClaimsPrincipal(identity); return new AuthenticationState(user); } public async Task MarkUserAsAuthenticatedAsync(string token) { await _tokenStore.SetTokenAsync(token); var identity new ClaimsIdentity(ParseClaims(token), jwt); var user new ClaimsPrincipal(identity); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user))); } public async Task MarkUserAsLoggedOutAsync() { await _tokenStore.RemoveTokenAsync(); var anonymous new ClaimsPrincipal(new ClaimsIdentity()); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(anonymous))); } }这里有个时序陷阱第一次加载组件时异步读取存储还没完成AuthenticationState 可能仍然是匿名。所以涉及登录状态判断的 UI一定要有“正在检查”的过渡状态否则用户会看到登录界面闪一下然后才切换成正常内容。AuthorizeRouteView里提供NotAuthorized和Authorizing模板就是用来处理这种情况的。还有一个容易被忽略的点不要在服务器端预渲染阶段直接读取 Token。预渲染发生在服务端进程里那里没有浏览器存储一读就是空值或者直接报错。最好把获取 Token 的逻辑延后到OnInitializedAsync或者通过JSInterop在浏览器端执行不然你会发现部署后首页看起来一切正常刷新一下反而炸了。5. 授权基于角色与基于策略按场景选择认证做完只是第一步真正涉及业务权限的是授权。Blazor 里的授权和 ASP.NET Core 保持一致可以在组件、页面、API 三个层面使用。5.1 基于角色的授权最简单的授权方式是角色判断[Authorize(Roles Admin)] class AdminPage : ComponentBase { }在服务端 API 里也类似比如[Authorize(Roles Admin,Manager)]表示管理员或经理都可以访问。但角色授权的缺点是粒度太粗。比如系统里有三个管理员其中两个只能管订单一个还能管财务仅用角色去区分就无能为力了总不能让财务和订单各建一个角色。角色的数量一旦膨胀权限管理就会变得很难维护。我见过一个系统里建了四十多个角色每个角色对应一个页面最后改权限的时候没人说得清楚某个角色到底能干什么。这种系统基本已经失控。5.2 基于策略的授权策略授权是更推荐的方案。先把权限规则定义成策略再在业务代码里引用策略名业务代码和规则实现解耦。注册策略的代码builder.Services.AddAuthorizationCore(options { options.AddPolicy(CanViewFinanceReport, policy policy.RequireRole(Admin) .RequireClaim(Department, Finance)); options.AddPolicy(CanDeleteOrder, policy policy.RequireAssertion(context { var user context.User; if (user.IsInRole(Admin)) { return true; } var managedRegions user.FindAll(ManagedRegion).Select(c c.Value).ToHashSet(); var orderRegion context.Resource?.ToString(); return orderRegion ! null managedRegions.Contains(orderRegion); })); });第二个策略展示了一个典型场景区域经理只能删除自己负责区域的订单。这种规则写成自定义策略后页面和接口都引用同一个策略名规则变更时只需改一处。AddAuthorizationCore适用于 Blazor WebAssembly 的独立应用如果同时有服务端托管通常用AddAuthorization注册完整版本。如果你的权限规则特别复杂比如要从数据库动态读取权限点可以再往下封装“权限点”系统每个操作对应一个权限码用户通过角色绑到一组权限码上策略里只需要检查“当前用户是否拥有某权限码”。这套模型在管理后台里非常实用尤其是要给不同部门开放不同菜单和操作的时候。5.3 组件级授权控制在组件里除了给整个页面加[Authorize]还可以对页面局部做权限控制。AuthorizeView支持策略AuthorizeView PolicyCanDeleteOrder Authorized button onclickDeleteOrder删除订单/button /Authorized NotAuthorized span无删除权限/span /NotAuthorized /AuthorizeView不要把权限判断写在OnInitializedAsync里然后靠 return 跳过渲染这样用户仍然可能看到闪烁或者堆栈报错。用AuthorizeView包裹是声明式的安全和渲染分离更可靠。对于需要大量权限判断的页面我通常会把AuthorizeView抽成一个小组件比如PermissionButton传个策略名进去组件内部自动控制显示和禁用状态。这样业务页面里就不会到处是权限逻辑代码可读性也高。5.4 客户端和服务端两侧都要做授权不能只信一端这句话我在团队里说过很多遍Blazor WebAssembly 不管前端隐藏了多少按钮只要调用 API真正决定数据是否返回的必须是后端。所以后端每个受保护接口都必须用[Authorize]或策略授权不能因为前端项目里写了AuthorizeView就认为安全。特别是处理“未授权访问”问题时要清楚区分两类风险一类是接口对未登录用户返回了数据另一类是普通用户在知道 Id 的情况下直接访问了别人的资源。后者叫对象级授权或水平越权比如访问/api/orders/123时必须验证当前用户是否拥有订单 123。这种校验在控制器里查一次归属关系通常就够了但很多人会忘记做上线之后被安全扫描一打一个准。还有一点容易被忽略Wasm 的应用编译产物是可以直接下载到本地的。换句话说任何人都能拿到你的前端代码并慢慢分析前端里藏的逻辑、判断、接口地址全都是透明的。所以不要在前端代码里写任何权限绕过逻辑或者硬编码的管理员账号那等于把钥匙放在门垫下面。6. 常见问题与排查技巧实录这一节是我在项目里积累下来的实际问题按出现频率排了一下。6.1 典型报错与解决方案速查现象原因处理方式登录后跳转回登录页认证 Cookie 未写入成功或中间件顺序错误检查UseAuthentication()是否在UseAuthorization()之前页面刷新后变成未登录WASM未从存储恢复 Token自定义 AuthenticationStateProvider 中读取 Token 并构建身份调用 API 返回 401Token 缺失或过期检查 DelegatingHandler 是否注册检查 Token 有效期SignalR 重连后身份错乱Cookie 过期或 Circuit 重建检查登录有效期配置重连后重新获取认证状态AuthorizeView内容一闪而过异步加载状态时未处理 Authorizing加Authorizing模板用户退出后仍能访问接口客户端清除了 Token 但服务端未吊销退出时调用后端吊销接口或缩短 Token 有效期这张表我自己打印出来贴过工位旁边因为很多问题是跨着前端后端一起出现的只看一侧很难定位。你要是在排查某个报错先确认自己项目是 Server 还是 Wasm再按表里的第一列去找基本能少走一半弯路。6.2 认证状态丢失的场景排查如果你发现用户在使用的过程中突然变成未登录先分清楚是 Server 还是 WasmServer 模式优先看 Cookie 过期时间、SlidingExpiration 是否关闭、应用池回收是否频繁。IIS 默认回收应用池时会重建进程导致内存里的登录态丢失。可以适当调长回收时间或者把登录状态改存到分布式缓存如 Redis里但要注意序列化。Wasm 模式优先看 Token 存储在哪种 Storage 里浏览器是否清掉了站点数据其次看 Token 是否被服务端主动吊销。还有一个细节是时间同步JWT 的exp是基于 UTC 的如果客户端机器时间偏差太大本地验证可能直接判定过期。我在实际排查时习惯在 AuthenticationStateProvider 里加一行日志打印出每次状态变化的来源和用户 ID。这样判断是“没有读到 Token”还是“读取后解析失败”还是“解析成功但被后续逻辑覆盖”会快很多。日志别记录 Token 内容本身只要记录用户标识和状态来源安全上才站得住。6.3 SignalR 断开重连后的认证处理Blazor Server 依赖 SignalR断网重连是经常发生的事情。SignalR 重连时不会重新执行 HTTP 认证管道所以如果 Cookie 过期重连后的 Circuit 可能保留旧身份。处理思路是在前端监听连接状态重连成功后主动向服务端请求一次当前用户身份跟本地缓存的身份做比对不一致就提示重新登录。这个需求可以直接用CircuitHandler去实现。我的做法是写了一个自定义 Handler在OnConnectionUpAsync里调用一个/api/auth/current接口获取用户 Claim返回值跟当前成员比对发现变化时调用AuthenticationStateProvider.NotifyAuthenticationStateChanged刷新 UI。另外一个容易忽略的问题Blazor Server 里如果你用了AddDbContext它的DbContext默认是 Scoped会跟着 Circuit 存活。认证相关的用户数据要是在 DbContext 里被缓存了一段时间后可能读到旧数据。所以涉及用户状态变更后比如改角色、重置密码要确保相关数据能重新加载别只改数据库不刷新内存里的身份。6.4 容易被忽略的安全细节认证授权不是把流程跑通就算完还有几个细节我在代码评审时一定会看。第一登录接口需要限流和防暴力破解。Identity 自带的锁定策略要开启设置最大失败次数和锁定时间。不是所有系统都对接企业微信自己管账号的情况下这部分是硬需求。我曾见过一个后台登录接口被脚本爆破对方连续试了几万次密码日志里全是 400 报错就是因为项目把锁定策略关了说“内部系统没人会来爆破”结果上线的第二周就开始被扫。第二避免在 URL 上传递 Token。有些团队为了方便把 Token 放在查询参数里比如?tokenxxx。浏览器历史、代理日志、服务器访问日志都会记录 URLToken 等于被明文写在日志里。我见过不止一次因为 URL 里带 Token 导致的安全事故这事真不能偷懒。第三对外部跳转地址做校验。登录成功后如果要重定向到回跳地址必须校验地址属于你的站点否则会被做成开放重定向漏洞。ASP.NET Core 的ReturnUrl处理里需要加Url.IsLocalUrl()判断或者用白名单比对。第四记录关键操作日志但不要记录密码、Token、Cookie 原文。日志里出现密码明文我直接视为不合格代码。认证失败次数、锁定事件、权限变更这类事件建议完整记录方便事后审计。第五前后端都要处理 Session 过期。Wasm 项目里 Token 过期后后端接口该返回 401 就返回 401不要为了“友好”返回 200 空数据。前端拿到 401 后统一弹一次登录过期的提示然后去走刷新流程。很多项目就是因为后端接口对登录过期处理不严格导致用户一直在页面上操作实际上数据全是旧的最后排查半天才发现问题出在认证上。结尾这套东西我在不同项目里翻来覆去落地过好几遍踩过的坑一直提醒我认证授权不是某个页面的功能而是整个应用的横切关注点。我个人的体会是不要在项目还没想清楚权限模型时就急着写业务代码也不要为了炫技引入太重的认证框架。Blazor 的好处是前后端模型都统一在 C# 里把认证状态管理成一个清晰的 Provider所有组件都从同一个源头拿身份整个系统就会干净很多。最后再分享一个小技巧不管你是 Server 还是 Wasm都建议在开发环境就配上真实有效的认证流程不要用“先绕过登录”的方式开发。很多团队前期图快在页面里写死一个测试用户结果到了联调阶段才发现认证状态这一环根本走不通返工成本远比想象中大。认证授权这类基础能力越早按生产标准做后面越省心。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进