12. 【Blazor全栈开发实战指南】--认证与授权
一、JWT认证流程全景
JSON Web Token(JWT)是现代Web应用最广泛采用的无状态认证方案。与传统的基于Session的认证不同,服务器不需要在内存或数据库中存储会话,只需颁发一个包含用户身份声明的加密令牌,客户端在每次请求时携带该令牌,服务器验证令牌签名的有效性即可判断用户身份。这种设计天然适合前后端分离架构和水平扩展场景。
JWT令牌由三部分组成,用.分隔:Header(算法声明)、Payload(声明集合,如用户ID、角色、过期时间)和Signature(使用服务器密钥对前两部分的HMAC签名)。任何人可以解码Payload看到声明内容,但无法在不知道密钥的情况下伪造有效签名,这保证了令牌的完整性。
完整的JWT认证流程如下:
用户 → 输入凭据 → Blazor前端 → POST /api/auth/login → API服务器 ↓ 验证凭据 颁发 AccessToken + RefreshToken API服务器 → 返回令牌 → Blazor前端存储令牌 → 后续请求携带 Authorization: Bearer <token> ↓ 验证签名 API服务器 → 返回受保护数据 → Blazor前端展示二、API服务器端JWT配置
首先在API项目安装所需包:
dotnetaddpackage Microsoft.AspNetCore.Authentication.JwtBearer dotnetaddpackage System.IdentityModel.Tokens.Jwt在appsettings.json中存放JWT配置(生产环境的密钥应通过环境变量或密钥管理服务注入,绝对不要将真实密钥提交到代码仓库):
{"Jwt":{"Issuer":"https://api.myapp.com","Audience":"https://myapp.com","SecretKey":"your-256-bit-secret-key-here-replace-in-production","AccessTokenExpirationMinutes":60,"RefreshTokenExpirationDays":30}}在Program.cs配置JWT Bearer认证:
// Program.cs(API项目)varjwtConfig=builder.Configuration.GetSection("Jwt");varsecretKey=jwtConfig["SecretKey"]!;builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options=>{options.TokenValidationParameters=newTokenValidationParameters{// 验证令牌的签发者(服务器声明自己是谁)ValidateIssuer=true,ValidIssuer=jwtConfig["Issuer"],// 验证令牌的受众(令牌是为哪个应用签发的)ValidateAudience=true,ValidAudience=jwtConfig["Audience"],// 验证令牌是否已过期(必须开启)ValidateLifetime=true,// 验证签名密钥(核心安全性)ValidateIssuerSigningKey=true,IssuerSigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),// 令牌过期的时钟偏差容忍(默认5分钟,建议设为0)ClockSkew=TimeSpan.Zero};});builder.Services.AddAuthorization();// 中间件顺序:Authentication 必须在 Authorization 之前app.UseAuthentication();app.UseAuthorization();登录端点负责验证用户凭据并签发令牌:
// 登录请求和响应 DTOpublicrecordLoginRequest(stringEmail,stringPassword);publicrecordLoginResponse(stringAccessToken,stringRefreshToken,DateTimeExpiresAt);// POST /api/auth/loginapp.MapPost("/api/auth/login",async(LoginRequestrequest,IUserServiceuserService,IConfigurationconfig)=>{// 验证用户凭据(具体实现依赖用户服务和密码哈希策略)varuser=awaituserService.ValidateCredentialsAsync(request.Email,request.Password);if(userisnull)returnResults.Unauthorized();// 构建JWT声明集合(Claims)varclaims=newList<Claim>{// sub(Subject):唯一标识用户身份new(JwtRegisteredClaimNames.Sub,user.Id.ToString()),new(JwtRegisteredClaimNames.Email,user.Email),new(JwtRegisteredClaimNames.Jti,Guid.NewGuid().ToString()),// 唯一标识此令牌// 用户角色,支持多角色new(ClaimTypes.Role,user.Role),new(ClaimTypes.Name,user.DisplayName)};varjwtSection=config.GetSection("Jwt");varkey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSection["SecretKey"]!));varexpiresAt=DateTime.UtcNow.AddMinutes(jwtSection.GetValue<int>("AccessTokenExpirationMinutes"));vartoken=newJwtSecurityToken(issuer:jwtSection["Issuer"],audience:jwtSection["Audience"],claims:claims,expires:expiresAt,signingCredentials:newSigningCredentials(key,SecurityAlgorithms.HmacSha256));varaccessToken=newJwtSecurityTokenHandler().WriteToken(token);// RefreshToken 是不透明的随机字符串,存储于数据库供刷新使用varrefreshToken=awaituserService.GenerateRefreshTokenAsync(user.Id);returnResults.Ok(newLoginResponse(accessToken,refreshToken,expiresAt));});// 需要认证的端点:加 .RequireAuthorization() 过滤器app.MapGet("/api/users/me",(ClaimsPrincipaluser)=>{// User.FindFirst 从JWT Claims中读取用户信息returnResults.Ok(new{Id=user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value,Email=user.FindFirst(JwtRegisteredClaimNames.Email)?.Value,Name=user.FindFirst(ClaimTypes.Name)?.Value,Role=user.FindFirst(ClaimTypes.Role)?.Value});}).RequireAuthorization();// 未携带有效JWT将返回401三、Blazor前端:自定义AuthenticationStateProvider
在Blazor的认证体系中,AuthenticationStateProvider是一个抽象基类,负责告知框架"当前用户是谁"。AuthorizeView组件和[Authorize]特性都依赖它来判断认证和授权状态。对于JWT认证方案,我们需要自定义这个提供者,让它从本地存储中读取JWT令牌并解析用户身份。
// Auth/JwtAuthenticationStateProvider.csusingSystem.IdentityModel.Tokens.Jwt;usingSystem.Security.Claims;usingMicrosoft.AspNetCore.Components.Authorization;publicclassJwtAuthenticationStateProvider:AuthenticationStateProvider{privatereadonlyLocalStorageService_localStorage;privatereadonlyHttpClient_http;// 表示未认证的匿名用户状态(静态复用,避免重复创建)privatestaticreadonlyAuthenticationStateAnonymousState=new(newClaimsPrincipal(newClaimsIdentity()));publicJwtAuthenticationStateProvider(LocalStorageServicelocalStorage,HttpClienthttp){_localStorage=localStorage;_http=http;}// 框架在需要认证状态时调用此方法publicoverrideasyncTask<AuthenticationState>GetAuthenticationStateAsync(){vartoken=await_localStorage.GetAsync<string>("accessToken");if(string.IsNullOrWhiteSpace(token))returnAnonymousState;// 解析JWT令牌中的Claims,无需请求服务器varhandler=newJwtSecurityTokenHandler();JwtSecurityTokenjwtToken;try{jwtToken=handler.ReadJwtToken(token);}catch{// Token格式错误,视为未认证returnAnonymousState;}// 检查令牌是否已过期if(jwtToken.ValidTo<DateTime.UtcNow){// 令牌已过期,清除本地存储awaitClearTokenAsync();returnAnonymousState;}// 从JWT Payload中提取Claims,构建已认证的ClaimsPrincipal// 必须指定 authenticationType 才会被识别为已认证身份(IsAuthenticated = true)varidentity=newClaimsIdentity(jwtToken.Claims,"jwt");varuser=newClaimsPrincipal(identity);returnnewAuthenticationState(user);}// 登录成功后调用:保存令牌并通知框架认证状态已变化publicasyncTaskLoginAsync(stringaccessToken,stringrefreshToken){await_localStorage.SetAsync("accessToken",accessToken);await_localStorage.SetAsync("refreshToken",refreshToken);// 将JWT添加到所有后续HTTP请求的Authorization头_http.DefaultRequestHeaders.Authorization=newSystem.Net.Http.Headers.AuthenticationHeaderValue("Bearer",accessToken);// 重新解析认证状态并通知订阅者(触发授权相关组件重渲染)varstate=awaitGetAuthenticationStateAsync();NotifyAuthenticationStateChanged(Task.FromResult(state));}// 退出登录:清除令牌并通知框架publicasyncTaskLogoutAsync(){awaitClearTokenAsync();_http.DefaultRequestHeaders.Authorization=null;NotifyAuthenticationStateChanged(Task.FromResult(AnonymousState));}// 应用启动时恢复认证状态(从localStorage读取并设置HTTP头)publicasyncTaskInitializeAsync(){vartoken=await_localStorage.GetAsync<string>("accessToken");if(!string.IsNullOrEmpty(token)){_http.DefaultRequestHeaders.Authorization=newSystem.Net.Http.Headers.AuthenticationHeaderValue("Bearer",token);}}privateasyncTaskClearTokenAsync(){await_localStorage.RemoveAsync("accessToken");await_localStorage.RemoveAsync("refreshToken");}}在Program.cs中注册认证相关服务:
// Blazor WASM 前端 Program.csbuilder.Services.AddScoped<LocalStorageService>();builder.Services.AddScoped<JwtAuthenticationStateProvider>();// 将自定义Provider注册为 AuthenticationStateProvider,供框架使用builder.Services.AddScoped<AuthenticationStateProvider>(sp=>sp.GetRequiredService<JwtAuthenticationStateProvider>());// 添加级联认证状态(让 AuthorizeView 等组件能收到认证状态推送)builder.Services.AddAuthorizationCore();登录页面组件:
@*Components/Pages/Login.razor*@ @page"/login"@inject JwtAuthenticationStateProvider AuthProvider @inject NavigationManager NavManager @inject ProductApiClient ProductApi<h1>登录</h1><EditFormModel="loginModel"OnValidSubmit="HandleLogin"><DataAnnotationsValidator/><ValidationSummary/><divclass="mb-3"><label>邮箱</label><InputText @bind-Value="loginModel.Email"class="form-control"/></div><divclass="mb-3"><label>密码</label><InputTexttype="password"@bind-Value="loginModel.Password"class="form-control"/></div>@if(!string.IsNullOrEmpty(errorMessage)){<divclass="alert alert-danger">@errorMessage</div>}<buttontype="submit"class="btn btn-primary"disabled="@isLoading">@(isLoading?"登录中...":"登录")</button></EditForm>@code{[SupplyParameterFromQuery]privatestringReturnUrl{get;set;}="/";privateLoginModelloginModel=new();privatestring?errorMessage;privateboolisLoading=false;privateasyncTaskHandleLogin(){isLoading=true;errorMessage=null;try{varresponse=awaitProductApi.LoginAsync(newLoginRequest(loginModel.Email,loginModel.Password));// 保存令牌并更新认证状态awaitAuthProvider.LoginAsync(response.AccessToken,response.RefreshToken);// 登录成功后跳转到原目标页面NavManager.NavigateTo(ReturnUrl);}catch(HttpRequestExceptionex)when(ex.StatusCode==System.Net.HttpStatusCode.Unauthorized){errorMessage="邮箱或密码错误,请重试。";}finally{isLoading=false;}}privateclassLoginModel{[Required]publicstringEmail{get;set;}=string.Empty;[Required]publicstringPassword{get;set;}=string.Empty;}}四、策略授权与角色授权
简单的角色授权([Authorize(Roles = "Admin")])足以应付大多数场景,但对于复杂的权限规则,ASP.NET Core提供了策略授权(Policy-based authorization),允许用C#代码定义任意复杂的授权逻辑。
在API端定义授权策略:
builder.Services.AddAuthorization(options=>{// 策略:必须是高级 VIP 用户(角色为 VIP 且账号年龄超过30天)options.AddPolicy("SeniorVip",policy=>policy.RequireRole("VIP").RequireAssertion(context=>{varregisteredAtClaim=context.User.FindFirst("registered_at")?.Value;if(DateTime.TryParse(registeredAtClaim,outvarregisteredAt))return(DateTime.UtcNow-registeredAt).TotalDays>30;returnfalse;}));// 策略:需要特定权限声明options.AddPolicy("CanManageProducts",policy=>policy.RequireClaim("permission","products:write"));});在Blazor前端,AuthorizeView组件可以接收Policy或Roles参数做细粒度UI控制:
@*基于策略控制UI可见性*@<AuthorizeViewPolicy="CanManageProducts"><Authorized><buttonclass="btn btn-primary"@onclick="CreateProduct">新增产品</button></Authorized></AuthorizeView>@*同时订阅认证状态,获取当前用户信息*@<AuthorizeView><AuthorizedContext="authState"><p>欢迎,@authState.User.FindFirst(ClaimTypes.Name)?.Value!</p><button@onclick="Logout">退出登录</button></Authorized><NotAuthorized><ahref="/login">请先登录</a></NotAuthorized></AuthorizeView>五、总结
本章完整实现了JWT认证的全链路:API端用AddJwtBearer配置令牌验证参数,登录端点签发包含用户声明的访问令牌;Blazor前端通过自定义JwtAuthenticationStateProvider从本地存储恢复认证状态,并在AuthorizeView和[Authorize]组件的支持下实现声明式UI权限控制;基于声明和断言的策略授权则提供了超越简单角色判断的灵活授权能力。
但是,并非所有的信息交换都适合"请求-响应"模式。当服务器需要主动向客户端推送数据时——例如实时聊天消息、股票行情更新、协作文档的他人编辑通知——轮询API会造成大量浪费。下一章,我们将介绍实时通信的利器SignalR,探讨如何在Blazor应用中构建真正的双向实时通道。
