大家好,我是码农刚子。
用户在页面里填了一整页表单,点了提交------啪,跳回登录页。JWT 过期了,刷新令牌没接上,所有数据白填。这篇文章讲 Blazor 里怎么正确实现 JWT 刷新令牌,让用户无感续期。
先说为什么 Blazor 里这个问题特别恶心。
在普通 SPA(React/Vue)里,JWT 存在 localStorage,请求拦截器统一加 header,401 时自动刷新------一套 Axios 拦截器搞定。但 Blazor 不一样:Blazor Server 跑在服务端,Blazor WebAssembly 跑在浏览器里,两种模式下 token 的存储位置、刷新时机、信号传递方式完全不同。
而且 Blazor 的组件状态是活的------用户在页面上操作时,组件不会重新渲染,你没法靠"页面刷新"来触发重新认证。必须在 HTTP 层静默处理。
01 为什么 JWT 过期会"踢人"
JWT 有过期时间(exp claim)。过期后,API 服务器验证失败返回 401。如果你的应用不做刷新,401 的处理就是跳登录页。
问题是------JWT 的过期时间通常很短 (15-30 分钟),这是安全设计。长过期时间 = token 被盗后攻击窗口大。所以业界做法是短 access token + 长 refresh token:
Access Token --- 15-30 分钟过期,放 Authorization header
Refresh Token --- 7-30 天过期,存安全位置(httpOnly cookie / protected storage)
↓ access token 过期时,用 refresh token 换新的
用户无感续期的流程是:
① 请求 API → 带 access token ② API 返回 401 → access token 过期了 ③ 拦截 401 → 用 refresh token 调 /refresh 端点换新 access token ④ 拿到新 token → 重放原始请求 ⑤ 用户完全无感,表单数据不丢
坑在哪 ?Blazor 的 HttpClient 不是浏览器 fetch,不能简单套 Axios 那套。Blazor Server 的 HttpClient 是 HttpClientFactory 创建的,Blazor WebAssembly 的 HttpClient 是 WASM runtime 里的------两者拦截 401 的方式完全不同。
02 Blazor Server:DelegatingHandler 拦截
Blazor Server 跑在服务端,HttpClient 可以套 DelegatingHandler------这是 .NET 原生的 HTTP 管道机制,所有请求经过 handler 链。
核心思路:自定义一个 AuthTokenHandler,在 SendAsync 里自动加 token,401 时自动刷新。
csharp
public class AuthTokenHandler : DelegatingHandler
{
private readonly TokenService _tokenService;
public AuthTokenHandler(TokenService tokenService)
{
_tokenService = tokenService;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
// 1. 加 access token
var token = await _tokenService.GetAccessTokenAsync();
if (!string.IsNullOrEmpty(token))
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
// 2. 发请求
var response = await base.SendAsync(request, ct);
// 3. 401 → 尝试刷新
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
var refreshed = await _tokenService
.TryRefreshTokenAsync();
if (refreshed)
{
// 4. 重放原始请求
var newToken = await _tokenService
.GetAccessTokenAsync();
request.Headers.Authorization =
new AuthenticationHeaderValue(
"Bearer", newToken);
// 注意:HttpClient 会缓存请求,
// 需要克隆一份再发
response.Dispose();
var retry = await CloneAndSendAsync(
request, ct);
return retry;
}
}
return response;
}
private async Task<HttpResponseMessage> CloneAndSendAsync(
HttpRequestMessage req, CancellationToken ct)
{
var clone = new HttpRequestMessage(req.Method, req.RequestUri);
// 克隆 content(如果有的话)
if (req.Content != null)
{
var bytes = await req.Content.ReadAsByteArrayAsync(ct);
clone.Content = new ByteArrayContent(bytes);
}
foreach (var h in req.Headers)
clone.Headers.TryAddWithoutValidation(
h.Key, h.Value);
return await base.SendAsync(clone, ct);
}
}
关键细节:
请求克隆------HttpRequestMessage 发送后不能复用,必须克隆一份再重发。这是最容易漏的一步,漏了直接报"请求已发送"异常。
并发刷新锁 ------多个请求同时 401 会触发多次刷新,refresh token 可能被消耗多次(如果是一次性 token)。加一个 SemaphoreSlim 保证只刷新一次,其他请求等结果。
刷新失败处理 ------refresh token 也过期了,跳登录页。但 Blazor Server 里不能直接 NavigationManager.NavigateTo("/login")(那是客户端导航),需要通过 JS Interop 调 location.href 做整页跳转。
csharp
// TokenService 里的并发刷新
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
public async Task<bool> TryRefreshTokenAsync()
{
await _refreshLock.WaitAsync();
try
{
// 双重检查:也许别的线程刚刷过
if (IsAccessTokenValid())
return true;
var refreshReq = new { RefreshToken = _refreshToken };
var resp = await _httpClient.PostAsJsonAsync(
"/api/auth/refresh", refreshReq);
if (!resp.IsSuccessStatusCode)
{
// refresh token 也过期 → 强制登出
await _jsRuntime.InvokeVoidAsync(
"location.replace", "/login?expired=1");
return false;
}
var tokens = await resp.Content
.ReadFromJsonAsync<TokenResponse>();
_accessToken = tokens.AccessToken;
_refreshToken = tokens.RefreshToken;
return true;
}
finally
{
_refreshLock.Release();
}
}
03 Blazor WebAssembly:自定义 HttpMessageHandler
WASM 模式下思路一样,但实现有区别------token 存在浏览器里 ,而且没有 SemaphoreSlim 的跨组件同步问题(因为 WASM 是单线程的)。
存储选择:
csharp
// Program.cs --- 注册带 token 拦截的 HttpClient
builder.Services.AddScoped<AuthTokenHandler>();
builder.Services.AddScoped(sp =>
{
var handler = sp.GetRequiredService<AuthTokenHandler>();
handler.InnerHandler = new HttpClientHandler();
return new HttpClient(handler)
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
};
});
// Token 存 protected localStorage
// (Blazor WASM 的 ProtectedLocalStorage 在 Server 端,
// WASM 用 IJSRuntime 直接调 localStorage API)
builder.Services.AddSingleton<ITokenStorage,
LocalTokenStorage>();
WASM 里的 handler 和 Server 版几乎一样,区别在刷新失败时的跳转 ------WASM 可以直接用 NavigationManager 做客户端导航,不需要 JS Interop。
WASM 专属坑 :localStorage 是异步的,但 DelegatingHandler.SendAsync 也是异步的,没问题。但如果你在 OnInitializedAsync 里调 API,刷新 + 重放的延迟会导致组件渲染两次------务必在组件里处理 Loading 状态,否则用户会看到一闪而过的错误。
04 两种模式都要注意的三件事
① refresh token 的存储位置
Blazor Server:存在服务端内存 / Session / Redis。别存 cookie------Blazor Server 用 SignalR 连接,cookie 跟着连接走,断线重连会丢。
Blazor WASM:存 localStorage。XSS 风险?WASM 默认不做 dangerouslySetInnerHTML,XSS 攻击面比传统 SPA 小。但如果你的 WASM 应用加载了第三方 JS 模块,仍有风险。折中方案:access token 存内存,refresh token 存 localStorage,JS 模块拿不到内存里的 access token。
② 刷新端点的设计
POST /api/auth/refresh
Body: { "refreshToken": "xxx" }
Response: {
"accessToken": "新 access token",
"refreshToken": "新 refresh token" // 轮换!
}
refresh token 必须轮换------每次刷新后旧的失效,返回新的。不轮换 = refresh token 被盗后永久有效。进一步可以加 refresh token rotation + reuse detection:如果检测到旧 token 被使用,说明可能被窃取,立即吊销整个 token 家族。
③ 预刷新:别等 401 再刷
等 401 再刷的问题是------那个请求已经失败了 。虽然重放能恢复,但用户可能看到一瞬间的错误状态。更好的做法是预判过期:在发请求前检查 access token 的 exp claim,如果快过期了(比如 5 分钟内),先刷新再发请求。
csharp
// TokenService 里加预判
public async Task<string> GetAccessTokenAsync()
{
if (_accessToken != null)
{
var jwt = new JwtSecurityToken(_accessToken);
// 过期前 5 分钟刷新
if (jwt.ValidTo > DateTime.UtcNow.AddMinutes(5))
return _accessToken;
}
// 快过期了 → 主动刷新
await TryRefreshTokenAsync();
return _accessToken;
}
这样 AuthTokenHandler 里 GetAccessTokenAsync 自带预判,401 分支只是兜底------正常情况下永远不会走到 401 分支。
Blazor JWT 刷新令牌,核心就四步: DelegatingHandler 拦截 → 401 自动刷新 → 请求重放 → 预判过期抢先刷 用户从头到尾无感,表单数据不丢。
收藏这篇,下次别再让用户填了一半被踢出去。 refresh token 接好,用户体验直接上一个台阶。
关注 CSharp精选营,每周二四 get 能直接抄的 C# 实战。