Blazor 里 JWT 过期,用户正下单就被踢

大家好,我是码农刚子。

用户在页面里填了一整页表单,点了提交------啪,跳回登录页。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# 实战。

阅读原文👉:Blazor 里 JWT 过期,用户正下单就被踢 - 码录集

相关推荐
gudufy21 小时前
ASP.NET Core 多租户如何动态创建数据库?数据库初始化怎么做?
数据库·blazor·admin
白帽攻防录7 天前
SRC 挖洞:WSO2 JWT 算法混淆绕过深度复盘,CVE-2026-5430 不支持的算法怎么变成管理员
网络·算法·安全·网络安全·jwt
gudufy8 天前
EasyAdminBlazor Excel 导入源码解析:数据验证、权限与事务如何处理?
excel·blazor·admin
EatFan8 天前
Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
vue.js·golang·go·vue·gin·jwt·rbac
CSharp精选营11 天前
聊聊 Blazor 里 Radzen 6.0.0 那几个用着别扭的官方组件
blazor·radzen
szephyr18 天前
前端鉴权实战:Token 存哪、怎么刷新、怎么防 XSS 与 CSRF
鉴权·xss·csrf·jwt·token
known24 天前
基于Blazor实现的项目管理系统
blazor·known
前端逗比逗1 个月前
AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染
前端·webassembly
2601_962297251 个月前
Authlib 0.13通用Python认证授权库wheel安装包(支持Python 2/3)
python·jwt·oauth2.0·authlib·openidconnect