[Blazor] 一文理清 Blazor Identity 鉴权验证
一文理清 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
等)。如果凭据有效,它将解析并构建用户的身份信息;如果无效,则将用户视为未认证状态。
引用:
后端鉴权逻辑服务: IdentityCore (Microsoft.AspNetCore.Identity)
IdentityCore
是ASP.NET Core提供的完整的身份管理框架。它为开发者提供了处理用户注册、登录、角色管理、密码重置等功能的 APIs 和服务。IdentityCore
高度可定制,可以使用不同的数据存储方式(如Entity Framework Core、MongoDB等)和密码哈希算法。
在Blazor Server模式下,IdentityCore
通常与AuthenticationMiddleware
结合使用。后端服务器负责处理所有与身份相关的逻辑,包括验证用户凭据、管理用户数据和生成身份认证令牌等。
核心架构图
配置与启动流程 (图中的配置函数非常原始,案例中会使用较新的简化函数,不过最终都是调用它们)
用户注册与认证流程
授权与角色管理流程(更侧重于使用AuthorizationMiddleware)
扩展点和自定义实现
主要特点:
模块化设计:核心身份模型、用户管理服务、存储抽象层、验证器接口
灵活的扩展性:自定义用户模型、自定义存储实现、可配置的选项、验证器扩展
完整的认证流程:用户注册、密码验证、双因素认证、外部登录
丰富的授权机制:基于角色、基于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"));
}
结语
详细案例文章、视频、源码请等待后续发布