一文理清 Blazor Identity 鉴权验证
摘要
在现代Web应用程序中,身份认证与授权是确保应用安全性和用户数据保护的关键环节。Blazor作为基于C#和.NET的前端框架,提供了丰富的身份认证与授权机制。本文将深入解析Blazor的身份认证框架的构成,比较不同渲染模式下鉴权逻辑的异同,并通过具体案例演示如何在Blazor Server和Blazor WebAssembly中实现身份认证。通过本文的学习,读者将能够更好地理解并应用Blazor中的Identity,以构建安全可靠的Web应用程序。
鉴权框架的构成
Blazor的身份认证框架主要由以下三个核心部分组成:
基架: AuthenticationMiddleware (Microsoft.AspNetCore.Authentication)
AuthenticationMiddleware
是ASP.NET Core中用于处理身份认证的中间件组件。它位于请求处理管道中,负责验证用户的身份并构建ClaimsPrincipal
对象,将其附加到HttpContext.User
属性中。所有后续的中间件和请求处理程序都可以访问该用户对象,从而了解当前请求的身份信息。
在Blazor应用程序中,AuthenticationMiddleware
的作用是拦截HTTP请求,检查请求中是否包含有效的认证凭据(例如Cookie
、JWT
等)。如果凭据有效,它将解析并构建用户的身份信息;如果无效,则将用户视为未认证状态。
否
是
Cookie认证
JWT认证
其他方案
成功
失败
是
否
通过
不通过
HTTP请求
是否需要认证
直接访问资源
Authentication中间件
检查认证方案
解析Cookie
解析Authorization Header
...
验证Cookie有效性
验证Token签名
验证相应凭证
验证结果
创建ClaimsPrincipal
返回401/403
设置HttpContext.User
是否需要授权
Authorization中间件
访问资源
检查授权策略
访问资源
返回403
引用:
后端鉴权逻辑服务: IdentityCore (Microsoft.AspNetCore.Identity)
IdentityCore
是ASP.NET Core提供的完整的身份管理框架。它为开发者提供了处理用户注册、登录、角色管理、密码重置等功能的 APIs 和服务。IdentityCore
高度可定制,可以使用不同的数据存储方式(如Entity Framework Core、MongoDB等)和密码哈希算法。
在Blazor Server模式下,IdentityCore
通常与AuthenticationMiddleware
结合使用。后端服务器负责处理所有与身份相关的逻辑,包括验证用户凭据、管理用户数据和生成身份认证令牌等。
核心架构图
IdentityUser
+string Id
+string UserName
+string Email
+string PasswordHash
+string SecurityStamp
+bool EmailConfirmed
+bool TwoFactorEnabled
IdentityRole
+string Id
+string Name
+string NormalizedName
UserManager
+CreateAsync()
+FindByIdAsync()
+AddToRoleAsync()
+CheckPasswordAsync()
+GenerateEmailConfirmationTokenAsync()
SignInManager
+PasswordSignInAsync()
+SignInAsync()
+SignOutAsync()
+TwoFactorAuthenticatorSignInAsync()
RoleManager
+CreateAsync()
+FindByIdAsync()
+AddClaimAsync()
IdentityDbContext
+DbSet<IdentityUser> Users
+DbSet<IdentityRole> Roles
+DbSet<IdentityUserClaim> UserClaims
+DbSet<IdentityRoleClaim> RoleClaims
UserStore
+CreateAsync()
+UpdateAsync()
+FindByIdAsync()
+AddToRoleAsync()
RoleStore
+CreateAsync()
+UpdateAsync()
+FindByIdAsync()
IdentityOptions
+PasswordOptions
+LockoutOptions
+UserOptions
+SignInOptions
IdentityBuilder
+AddEntityFrameworkStores()
+AddDefaultTokenProviders()
+AddDefaultUI()
配置与启动流程 (图中的配置函数非常原始,案例中会使用较新的简化函数,不过最终都是调用它们)
用户注册与认证流程
授权与角色管理流程(更侧重于使用AuthorizationMiddleware)
扩展点和自定义实现
«interface»
IUserStore
+CreateAsync()
+UpdateAsync()
+DeleteAsync()
+FindByIdAsync()
«interface»
IUserPasswordStore
+SetPasswordHashAsync()
+GetPasswordHashAsync()
«interface»
IUserRoleStore
+AddToRoleAsync()
+RemoveFromRoleAsync()
+GetRolesAsync()
«interface»
IUserClaimStore
+AddClaimsAsync()
+RemoveClaimsAsync()
+GetClaimsAsync()
CustomUserStore
-IRepository repository
+CreateAsync()
+UpdateAsync()
+FindByIdAsync()
«interface»
IPasswordHasher
+HashPassword()
+VerifyHashedPassword()
«interface»
IPasswordValidator
+ValidateAsync()
«interface»
IUserValidator
+ValidateAsync()
主要特点:
模块化设计:核心身份模型、用户管理服务、存储抽象层、验证器接口
灵活的扩展性:自定义用户模型、自定义存储实现、可配置的选项、验证器扩展
完整的认证流程:用户注册、密码验证、双因素认证、外部登录
丰富的授权机制:基于角色、基于Claims、基于策略、动态授权
安全特性:密码哈希、账户锁定、安全戳验证、令牌管理
引用:
- Microsoft 文档:ASP.NET Core 上的 Identity 简介
- Microsoft 文档:在 ASP.NET Core 项目中添加 Identity
前端鉴权逻辑服务: AuthenticationStateProvider (Microsoft.AspNetCore.Components.Authorization)
AuthenticationStateProvider
是Blazor中用于提供当前用户身份状态的抽象类。它的主要作用是向Blazor组件提供身份认证状态(AuthenticationState
),以便组件能够根据用户的身份进行相应的显示和操作。
在Blazor应用程序中,AuthenticationStateProvider
的具体实现方式取决于应用的渲染模式和身份认证方案。对于Blazor Server
,这个提供程序可以直接从服务器的HttpContext.User
获取身份信息;对于Blazor WebAssembly
,由于代码在客户端运行,需要通过其他方式(如调用后端API或解析令牌)获取用户身份。
主要特点:
状态管理:缓存认证状态、状态变更通知、状态同步更新
组件集成:CascadingAuthenticationState提供状态共享、AuthorizeView用于条件渲染、Authorize特性支持
自定义能力:自定义认证逻辑、自定义授权策略、状态持久化
安全特性:状态验证、角色授权、Claims基础授权
性能优化:状态缓存、按需刷新、组件重渲染控制
引用:
- Microsoft 文档:ASP.NET Core Blazor 身份验证和授权
不同渲染模式中鉴权逻辑的异同
相同点
组件在获取用户信息与鉴权状态时,都统一使用CascadingAuthenticationState
无论是Blazor Server还是Blazor WebAssembly,组件在需要访问用户身份信息时,都通过CascadingAuthenticationState
提供的AuthenticationState
。这使得组件能够以一致的方式获取用户的身份认证状态,无需关注底层的实现细节。
使用级联参数获取用户信息
@code { [CascadingParameter] private Task<AuthenticationState> stateTask { get; set; } private ClaimsPrincipal user; protected override async Task OnInitializedAsync() { var authState = await stateTask; user = authState.User; } }
使用服务获取用户信息
@inject AuthenticationStateProvider AuthenticationStateProvider @code { private ClaimsPrincipal user; protected override async Task OnInitializedAsync() { var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); user = authState.User; } }
引用:
- Microsoft 文档:使身份验证状态成为级联参数
不同点
Server模式 实现RevalidatingServerAuthenticationStateProvider(基于AuthenticationStateProvider) 重点在验证ClaimPrincipal中的安全戳
在Blazor Server模式下,应用程序在服务器上运行,客户端通过SignalR持久连接与服务器通信。默认情况下,Blazor Server使用ServerAuthenticationStateProvider
,它直接从HttpContext.User
获取用户的身份信息。
为了增强安全性,Blazor Server提供了RevalidatingServerAuthenticationStateProvider
。它继承自ServerAuthenticationStateProvider
,能够在指定的时间间隔内重新验证用户的身份状态。这主要通过检查ClaimsPrincipal
中的安全戳(Security Stamp)来实现。当用户的安全戳发生变化(如密码更改、账户被禁用等),该提供程序会检测到并更新用户的身份状态,要求用户重新登录。
引用:
- Microsoft 文档:在服务器上重新验证身份
- Microsoft 文档:RevalidatingServerAuthenticationStateProvider 源码示例
Webassembly模式 实现AuthenticationStateProvider与HttpMessageHandler(可选) 重点在访问个人信息终结点(或从自包含令牌)解析ClaimPrincipal
在Blazor WebAssembly模式下,应用程序在客户端浏览器中运行,没有直接访问服务器HttpContext
的能力。因此,获取用户身份信息需要通过其他方式。例如,实现自定义的AuthenticationStateProvider
,通过调用后端API(如用户信息终结点)获取用户信息,或者解析存储在客户端的JWT令牌来构建ClaimsPrincipal
。
此外,为了在客户端向受保护的API发送请求时自动附加身份认证令牌,或是在令牌过期后自动刷新令牌,可以配置HttpClient
使用自定义的HttpMessageHandler
。这样,可以在请求头中添加必要的身份认证信息(如Bearer Token)或拦截401响应并刷新令牌重试请求。
引用:
- Microsoft 文档:在 Blazor WebAssembly 中实现自定义 AuthenticationStateProvider
- Microsoft 文档:向 HttpClient 请求添加令牌
眼见为实:通过案例实现两种渲染模式的鉴权
下面,我们将通过具体的案例,演示如何在Blazor Server和Blazor WebAssembly应用程序中实现身份认证。
Server + Cookie
注册服务
builder.Services.AddCascadingAuthenticationState(); // 添加级联参数获取认证信息 builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>(); // 注册自实现的StateProvider builder.Services.AddAuthentication(options => { //在这里设定默认方案 options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme; options.DefaultSignOutScheme = IdentityConstants.ExternalScheme; }).AddIdentityCookies(options => { }); builder.Services.AddIdentityCore<ApplicationUser>(options => //ApplicationUser继承自IdentityUser { //在这里设定鉴权配置,比如验证邮箱(这里不验证)、密码规则(这里设置最简规则) options.SignIn.RequireConfirmedAccount = false; options.Password.RequiredLength = 6; options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; }) .AddRoles<IdentityRole>() //需要自定义角色时,继承IdentityRole .AddEntityFrameworkStores<ApplicationDbContext>() //ApplicationDbContext需要继承IdentityDbContext,其他ORM请自行搜索Store实现 .AddSignInManager() //使用Cookie时,推荐注册 .AddDefaultTokenProviders(); builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization();
其中,AddIdentityCookies方法的源代码如下:
/// <summary> /// Adds the cookie authentication needed for sign in manager. /// </summary> /// <param name="builder">The current <see cref="AuthenticationBuilder"/> instance.</param> /// <param name="configureCookies">Action used to configure the cookies.</param> /// <returns>The <see cref="IdentityCookiesBuilder"/> which can be used to configure the identity cookies.</returns> public static IdentityCookiesBuilder AddIdentityCookies(this AuthenticationBuilder builder, Action<IdentityCookiesBuilder> configureCookies) { var cookieBuilder = new IdentityCookiesBuilder(); cookieBuilder.ApplicationCookie = builder.AddApplicationCookie(); cookieBuilder.ExternalCookie = builder.AddExternalCookie(); cookieBuilder.TwoFactorRememberMeCookie = builder.AddTwoFactorRememberMeCookie(); cookieBuilder.TwoFactorUserIdCookie = builder.AddTwoFactorUserIdCookie(); configureCookies?.Invoke(cookieBuilder); return cookieBuilder; }
IdentityNoOpEmailSender
public sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser> { private readonly IEmailSender emailSender = new NoOpEmailSender(); //案例不实现真正的邮件发送 public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>."); public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>."); public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); }
IdentityRevalidatingAuthenticationStateProvider
public sealed class IdentityRevalidatingAuthenticationStateProvider( ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory, IOptions<IdentityOptions> options) : RevalidatingServerAuthenticationStateProvider(loggerFactory) { protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); protected override async Task<bool> ValidateAuthenticationStateAsync( AuthenticationState authenticationState, CancellationToken cancellationToken) { // Get the user manager from a new scope to ensure it fetches fresh data await using var scope = scopeFactory.CreateAsyncScope(); var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); return await ValidateSecurityStampAsync(userManager, authenticationState.User); } private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal) { var user = await userManager.GetUserAsync(principal); if (user is null) { return false; } else if (!userManager.SupportsUserSecurityStamp) { return true; } else { var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); var userStamp = await userManager.GetSecurityStampAsync(user); return principalStamp == userStamp; } } }
登录时,可以使用表单提交,也可以使用Ajax POST(制作动态网页时的首选),后端处理的逻辑代码相同,即都通过SignInManager
实现发送Set-Cookie请求
表单处理:
public async Task LoginUser() { // This doesn't count login failures towards account lockout // To enable password failures to trigger account lockout, set lockoutOnFailure: true var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); if (result.Succeeded) { RedirectManager.RedirectTo("/"); } else if (result.RequiresTwoFactor) { RedirectManager.RedirectTo( "Account/LoginWith2fa", new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe }); } else if (result.IsLockedOut) { RedirectManager.RedirectTo("Account/Lockout"); } else { errorMessage = $@"账号或密码错误"; } }
WebAPI:
app.MapPost("Login", async (CheckDto Input, UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager) => { var emailResult = await userManager.FindByEmailAsync(dto.username); if (emailResult is null) return Results.BadRequest("账号未注册"); if (await userManager.CheckPasswordAsync(emailResult, dto.password)) { await signInManager.SignInAsync(emailResult, false); return Results.Ok(); } return Results.BadRequest("密码错误"); //401不能返回错误信息,故使用400 });
WebAssembly + Cookie + UserInfo Endpoint
此方式是微软推荐的鉴权方式,相比于JWT安全性较高
注册服务(使用.NET 8以后新增的方法)
builder.Services.AddAuthorization(); builder.Services.AddIdentityApiEndpoints<ApplicationUser>() .AddEntityFrameworkStores<ApplicationDbContext>(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapIdentityApi<ApplicationUser>();
源码解读:
/// <summary> /// Adds a set of common identity services to the application to support <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi{TUser}(IEndpointRouteBuilder)"/> /// and configures authentication to support identity bearer tokens and cookies. /// </summary> /// <param name="services">The <see cref="IServiceCollection"/>.</param> /// <param name="configure">Configures the <see cref="IdentityOptions"/>.</param> /// <returns>The <see cref="IdentityBuilder"/>.</returns> public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services, Action<IdentityOptions> configure) where TUser : class, new() { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); services .AddAuthentication(IdentityConstants.BearerAndApplicationScheme) .AddScheme<AuthenticationSchemeOptions, CompositeIdentityHandler>(IdentityConstants.BearerAndApplicationScheme, null, compositeOptions => { compositeOptions.ForwardDefault = IdentityConstants.BearerScheme; compositeOptions.ForwardAuthenticate = IdentityConstants.BearerAndApplicationScheme; }) .AddBearerToken(IdentityConstants.BearerScheme) .AddIdentityCookies(); return services.AddIdentityCore<TUser>(configure) .AddApiEndpoints(); } /// <summary> /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. /// </summary> /// <typeparam name="TUser">The type describing the user. This should match the generic parameter in <see cref="UserManager{TUser}"/>.</typeparam> /// <param name="endpoints"> /// The <see cref="IEndpointRouteBuilder"/> to add the identity endpoints to. /// Call <see cref="EndpointRouteBuilderExtensions.MapGroup(IEndpointRouteBuilder, string)"/> to add a prefix to all the endpoints. /// </param> /// <returns>An <see cref="IEndpointConventionBuilder"/> to further customize the added endpoints.</returns> public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRouteBuilder endpoints) where TUser : class, new() { ArgumentNullException.ThrowIfNull(endpoints); var timeProvider = endpoints.ServiceProvider.GetRequiredService<TimeProvider>(); var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService<IOptionsMonitor<BearerTokenOptions>>(); var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender<TUser>>(); var linkGenerator = endpoints.ServiceProvider.GetRequiredService<LinkGenerator>(); // We'll figure out a unique endpoint name based on the final route pattern during endpoint generation. string? confirmEmailEndpointName = null; var routeGroup = endpoints.MapGroup(""); // NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG. // https://github.com/dotnet/aspnetcore/issues/47338 routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>> ([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); if (!userManager.SupportsUserEmail) { throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support."); } var userStore = sp.GetRequiredService<IUserStore<TUser>>(); var emailStore = (IUserEmailStore<TUser>)userStore; var email = registration.Email; if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email)) { return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email))); } var user = new TUser(); await userStore.SetUserNameAsync(user, email, CancellationToken.None); await emailStore.SetEmailAsync(user, email, CancellationToken.None); var result = await userManager.CreateAsync(user, registration.Password); if (!result.Succeeded) { return CreateValidationProblem(result); } await SendConfirmationEmailAsync(user, userManager, context, email); return TypedResults.Ok(); }); routeGroup.MapPost("/login", async Task<Results<Ok<AccessTokenResponse>, EmptyHttpResult, ProblemHttpResult>> ([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService<SignInManager<TUser>>(); var useCookieScheme = (useCookies == true) || (useSessionCookies == true); var isPersistent = (useCookies == true) && (useSessionCookies != true); signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true); if (result.RequiresTwoFactor) { if (!string.IsNullOrEmpty(login.TwoFactorCode)) { result = await signInManager.TwoFactorAuthenticatorSignInAsync(login.TwoFactorCode, isPersistent, rememberClient: isPersistent); } else if (!string.IsNullOrEmpty(login.TwoFactorRecoveryCode)) { result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode); } } if (!result.Succeeded) { return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); } // The signInManager already produced the needed response in the form of a cookie or bearer token. return TypedResults.Empty; }); routeGroup.MapPost("/refresh", async Task<Results<Ok<AccessTokenResponse>, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> ([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService<SignInManager<TUser>>(); var refreshTokenProtector = bearerTokenOptions.Get(IdentityConstants.BearerScheme).RefreshTokenProtector; var refreshTicket = refreshTokenProtector.Unprotect(refreshRequest.RefreshToken); // Reject the /refresh attempt with a 401 if the token expired or the security stamp validation fails if (refreshTicket?.Properties?.ExpiresUtc is not { } expiresUtc || timeProvider.GetUtcNow() >= expiresUtc || await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not TUser user) { return TypedResults.Challenge(); } var newPrincipal = await signInManager.CreateUserPrincipalAsync(user); return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme); }); routeGroup.MapGet("/confirmEmail", async Task<Results<ContentHttpResult, UnauthorizedHttpResult>> ([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); if (await userManager.FindByIdAsync(userId) is not { } user) { // We could respond with a 404 instead of a 401 like Identity UI, but that feels like unnecessary information. return TypedResults.Unauthorized(); } try { code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); } catch (FormatException) { return TypedResults.Unauthorized(); } IdentityResult result; if (string.IsNullOrEmpty(changedEmail)) { result = await userManager.ConfirmEmailAsync(user, code); } else { // As with Identity UI, email and user name are one and the same. So when we update the email, // we need to update the user name. result = await userManager.ChangeEmailAsync(user, changedEmail, code); if (result.Succeeded) { result = await userManager.SetUserNameAsync(user, changedEmail); } } if (!result.Succeeded) { return TypedResults.Unauthorized(); } return TypedResults.Text("Thank you for confirming your email."); }) .Add(endpointBuilder => { var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText; confirmEmailEndpointName = $"{nameof(MapIdentityApi)}-{finalPattern}"; endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName)); }); routeGroup.MapPost("/resendConfirmationEmail", async Task<Ok> ([FromBody] ResendConfirmationEmailRequest resendRequest, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); if (await userManager.FindByEmailAsync(resendRequest.Email) is not { } user) { return TypedResults.Ok(); } await SendConfirmationEmailAsync(user, userManager, context, resendRequest.Email); return TypedResults.Ok(); }); routeGroup.MapPost("/forgotPassword", async Task<Results<Ok, ValidationProblem>> ([FromBody] ForgotPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is not null && await userManager.IsEmailConfirmedAsync(user)) { var code = await userManager.GeneratePasswordResetTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); await emailSender.SendPasswordResetCodeAsync(user, resetRequest.Email, HtmlEncoder.Default.Encode(code)); } // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have // returned a 400 for an invalid code given a valid user email. return TypedResults.Ok(); }); routeGroup.MapPost("/resetPassword", async Task<Results<Ok, ValidationProblem>> ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is null || !(await userManager.IsEmailConfirmedAsync(user))) { // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have // returned a 400 for an invalid code given a valid user email. return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken())); } IdentityResult result; try { var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(resetRequest.ResetCode)); result = await userManager.ResetPasswordAsync(user, code, resetRequest.NewPassword); } catch (FormatException) { result = IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken()); } if (!result.Succeeded) { return CreateValidationProblem(result); } return TypedResults.Ok(); }); var accountGroup = routeGroup.MapGroup("/manage").RequireAuthorization(); accountGroup.MapPost("/2fa", async Task<Results<Ok<TwoFactorResponse>, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService<SignInManager<TUser>>(); var userManager = signInManager.UserManager; if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } if (tfaRequest.Enable == true) { if (tfaRequest.ResetSharedKey) { return CreateValidationProblem("CannotResetSharedKeyAndEnable", "Resetting the 2fa shared key must disable 2fa until a 2fa token based on the new shared key is validated."); } if (string.IsNullOrEmpty(tfaRequest.TwoFactorCode)) { return CreateValidationProblem("RequiresTwoFactor", "No 2fa token was provided by the request. A valid 2fa token is required to enable 2fa."); } if (!await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, tfaRequest.TwoFactorCode)) { return CreateValidationProblem("InvalidTwoFactorCode", "The 2fa token provided by the request was invalid. A valid 2fa token is required to enable 2fa."); } await userManager.SetTwoFactorEnabledAsync(user, true); } else if (tfaRequest.Enable == false || tfaRequest.ResetSharedKey) { await userManager.SetTwoFactorEnabledAsync(user, false); } if (tfaRequest.ResetSharedKey) { await userManager.ResetAuthenticatorKeyAsync(user); } string[]? recoveryCodes = null; if (tfaRequest.ResetRecoveryCodes || (tfaRequest.Enable == true && await userManager.CountRecoveryCodesAsync(user) == 0)) { var recoveryCodesEnumerable = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); recoveryCodes = recoveryCodesEnumerable?.ToArray(); } if (tfaRequest.ForgetMachine) { await signInManager.ForgetTwoFactorClientAsync(); } var key = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(key)) { await userManager.ResetAuthenticatorKeyAsync(user); key = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(key)) { throw new NotSupportedException("The user manager must produce an authenticator key after reset."); } } return TypedResults.Ok(new TwoFactorResponse { SharedKey = key, RecoveryCodes = recoveryCodes, RecoveryCodesLeft = recoveryCodes?.Length ?? await userManager.CountRecoveryCodesAsync(user), IsTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user), IsMachineRemembered = await signInManager.IsTwoFactorClientRememberedAsync(user), }); }); accountGroup.MapGet("/info", async Task<Results<Ok<InfoResponse>, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager)); }); accountGroup.MapPost("/info", async Task<Results<Ok<InfoResponse>, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromBody] InfoRequest infoRequest, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService<UserManager<TUser>>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } if (!string.IsNullOrEmpty(infoRequest.NewEmail) && !_emailAddressAttribute.IsValid(infoRequest.NewEmail)) { return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(infoRequest.NewEmail))); } if (!string.IsNullOrEmpty(infoRequest.NewPassword)) { if (string.IsNullOrEmpty(infoRequest.OldPassword)) { return CreateValidationProblem("OldPasswordRequired", "The old password is required to set a new password. If the old password is forgotten, use /resetPassword."); } var changePasswordResult = await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword); if (!changePasswordResult.Succeeded) { return CreateValidationProblem(changePasswordResult); } } if (!string.IsNullOrEmpty(infoRequest.NewEmail)) { var email = await userManager.GetEmailAsync(user); if (email != infoRequest.NewEmail) { await SendConfirmationEmailAsync(user, userManager, context, infoRequest.NewEmail, isChange: true); } } return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager)); }); }
可以看到,基架生成的info接口返回的用户信息非常有限,实际业务中需要自行写一个返回角色/Claims信息的userinfo接口(注入UserManager和RoleManager查询)
根据基架生成的API,编写前端代码:
注册服务:
// 注册LocalStorage服务 builder.Services.AddBlazoredLocalStorageAsSingleton(); // 注册鉴权服务 builder.Services.AddSingleton<AuthenticationStateProvider, CookieAuthenticationStateProvider>(); // 注册通用客户端的鉴权拦截器(令牌过期重试) builder.Services.AddSingleton<AuthenticationStateHandler>(); // 注册鉴权专用客户端 builder.Services.AddHttpClient("auth", client => { client.BaseAddress = new Uri("API_URL"); }); // 注册业务通用客户端 builder.Services.AddHttpClient("backend", client => { client.BaseAddress = new Uri("API_URL"); }).AddHttpMessageHandler<AuthenticationStateHandler>(); // 设为默认客户端 builder.Services.AddSingleton(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("backend")); // 注册鉴权基架 builder.Services.AddAuthorizationCore(); builder.Services.AddCascadingAuthenticationState();
AuthenticationStateHandler:
public class AuthenticationStateHandler(AuthenticationStateProvider stateProvider, NavigationManager navigationManager) : DelegatingHandler { protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var response = await base.SendAsync(request, cancellationToken); //如果令牌过期,刷新令牌并重试请求 if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { var authState = await stateProvider.GetAuthenticationStateAsync(); if (authState.User.Identity?.IsAuthenticated ?? false) { if (await (stateProvider as CookieAuthenticationStateProvider).RefreshTokenAsync()) { return await SendAsync(request, cancellationToken); } } navigationManager.NavigateTo("/login"); } return response; } }
CookieAuthenticationStateProvider:
public sealed class CookieAuthenticationStateProvider(IServiceProvider serviceProvider, IHttpClientFactory httpClientFactory, ILocalStorageService localStorage, ISyncLocalStorageService syncLocalStorage) : AuthenticationStateProvider { //token过期时间 private static TimeSpan UserCacheRefreshInterval = TimeSpan.FromHours(1); //上次获取token时间 private static DateTimeOffset UserLastCheckTime = DateTimeOffset.FromUnixTimeSeconds(0); //缓存用户状态 private ClaimsPrincipal CachedUser = new(new ClaimsIdentity()); //默认用户状态(未登录) private static readonly Task<AuthenticationState> defaultUnanthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); //刷新令牌 private string? refresh_token { get; set; } = syncLocalStorage.GetItem<string>("refresh_token"); //访问令牌 private string? access_token { get; set; } = syncLocalStorage.GetItem<string>("access_token"); //鉴权专用客户端示例 private HttpClient client = httpClientFactory.CreateClient("auth"); //解析令牌获取身份信息 private async Task ParseTokenAsync() { var response = await client.GetAsync("/info"); //推荐自己写userinfo接口代替 if (response.IsSuccessStatusCode) { var infoResponse = await response.Content.ReadFromJsonAsync<InfoResponse>(); if (infoResponse != null) { var claims = new List<Claim> { new Claim(ClaimTypes.Email, infoResponse.Email), new Claim("IsEmailConfirmed", infoResponse.IsEmailConfirmed.ToString()) }; CachedUser = new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer")); } } else { CachedUser = new ClaimsPrincipal(new ClaimsIdentity()); } } //设置客户端携带令牌 private void SetClientToken() { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token); serviceProvider.GetRequiredService<HttpClient>().DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token); } //处理令牌接口返回值 private async Task<bool> ParseResponseAsync(AccessTokenResponse token) { if (token != null) { refresh_token = token.RefreshToken; access_token = token.AccessToken; await localStorage.SetItemAsync("access_token", access_token); await localStorage.SetItemAsync("refresh_token", refresh_token); UserCacheRefreshInterval = TimeSpan.FromSeconds(token.ExpiresIn); UserLastCheckTime = DateTimeOffset.UtcNow; SetClientToken(); await ParseTokenAsync(); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); return true; } return false; } //注册 public async Task<RegisterResponse> RegisterAsync(RegisterModel model) { var response = await client.PostAsJsonAsync(@"register", model); var reg = await response.Content.ReadFromJsonAsync<RegisterResponse>(); if (reg.Succeeded) await ParseResponseAsync(reg.TokenResponse); return reg; } //登录 public async Task<bool> LoginAsync(string username, string password) { var response = await client.PostAsJsonAsync(@"login", new LoginRequest { Username = username, Password = password } ); if (response.StatusCode == HttpStatusCode.Unauthorized) { return false; } var token = await response.Content.ReadFromJsonAsync<AccessTokenResponse>(); return await ParseResponseAsync(token); } //登出 public async Task LogoutAsync() { await client.PostAsync(@"logout",new StringContent(string.Empty)); refresh_token = null; access_token = null; await localStorage.RemoveItemAsync("access_token"); await localStorage.RemoveItemAsync("refresh_token"); UserLastCheckTime = DateTimeOffset.FromUnixTimeSeconds(0); CachedUser = new ClaimsPrincipal(new ClaimsIdentity()); NotifyAuthenticationStateChanged(defaultUnanthenticatedTask); } //刷新令牌 public async Task<bool> RefreshTokenAsync() { if (refresh_token is null) return false; SetClientToken(); var response = await client.PostAsJsonAsync(@"refresh", new { RefreshToken = refresh_token }); if (response.StatusCode == HttpStatusCode.Unauthorized) { return false; } var token = await response.Content.ReadFromJsonAsync<AccessTokenResponse>(); return await ParseResponseAsync(token); } //获取用户鉴权信息 public override async Task<AuthenticationState> GetAuthenticationStateAsync() { if (DateTimeOffset.UtcNow - UserLastCheckTime < UserCacheRefreshInterval || await RefreshTokenAsync()) { return new AuthenticationState(CachedUser); } return await defaultUnanthenticatedTask; } }
WebAssembly + JWT
此方法安全性较低,不推荐
注册服务
// JWT 配置 var jwtSettings = builder.Configuration.GetSection("JwtSettings"); var secretKey = Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!); // 添加认证服务 builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings["Issuer"], ValidAudience = jwtSettings["Audience"], IssuerSigningKey = new SymmetricSecurityKey(secretKey) }; }); // 添加授权服务 builder.Services.AddAuthorization(); // 添加 JWT 服务 builder.Services.AddScoped<JwtService>(); // 添加 Identity 服务(如果需要) builder.Services.AddIdentityCore<ApplicationUser>(options => { options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequiredLength = 6; options.SignIn.RequireConfirmedEmail = false; }).AddRoles<IdentityRole<Guid>>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders() .AddRoleManager<RoleManager<IdentityRole<Guid>>>() .AddUserManager<UserManager<ApplicationUser>>(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization();
appsettings.json
"JwtSettings": { "SecretKey": "your-very-looong-secret-key-here", "Issuer": "your-issuer", "Audience": "your-audience" }
JwtService
public class JwtService(IConfiguration configuration) { public AccessTokenResponse GenerateTokens(ApplicationUser user, IList<string> roles) { // 生成访问令牌 var accessToken = GenerateAccessToken(user, roles); // 生成刷新令牌 var refreshToken = GenerateRefreshToken(); // 计算过期时间(以秒为单位) var expiresIn = Convert.ToInt32(TimeSpan.FromHours(1).TotalSeconds); return new AccessTokenResponse(accessToken, expiresIn, refreshToken); } private string GenerateAccessToken(ApplicationUser user, IList<string> roles) { var secretKey = Encoding.UTF8.GetBytes(configuration["JwtSettings:SecretKey"]!); var signingCredentials = new SigningCredentials( new SymmetricSecurityKey(secretKey), SecurityAlgorithms.HmacSha256Signature ); var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Name, user.UserName!) }; claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); var token = new JwtSecurityToken( issuer: configuration["JwtSettings:Issuer"], audience: configuration["JwtSettings:Audience"], claims: claims, expires: DateTime.UtcNow.AddHours(1), signingCredentials: signingCredentials ); return new JwtSecurityTokenHandler().WriteToken(token); } private string GenerateRefreshToken() { var randomNumber = new byte[64]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(randomNumber); return Convert.ToBase64String(randomNumber); } public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token) { var secretKey = configuration["JwtSettings:SecretKey"]!; var tokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = false, // 不验证过期时间 ValidateIssuerSigningKey = true, ValidIssuer = configuration["JwtSettings:Issuer"], ValidAudience = configuration["JwtSettings:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)) }; var tokenHandler = new JwtSecurityTokenHandler(); var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken); if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature, StringComparison.InvariantCultureIgnoreCase)) { return null; } return principal; } }
IdentityEndpoints
public static void MapIdentityEndpoints(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/Account").WithTags("Account"); group.MapPost("/Login", async ( LoginRequest model, UserManager<ApplicationUser> userManager, JwtService jwtService) => { var user = await userManager.FindByNameAsync(model.Username); if (user == null) { return Results.Unauthorized(); } var isPasswordValid = await userManager.CheckPasswordAsync(user, model.Password); if (!isPasswordValid) { return Results.Unauthorized(); } var roles = await userManager.GetRolesAsync(user); var tokenResponse = jwtService.GenerateTokens(user, roles); // 保存刷新令牌到用户记录 user.RefreshToken = tokenResponse.RefreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7); // 刷新令牌7天有效 await userManager.UpdateAsync(user); return Results.Ok(tokenResponse); }) .AllowAnonymous() .WithName("Login") .WithOpenApi(); group.MapGet("/User", (HttpContext context) => { var user = context.User; return Results.Ok(new { Username = user.Identity?.Name, UserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value, Roles = user.Claims .Where(c => c.Type == ClaimTypes.Role) .Select(c => c.Value) .ToList() }); }) .RequireAuthorization() .WithName("User") .WithOpenApi(); group.MapPost("/Refresh", async ( RefreshTokenModel model, UserManager<ApplicationUser> userManager, JwtService jwtService, HttpContext context) => { // 从请求头获取过期的访问令牌 string? accessToken = context.Request.Headers["Authorization"] .FirstOrDefault()?.Split(" ").Last(); if (string.IsNullOrEmpty(accessToken)) { return Results.BadRequest("Access token is required"); } // 从过期的访问令牌中获取用户信息 var principal = jwtService.GetPrincipalFromExpiredToken(accessToken); if (principal == null) { return Results.BadRequest("Invalid access token"); } var username = principal.Identity?.Name; var user = await userManager.FindByNameAsync(username!); if (user == null || user.RefreshToken != model.RefreshToken || user.RefreshTokenExpiryTime <= DateTime.UtcNow) { return Results.BadRequest("Invalid refresh token"); } // 生成新的令牌 var roles = await userManager.GetRolesAsync(user); var newTokenResponse = jwtService.GenerateTokens(user, roles); // 更新数据库中的刷新令牌 user.RefreshToken = newTokenResponse.RefreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7); await userManager.UpdateAsync(user); return Results.Ok(newTokenResponse); }) .AllowAnonymous() .WithName("Refresh") .WithOpenApi(); ; group.MapPost("/Logout", async ( UserManager<ApplicationUser> userManager, HttpContext context) => { var user = context.User; var appUser = await userManager.FindByNameAsync(user.Identity?.Name!); if (appUser == null) { return Results.NotFound(); } // 清除刷新令牌 appUser.RefreshToken = null; appUser.RefreshTokenExpiryTime = null; await userManager.UpdateAsync(appUser); return Results.Ok(); }) .RequireAuthorization() .WithName("Logout") .WithOpenApi(); group.MapPost("/Register", async ( RegisterModel model, UserManager<ApplicationUser> userManager, JwtService jwtService) => { // 验证模型 if (model.Password != model.ConfirmPassword) { return Results.BadRequest(new RegisterResponse( false, ["密码和确认密码不匹配"], null, null )); } // 检查用户名是否已存在 var existingUser = await userManager.FindByNameAsync(model.Username); if (existingUser != null) { return Results.BadRequest(new RegisterResponse( false, ["用户名已存在"], null, null )); } // 检查邮箱是否已存在 var existingEmail = await userManager.FindByEmailAsync(model.Email); if (existingEmail != null) { return Results.BadRequest(new RegisterResponse( false, ["邮箱已被使用"], null, null )); } // 创建新用户 var user = new ApplicationUser { UserName = model.Username, Email = model.Email, EmailConfirmed = true // 如果需要邮箱验证,设置为 false }; // 添加用户 var result = await userManager.CreateAsync(user, model.Password); if (!result.Succeeded) { return Results.BadRequest(new RegisterResponse( false, result.Errors.Select(e => e.Description), null, null )); } // 添加默认角色 // await userManager.AddToRoleAsync(user, "User"); // 生成令牌 var roles = await userManager.GetRolesAsync(user); var tokenResponse = jwtService.GenerateTokens(user, roles); // 保存刷新令牌 user.RefreshToken = tokenResponse.RefreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7); await userManager.UpdateAsync(user); // 返回成功响应和令牌 return Results.Ok(new RegisterResponse(true, [], tokenResponse, "注册成功")); }) .AllowAnonymous() .WithName("Register") .WithOpenApi(); }
前端只需要修改上一案例中的ParseTokenAsync
方法
private async Task ParseTokenAsync() { var handler = new JwtSecurityTokenHandler(); var token = handler.ReadJwtToken(access_token); CachedUser = new ClaimsPrincipal(new ClaimsIdentity(token.Claims, "Bearer")); }
结语
详细案例文章、视频、源码请等待后续发布