Blazor 中的身份验证和授权:实用指南
如果您使用过 ASP.NET MVC 或 Razor Pages,您可能对身份验证的工作原理有一个心理模型:中间件拦截请求,检查 cookie 或令牌,填充 HttpContext.User,然后您就可以开始比赛了。 Blazor 以微妙但重要的方式改变了这种心理模型 - 如果您不尽早理解这些差异,您最终会调试感觉不可能的身份验证问题。
在这篇文章中,我想介绍 Blazor 中身份验证和授权的实际工作原理,涵盖服务器和 WebAssembly 托管模型。我们将从基础知识一直讲到自定义提供程序、外部 OAuth 以及我所看到的甚至经验丰富的 .NET 开发人员也会遇到的陷阱。
为什么 Blazor 中的身份验证不同
在传统的 ASP.NET 中,每个用户交互都是一个 HTTP 请求。服务器验证凭据,设置 cookie,并且每个后续请求都携带该 cookie。身份验证管道是线性且可预测的。
Blazor 服务器通过持久 SignalR 连接运行。初始 HTTP 请求加载页面后,所有后续交互都通过 WebSocket 进行。每次单击按钮都不会产生新的 HTTP 请求,因此中间件不会在每次交互时重新执行。 HttpContext 在初始连接期间可用,但在电路的整个生命周期中依赖它会导致错误。
Blazor WebAssembly 完全在浏览器中运行。根本没有服务器端 HttpContext。身份验证状态必须从 API 获取、存储在客户端,并通过令牌(通常是 JWT)进行管理。服务器仅在令牌验证进行时才信任客户端。
这意味着 Blazor 需要自己的身份验证状态抽象,无论托管模型如何,该抽象都可以工作。这个抽象就是 AuthenticationStateProvider。
身份验证状态:基金会
Blazor 身份验证系统的核心是 AuthenticationStateProvider。这是一个抽象类,公开了一个关键方法:
public abstract Task<AuthenticationState> GetAuthenticationStateAsync();
AuthenticationState 对象包装了 ClaimsPrincipal — 整个 .NET 使用的相同身份模型。组件不直接与 cookie 或令牌对话;他们向 AuthenticationStateProvider 询问当前状态。
为了使此状态可用于整个组件树,Blazor 提供了 CascadingAuthenticationState。您通常将路由器包裹在 App.razor 或您的布局中:
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<p>You're not authorized to view this page.</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Page not found.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
AuthorizeRouteView 在这里承担双重职责:它在呈现匹配的页面组件之前检查用户是否经过身份验证和授权,并在未经过身份验证和授权时提供后备 UI。
在具有统一 Blazor 模型的 .NET 8 及更高版本中,您将在 App.razor 中进行配置,并且当您在服务注册中使用 AddCascadingAuthenticationState() 时,框架会自动处理级联参数。
ASP.NET 身份集成
对于大多数项目,您不需要从头开始构建身份验证。 ASP.NET Identity 为您提供开箱即用的用户管理、密码哈希、双因素身份验证和帐户确认。使用 Blazor 进行设置从 Program.cs 开始:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDefaultIdentity<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});
通过 .NET 8+ 中的 Blazor Web App 模板,脚手架的 Identity UI 直接使用 Razor 组件。您将获得与 Blazor 应用程序的其余部分自然集成的登录、注册和帐户管理页面,不再需要 Razor 页面和 Blazor 组件的尴尬组合。
ApplicationDbContext 继承自 IdentityDbContext,您需要运行迁移来创建身份表:
public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
}
dotnet ef migrations add InitialIdentity
dotnet ef database update
AuthorizeView 组件
一旦身份验证状态流经组件树,AuthorizeView 就可以让您有条件地渲染 UI:
<AuthorizeView>
<Authorized>
<p>Welcome, @context.User.Identity?.Name!</p>
<a href="/account/manage">Manage Account</a>
<form method="post" action="/account/logout">
<button type="submit">Log Out</button>
</form>
</Authorized>
<NotAuthorized>
<a href="/account/login">Log In</a>
<a href="/account/register">Register</a>
</NotAuthorized>
</AuthorizeView>
<Authorized> 内的 context 参数使您可以访问 AuthenticationState,因此您可以直接在标记中检查声明、角色和用户身份。
您还可以将 AuthorizeView 与角色和策略一起使用:
<AuthorizeView Roles="Admin,Moderator">
<Authorized>
<button @onclick="DeletePost">Delete Post</button>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="CanEditArticles">
<Authorized>
<button @onclick="EditArticle">Edit</button>
</Authorized>
</AuthorizeView>
要记住一件事:AuthorizeView 是 UI 问题。它隐藏或显示元素,但不保护底层逻辑。如果有人可以直接调用您的 API 端点或调用您的方法,他们将完全绕过 AuthorizeView。始终在服务器端强制执行授权。
[授权] 属性
要保护整个页面,请应用 [Authorize] 属性:
@page "/admin/dashboard"
@attribute [Authorize(Roles = "Admin")]
<h1>Admin Dashboard</h1>
<p>Only administrators can see this page.</p>
当未经身份验证的用户导航到此页面时,AuthorizeRouteView 启动并呈现您之前定义的 <NotAuthorized> 模板。您可以通过使用导航处理 NotAuthorized 情况来重定向到登录页面:
<NotAuthorized>
@if (!context.User.Identity?.IsAuthenticated ?? true)
{
<RedirectToLogin />
}
else
{
<p>You don't have permission to access this page.</p>
}
</NotAuthorized>
一个简单的 RedirectToLogin 组件可能如下所示:
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(Navigation.Uri);
Navigation.NavigateTo($"/account/login?returnUrl={returnUrl}", forceLoad: true);
}
}
forceLoad: true 在这里很重要 - 您需要一个实际的 HTTP 导航,以便服务器端身份验证中间件可以正确处理登录流程。
基于角色和基于策略的授权
角色是最简单的模型:将用户分配到“管理员”或“编辑者”等组,然后检查成员资格。但政策给了你更大的灵活性。
在 Program.cs 中注册策略:
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("CanPublish", policy =>
policy.RequireClaim("Permission", "Publish"));
options.AddPolicy("MinimumAge", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
options.AddPolicy("PremiumUser", policy =>
policy.RequireRole("Premium")
.RequireClaim("Subscription", "Active"));
});
自定义需求需要一个处理程序:
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthClaim = context.User.FindFirst("DateOfBirth");
if (dateOfBirthClaim is null)
return Task.CompletedTask;
var dateOfBirth = DateOnly.Parse(dateOfBirthClaim.Value);
var age = DateOnly.FromDateTime(DateTime.Today).Year - dateOfBirth.Year;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
注册处理程序:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
在组件中,当需要动态逻辑时,您还可以通过编程方式检查授权:
@inject IAuthorizationService AuthorizationService
@inject AuthenticationStateProvider AuthStateProvider
@code {
private bool canPublish;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var result = await AuthorizationService.AuthorizeAsync(
authState.User, "CanPublish");
canPublish = result.Succeeded;
}
}
外部 OAuth 提供商
使用 ASP.NET 身份验证中间件,支持“使用 Google 登录”或“使用 GitHub 登录”非常简单。这些是在服务器端配置的,因为 OAuth 流程需要 HTTP 重定向。
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Auth:Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Google:ClientSecret"]!;
options.Scope.Add("profile");
})
.AddMicrosoftAccount(options =>
{
options.ClientId = builder.Configuration["Auth:Microsoft:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Microsoft:ClientSecret"]!;
})
.AddGitHub(options =>
{
options.ClientId = builder.Configuration["Auth:GitHub:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:GitHub:ClientSecret"]!;
});
对于 GitHub,您需要 AspNet.Security.OAuth.GitHub NuGet 包,因为它不包含在默认 ASP.NET 库中。
然后,登录 UI 会提供触发外部质询的链接:
@page "/account/external-login"
<h2>Sign in with an external provider</h2>
<form method="post" action="/api/auth/external-login">
<button type="submit" name="provider" value="Google">
Sign in with Google
</button>
<button type="submit" name="provider" value="Microsoft">
Sign in with Microsoft
</button>
<button type="submit" name="provider" value="GitHub">
Sign in with GitHub
</button>
</form>
API端点触发质询并处理回调:
app.MapPost("/api/auth/external-login", (string provider, HttpContext context) =>
{
var properties = new AuthenticationProperties
{
RedirectUri = "/api/auth/external-callback"
};
return Results.Challenge(properties, [provider]);
});
Blazor 中的外部身份验证始终需要完整页面导航 - 如果不通过服务器,您无法在 SignalR 电路或 WebAssembly 应用程序内完成 OAuth 重定向。
Blazor WebAssembly 中基于令牌的身份验证Blazor WebAssembly 在客户端上运行,因此基于 cookie 的身份验证不会以相同的方式应用。相反,您通常使用存储在内存中并附加到传出 HTTP 请求的 JWT。
框架提供 AuthorizationMessageHandler 自动附加令牌:
builder.Services.AddHttpClient("API",
client => client.BaseAddress = new Uri("https://api.example.com"))
.AddHttpMessageHandler<AuthorizationMessageHandler>();
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("API"));
对于根据自己的 API 进行身份验证的独立 Blazor WASM 应用程序,您将实现一个解析 JWT 的自定义 AuthenticationStateProvider:
public class JwtAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly HttpClient _httpClient;
public JwtAuthenticationStateProvider(
ILocalStorageService localStorage,
HttpClient httpClient)
{
_localStorage = localStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _localStorage.GetItemAsync<string>("authToken");
if (string.IsNullOrWhiteSpace(token))
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void NotifyAuthStateChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var padded = payload.Length % 4 switch
{
2 => payload + "==",
3 => payload + "=",
_ => payload
};
var bytes = Convert.FromBase64String(padded);
var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(bytes);
return json?.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))
?? Enumerable.Empty<Claim>();
}
}
成功登录后,您存储令牌并通知身份验证状态:
public class AuthService
{
private readonly HttpClient _httpClient;
private readonly ILocalStorageService _localStorage;
private readonly JwtAuthenticationStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
ILocalStorageService localStorage,
AuthenticationStateProvider authStateProvider)
{
_httpClient = httpClient;
_localStorage = localStorage;
_authStateProvider = (JwtAuthenticationStateProvider)authStateProvider;
}
public async Task<bool> LoginAsync(string email, string password)
{
var response = await _httpClient.PostAsJsonAsync("/api/auth/login",
new { Email = email, Password = password });
if (!response.IsSuccessStatusCode)
return false;
var result = await response.Content
.ReadFromJsonAsync<LoginResponse>();
await _localStorage.SetItemAsync("authToken", result!.Token);
_authStateProvider.NotifyAuthStateChanged();
return true;
}
public async Task LogoutAsync()
{
await _localStorage.RemoveItemAsync("authToken");
_authStateProvider.NotifyAuthStateChanged();
}
}
需要注意的是:将 JWT 存储在 localStorage 中会使它们遭受 XSS 攻击。对于安全性更高的应用程序,请考虑仅将令牌保留在内存中并使用刷新令牌,或者采用后端换前端 (BFF) 模式,其中服务器管理令牌而客户端使用仅 HTTP 的 cookie。
Blazor 服务器与 WebAssembly:安全注意事项
托管模型从根本上改变您的安全状况。
Blazor 服务器 将所有组件逻辑保留在服务器上。客户端只能通过 SignalR 看到渲染的 HTML 差异。这意味着:
- 敏感逻辑永远不会离开服务器
- 您可以直接从组件访问数据库和内部服务
- 身份验证状态来自初始连接时服务器的
HttpContext - 电路可以比身份验证 cookie 的寿命更长 — 如果用户的 cookie 过期,电路将保持活动状态直到断开连接
- 您应该优雅地处理电路断开并在重新连接时重新验证身份验证状态
Blazor WebAssembly 完全在浏览器中运行。这意味着:
- 您的所有组件代码均可下载和检查
- 切勿将机密、连接字符串或敏感业务逻辑放入 WASM 组件中
- 身份验证仅在客户端上强制执行以实现用户体验;真正的执行必须发生在 API 层
- 代币管理是您的责任
- 考虑使用托管模型,其中服务器项目处理身份验证并为 WASM 应用程序提供服务
我为 WebAssembly 应用程序推荐的一种模式是将每个组件视为“不受信任的 UI”,并将每个 API 端点视为由未知客户端调用。无论客户端检查什么,都在服务器端验证所有内容。
构建自定义 AuthenticationStateProvider
有时内置提供程序不适合您的架构。也许您正在与旧版身份验证系统集成,或者您需要轮询身份验证状态更改。以下是 Blazor Server 的更完整的自定义提供程序:
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IUserService _userService;
public CustomAuthStateProvider(
IHttpContextAccessor httpContextAccessor,
IUserService userService)
{
_httpContextAccessor = httpContextAccessor;
_userService = userService;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated != true)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var user = await _userService.GetUserWithClaimsAsync(userId);
if (user is null)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.DisplayName),
new(ClaimTypes.Email, user.Email)
};
claims.AddRange(user.Roles.Select(r => new Claim(ClaimTypes.Role, r)));
claims.AddRange(user.Permissions.Select(p => new Claim("Permission", p)));
var identity = new ClaimsIdentity(claims, "Custom");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void MarkUserAsAuthenticated(string userId)
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public void MarkUserAsLoggedOut()
{
var anonymous = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(anonymous));
NotifyAuthenticationStateChanged(authState);
}
}
将其注册到Program.cs中:
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<CustomAuthStateProvider>();
这里的关键见解是 NotifyAuthenticationStateChanged — 调用它会触发级联参数进行更新,从而重新评估组件树中的每个 AuthorizeView 和 AuthorizeRouteView。这就是让 UI 对登录/注销事件做出反应而无需刷新整个页面的方法。
常见陷阱和解决方案
在多个项目中使用 Blazor 身份验证后,以下是我最常看到的问题:
1. 在 Blazor 服务器组件中使用 HttpContextHttpContext 在初始 HTTP 请求期间可用,但在 SignalR 交互期间它是 null 或过时的。不要将 IHttpContextAccessor 注入到初始渲染后运行的组件中。
解决方案: 在初始化期间从 HttpContext 捕获您需要的内容并将其存储在作用域服务中:
public class UserContext
{
public string? UserId { get; set; }
public string? AccessToken { get; set; }
}
// In a component that renders during the initial HTTP request:
@inject IHttpContextAccessor HttpContextAccessor
@inject UserContext UserContext
@code {
protected override void OnInitialized()
{
var context = HttpContextAccessor.HttpContext;
UserContext.UserId = context?.User.FindFirstValue(ClaimTypes.NameIdentifier);
UserContext.AccessToken = context?.Request.Headers.Authorization
.ToString().Replace("Bearer ", "");
}
}
2.登录后身份验证状态不更新
您调用登录 API,它成功了,但 UI 仍然显示“登录”。
解决方案: 身份验证状态更改后,您必须在 AuthenticationStateProvider 上调用 NotifyAuthenticationStateChanged。该框架不会神奇地检测到令牌已存储或 cookie 已设置。
3. 授权属性不适用于组件
您将 [Authorize] 添加到组件,但它不会阻止未经身份验证的用户。
解决方案: 确保您在 App.razor 中使用 AuthorizeRouteView 而不是普通的 RouteView。标准 RouteView 完全忽略授权属性。
4. 预渲染破坏身份验证状态
在 Blazor WebAssembly 中的服务器端预呈现期间,没有可用的身份验证令牌。组件呈现为未经身份验证,然后在 WASM 加载后闪烁到经过身份验证的状态。
解决方案: 使用 @rendermode InteractiveWebAssembly 禁用对身份验证敏感页面的预渲染(无需预渲染),或者优雅地处理加载状态:
<AuthorizeView>
<Authorized>
<p>Welcome back, @context.User.Identity?.Name</p>
</Authorized>
<Authorizing>
<p>Loading...</p>
</Authorizing>
<NotAuthorized>
<a href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
5. 长时间运行的电路中的令牌过期
Blazor 服务器电路可以保持活动状态数小时。如果您的令牌或会话过期,用户在 UI 中保持“经过身份验证”,但 API 调用开始失败。
解决方案: 实施定期检查或使用 RevalidatingServerAuthenticationStateProvider:
public class RevalidatingAuthStateProvider
: RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceScopeFactory _scopeFactory;
public RevalidatingAuthStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory)
: base(loggerFactory)
{
_scopeFactory = scopeFactory;
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<IdentityUser>>();
var user = await userManager.GetUserAsync(authenticationState.User);
return user is not null;
}
}
这会每 30 分钟验证一次用户是否仍然存在(并且其安全标记未更改)。
结论
Blazor 中的身份验证和授权需要转变传统请求-响应 ASP.NET 的思维方式。 AuthenticationStateProvider 抽象是理解它们如何组合在一起的关键——一旦你内化了它,剩下的就自然而然了。
对于大多数应用程序,从 ASP.NET Identity 和内置模板开始。它们处理用户管理、密码散列和令牌生成的繁重工作。随着您的需求的增长,分层策略和基于声明的授权。在用户需要时添加外部 OAuth 提供商。
托管模型很重要:Blazor Server 为您提供更传统的安全态势,其中代码保留在服务器上,而 WebAssembly 则推动您采用 API 优先的思维,其中客户端在设计上不受信任。两者本质上都不是更安全——它们只是有不同的威胁模型。
无论您选择哪种方法,请记住黄金法则:**用户界面中的授权是为了用户体验,服务器上的授权是为了安全。**始终强制执行两者。