.NET6下Blazor Server+Web API实现Refresh Token拦截请求防登出
Blazor Server 刷新登出与HTTP请求拦截实现方案
首先明确:用refresh token解决刷新页面自动登出的思路是完全正确的。你之前自定义中间件只执行一次,是Blazor Server的运行机制导致的:Blazor Server通过SignalR长连接维持电路(Circuit),ASP.NET Core管道中间件仅在电路初始化阶段执行一次,后续组件内发起的所有HTTP请求都不会重复经过该中间件,自然无法满足每次请求拦截的需求。
以下是分步骤的可落地方案:
一、解决页面刷新丢失登录态问题
Blazor Server的Scoped服务生命周期绑定到电路连接,内存中存储的令牌会在页面刷新、电路重建时被清空,必须将令牌持久化到浏览器加密存储中:
- 引入Nuget包
Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage,使用加密的本地存储避免令牌明文泄露 - 新增内存令牌管理类,单电路实例内共享当前用户的令牌信息:
public class TokenProvider { public string? AccessToken { get; set; } public string? RefreshToken { get; set; } public DateTimeOffset ExpireAt { get; set; } public void Clear() { AccessToken = null; RefreshToken = null; ExpireAt = DateTimeOffset.MinValue; } } - 自定义电路处理器,每次电路初始化(含页面刷新触发的新电路)时自动从本地存储加载令牌,恢复登录态:
public class AuthCircuitHandler : CircuitHandler { private readonly ProtectedLocalStorage _localStorage; private readonly TokenProvider _tokenProvider; private const string StorageKey = "app_auth_tokens"; public AuthCircuitHandler(ProtectedLocalStorage localStorage, TokenProvider tokenProvider) { _localStorage = localStorage; _tokenProvider = tokenProvider; } public override async Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken ct) { var storageResult = await _localStorage.GetAsync<TokenStoreDto>(StorageKey); if (storageResult.Success) { _tokenProvider.AccessToken = storageResult.Value.AccessToken; _tokenProvider.RefreshToken = storageResult.Value.RefreshToken; _tokenProvider.ExpireAt = storageResult.Value.ExpireAt; } await base.OnCircuitOpenedAsync(circuit, ct); } } - 在
Program.cs中注册相关服务:
登录、刷新令牌成功后,记得同时更新builder.Services.AddScoped<TokenProvider>(); builder.Services.AddProtectedLocalStorage(); builder.Services.AddScoped<CircuitHandler, AuthCircuitHandler>();TokenProvider的内存值和本地加密存储中的令牌数据。
二、实现全局HTTP请求拦截,自动触发令牌刷新
不要使用管道中间件做请求拦截,正确的实现方式是自定义DelegatingHandler,绑定到你用于调用后端API的HttpClient上,该Handler会在每次HttpClient发起请求时自动执行:
- 编写自定义令牌刷新拦截器:
public class RefreshTokenDelegatingHandler : DelegatingHandler { private readonly IRefreshTokenService _refreshTokenService; private readonly TokenProvider _tokenProvider; private readonly ProtectedLocalStorage _localStorage; private const string StorageKey = "app_auth_tokens"; public RefreshTokenDelegatingHandler( IRefreshTokenService refreshTokenService, TokenProvider tokenProvider, ProtectedLocalStorage localStorage) { _refreshTokenService = refreshTokenService; _tokenProvider = tokenProvider; _localStorage = localStorage; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) { // 提前1分钟判断令牌是否过期,避免请求到后端才发现过期 if (_tokenProvider.ExpireAt <= DateTimeOffset.UtcNow.AddMinutes(1) && !string.IsNullOrEmpty(_tokenProvider.RefreshToken)) { var refreshResult = await _refreshTokenService.RefreshTokenAsync(_tokenProvider.RefreshToken); if (refreshResult.IsSuccess) { // 更新内存令牌 _tokenProvider.AccessToken = refreshResult.Data.AccessToken; _tokenProvider.RefreshToken = refreshResult.Data.RefreshToken; _tokenProvider.ExpireAt = refreshResult.Data.ExpireAt; // 同步更新持久化存储 await _localStorage.SetAsync(StorageKey, refreshResult.Data); } else { // 刷新失败清空令牌,后续可配合导航服务跳转到登录页 _tokenProvider.Clear(); await _localStorage.DeleteAsync(StorageKey); } } // 附加有效令牌到请求头 if (!string.IsNullOrEmpty(_tokenProvider.AccessToken)) { request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", _tokenProvider.AccessToken); } var response = await base.SendAsync(request, ct); // 兼容边界情况:令牌刚好过期触发401时,再尝试刷新一次重发请求 if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(_tokenProvider.RefreshToken)) { var refreshResult = await _refreshTokenService.RefreshTokenAsync(_tokenProvider.RefreshToken); if (refreshResult.IsSuccess) { _tokenProvider.AccessToken = refreshResult.Data.AccessToken; _tokenProvider.RefreshToken = refreshResult.Data.RefreshToken; _tokenProvider.ExpireAt = refreshResult.Data.ExpireAt; await _localStorage.SetAsync(StorageKey, refreshResult.Data); // 克隆请求用新令牌重发 request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", _tokenProvider.AccessToken); response = await base.SendAsync(request, ct); } else { _tokenProvider.Clear(); await _localStorage.DeleteAsync(StorageKey); } } return response; } } - 在
Program.cs中注册HttpClient时绑定该拦截器:
后续所有通过该HttpClient发起的请求,都会自动走令牌检查、刷新、附加请求头的逻辑,不需要在每个组件里单独处理。builder.Services.AddScoped<RefreshTokenDelegatingHandler>(); builder.Services.AddHttpClient("BackendApi", client => { client.BaseAddress = new Uri("你的后端Web API根地址"); }) .AddHttpMessageHandler<RefreshTokenDelegatingHandler>();
关键注意事项
- 所有和令牌、请求拦截相关的服务(
TokenProvider、RefreshTokenDelegatingHandler、RefreshTokenService)必须注册为Scoped生命周期,禁止使用Singleton,否则会出现多用户令牌串号的严重问题 - 不要在Blazor Server中使用
IHttpContextAccessor获取请求上下文做令牌处理,电路建立后HttpContext就会释放,后续调用会出现空引用或上下文错误 - 如果使用Blazor内置的认证状态提供程序,记得在令牌更新时同步通知认证状态变化,避免UI层登录状态显示不同步
内容的提问来源于stack exchange,提问作者Ovis
相关产品推荐
相关产品推荐

