单 Token 透明刷新:一个被放弃的方案及其思考过程

62 阅读7分钟

单 Token 透明刷新:一个被放弃的方案及其思考过程

本文是《JWT 认证方案深度对比:单 Token 扩展刷新 vs 双 Token 验证》的续篇。记录了我在尝试优化单 Token 刷新体验时的完整思考过程——从发现问题、设计方案、实现代码,到最终发现安全缺陷并放弃方案。希望这个"失败"的过程对其他开发者有所帮助。

起因:一次审计引发的思考

在做系统审计时,我发现一个问题:当 Token 过期返回 401 时,审计日志无法记录是谁在调用接口。

正常请求:GET /api/orders → 200 → 审计日志:用户 A 访问了订单接口 ✅
过期请求:GET /api/orders → 401 → 审计日志:某个请求被拒绝了(谁?)❌

这让我开始思考:为什么其他网站的登录状态能保持那么久?业界成熟的 Token 方案是怎么做的?

由此,我产生了一个想法:能不能让中间件在 Token 过期时自动刷新,而不是返回 401?

方案设计:透明刷新

现有流程的"痛点"

单 Token 方案的刷新流程是:

客户端 → 过期 Token → 中间件 → 401
客户端 → 调用 /refresh → 获得新 Token → 重试原请求 → 200

这个流程有两个让我不满意的地方:

  1. /refresh 接口必须放在中间件的 skip 列表里(因为它接收的是过期 Token)
  2. 客户端需要实现 401 → 刷新 → 重试的逻辑

我的方案

将刷新逻辑内置到认证中间件中:

authMiddleware, _ := jwt.New(&jwt.HertzJWTMiddleware{
    Timeout:                  15 * time.Minute,
    MaxRefresh:               7 * 24 * time.Hour,
    EnableTransparentRefresh: true,  // 新增:透明刷新
})

当 Token 过期但仍在 MaxRefresh 窗口内时,中间件自动生成新 Token,继续处理请求,新 Token 通过响应头/Cookie 返回给客户端。

客户端 → 过期 Token → 中间件 → 自动刷新 → 200 + 新 Token

实现代码

func (mw *HertzJWTMiddleware) middlewareImpl(ctx context.Context, c *app.RequestContext) {
    claims, err := mw.GetClaimsFromJWT(ctx, c)
    if err != nil {
        // Token 过期,尝试透明刷新
        if mw.EnableTransparentRefresh && mw.MaxRefresh > 0 && mw.isExpiredTokenError(err) {
            if refreshedClaims, refreshErr := mw.tryTransparentRefresh(ctx, c); refreshErr == nil {
                claims = refreshedClaims
                err = nil
            }
        }

        if err != nil {
            mw.unauthorized(ctx, c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, ctx, c))
            return
        }
    }

    // ... 正常处理
}

我甚至实现了完整的测试覆盖,提交了 PR #32。一切看起来很完美。

发现致命问题:安全边界被破坏

提交代码后的那天晚上,我躺在床上突然意识到一个严重的问题。

短过期时间的意义

JWT 设置短 exp(如 15 分钟)不仅仅是为了"让 Token 过期",更重要的是提供一个安全控制点

原始流程(有控制点):
Token 过期 → 401 → 客户端调用 /refresh → 服务端可以在这里拒绝
                                          ↑
                                    封禁用户?吊销 Token?
                                    这里可以做任何检查

透明刷新(控制点消失):
Token 过期 → 中间件自动刷新 → 继续处理请求
                              ↑
                        没有任何拦截机会

透明刷新把 exp 从 15 分钟实质上拉长到了 MaxRefresh(7 天)。短过期时间形同虚设——你无法在 MaxRefresh 期间让一个 Token 失效,因为每次过期都会被自动续期。

对比双 Token 方案

特性双 Token (OAuth 2.0)单 Token 透明刷新
Access Token 实际有效期15 分钟(真正的短期)7 天(名义 15 分钟)
刷新时的控制点/token 端点可以拒绝没有控制点
封禁用户生效时间最多 15 分钟最多 7 天
Token 泄露影响15 分钟7 天

尝试修复:滑动窗口

发现问题后,我尝试用"滑动窗口"来修复:

RefreshSlidingWindow: 1 * time.Hour,  // 只有超过 1 小时才允许透明刷新

但这个方案有两个问题:

1. 语义矛盾

窗口内(刚过期)→ 拒绝刷新,返回 401 窗口外(过期很久)→ 允许刷新

这意味着 Token 刚过期时反而不能刷新,要等足够久才能刷新——完全违反直觉。

2. orig_iat 更新后 MaxRefresh 失效

滑动窗口模式下会重置 orig_iat,这导致 MaxRefresh 的窗口也被重置——Token 理论上可以无限续期,回到了原来的问题。

滑动窗口并没有解决根本问题,只是换了一种方式暴露了同样的安全缺陷。

重新审视:那些"痛点"真的是问题吗?

冷静下来后,我重新审视了最初的两个"痛点":

"痛点"1:/refresh 必须放在 skip 列表里

这不是问题,这是正确的设计。

/refresh 接口接收的就是过期 Token,它不应该通过认证中间件。这和 OAuth 2.0 的 /token 端点不走 Bearer 认证是同一个道理——刷新端点本身就是安全边界的一部分,它需要跳过常规认证才能执行自己的验证逻辑(检查 orig_iat + MaxRefresh)。

"痛点"2:客户端需要处理 401 → 刷新 → 重试

这也不是问题,这是业界标准做法。

我们在其他网站体验到的"长期登录",并不是服务端在透明刷新——而是客户端(浏览器/App)在背后默默处理了这个流程。无论是 OAuth 2.0 双 Token、还是单 Token + MaxRefresh,客户端都需要处理刷新逻辑,区别只是实现方式。

真正的问题:审计

回到最初的出发点——审计日志在 401 时丢失调用者身份——这个问题根本不需要透明刷新来解决。

JWT 的 payload 只是 Base64 编码,不是加密的。Token 过期了,claims 仍然是可读的。真正的解决方案是:即使返回 401,也把能解析出的 claims 存到 context 里供审计使用。

func (mw *HertzJWTMiddleware) middlewareImpl(ctx context.Context, c *app.RequestContext) {
    claims, err := mw.GetClaimsFromJWT(ctx, c)
    if err != nil {
        // 即使 Token 无效,也尝试存储 claims 用于审计
        if claims != nil {
            c.Set("JWT_PAYLOAD", claims)
        }
        mw.unauthorized(ctx, c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, ctx, c))
        return
    }
    // ... 正常处理
}

改动很小,不破坏任何安全边界,完美解决了审计需求。

业界长会话方案总结

在这次探索中,我也调研了业界主流的长会话维持方案:

方案典型场景做法刷新由谁处理
OAuth 2.0 双 Token大多数 SaaSAccess Token 短期 + Refresh Token 长期客户端
服务端 Session传统 WebhttpOnly Cookie + 服务端存储,滑动过期服务端
BFF 模式现代 SPA后端网关代理刷新,前端无感知BFF 网关
单 Token + MaxRefreshhertz-contrib/jwt一个 Token + orig_iat 管理刷新窗口客户端

共同点: 所有安全的方案都保留了"刷新时的控制点"——服务端可以在刷新环节拒绝续期。透明刷新之所以不安全,正是因为它删掉了这个控制点。

总结与反思

时间线

  1. 发现审计问题 → 审计日志在 401 时丢失调用者身份
  2. 产生想法 → 能不能在中间件里自动刷新?
  3. 实现方案 → 透明刷新,提交 PR
  4. 发现安全缺陷 → 短过期时间失去意义,Token 无法被撤销
  5. 尝试修复 → 滑动窗口,但治标不治本
  6. 放弃方案 → 关闭 PR 和 Issue
  7. 回归本质 → 审计问题用更简单的方式解决

教训

  1. 不要把"不方便"当成"设计缺陷"/refresh 放在 skip 里看起来不够优雅,但这恰恰是正确的安全设计。
  2. 安全边界的存在是有原因的。在优化用户体验之前,先想清楚你正在移除什么安全保障。
  3. 先找准真正的问题。我的出发点是审计,但在思考过程中偏向了"优化刷新体验",最终发现审计问题有更简单的解法。
  4. "失败"的方案也有价值。这次探索加深了我对 JWT 安全模型的理解,也希望能帮其他开发者避免同样的弯路。

相关资源


优秀的工程设计不是追求"看起来优雅"的方案,而是在理解安全约束的前提下,找到最简单有效的解法。

「关于我的技术经历和成长,欢迎访问我的 个人网站