跳到主内容

JWT 无状态鉴权踩坑实录:多实例下的续期与黑名单 ​

问题背景:JWT 无状态为什么还需要状态 ​

JWT 常被宣传为“服务端不保存会话”,非常适合多实例部署:请求携带 Token,任意实例只要持有同一套签名密钥,就能独立完成验签和授权。但在真实后端系统里,注销、续期、权限变更、Token 泄露处置都要求系统在过期前改变鉴权结果,这本质上又引入了状态。

典型问题有三个:

  1. Access Token 有效期过长,泄露后可被长期重放;有效期过短,用户又会频繁掉线。
  2. 用户点击退出后,未过期的 JWT 默认仍然有效,普通黑名单只放单机内存会在多实例间失效。
  3. Refresh Token 长期有效,如果没有轮换、复用检测和设备维度吊销,账号被盗后很难止血。

因此,实战方案不是追求“绝对无状态”,而是把无状态验签和最小必要状态结合:Access Token 短期有效,Refresh Token 长期保存并可轮换;注销和泄露应急通过 Redis 等共享存储形成快速失效通道。

方案对比:三种 JWT 鉴权模式怎么选 ​

方案优点缺点适用场景
单 JWT,长期有效实现最简单泄露风险高,无法可靠注销内网低风险工具
双 Token,Access 短期 + Refresh 长期用户体验好,泄露窗口小需要刷新接口和轮换逻辑互联网 API、多实例服务
JWT + 集中会话存储注销和踢人最直接每次鉴权依赖存储,接近有状态会话高安全、强管控系统

推荐大多数业务采用第二种:Access Token 有效期设为 5 到 15 分钟,只保留用户 ID、角色、版本号等必要声明;Refresh Token 设为 7 到 30 天,以不透明随机字符串保存到 Redis,并在每次刷新时轮换。

客户端API 实例验签与黑名单检查Redis 黑名单Refresh 记录轮换与复用检测业务服务

实操步骤:Go 实现双 Token、续期与注销 ​

下面示例使用 golang-jwt、Redis 和标准库,演示核心配置与关键实现。生产项目可把中间件接入网关或统一 HTTP 框架。

1. 定义 Token 配置与声明 ​

Access Token 使用非对称签名更利于多服务验签;示例为便于运行采用 HMAC,生产环境应通过密钥管理系统加载密钥,避免硬编码。

go
package main

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "errors"
    "fmt"
    "net/http"
    "time"

    "github.com/golang-jwt/jwt/v5"\n    "github.com/redis/go-redis/v9"
)

type AuthConfig struct {
    JWTSecret []byte
    AccessTTL time.Duration
    RefreshTTL time.Duration
}

type Claims struct {
    UserID string `json:"user_id"`
    Role   string `json:"role"`
    TokenID string `json:"jti"`
    jwt.RegisteredClaims
}

func NewAccessClaims(userID, role string, ttl time.Duration) *Claims {
    now := time.Now()
    jti := randomToken(16)
    return &Claims{
        UserID:  userID,
        Role:    role,
        TokenID: jti,
        RegisteredClaims: jwt.RegisteredClaims{
            Subject:   userID,
            ID:        jti,
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
            Issuer:    "developer-diary",
        },
    }
}

func (c *AuthConfig) SignAccess(userID, role string) (string, time.Time, error) {
    claims := NewAccessClaims(userID, role, c.AccessTTL)
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    s, err := token.SignedString(c.JWTSecret)
    return s, claims.ExpiresAt.Time, err
}

func randomToken(n int) string {
    b := make([]byte, n)
    _, _ = rand.Read(b)
    return hex.EncodeToString(b)
}

2. 登录时写入 Refresh Token ​

Refresh Token 不建议直接放业务权限,也不要把它再编码成一个可解析的 JWT。使用随机字符串即可,服务端保存哈希值、用户、设备、过期时间和轮换版本。

go
type RefreshStore struct {
    rdb *redis.Client
    cfg *AuthConfig
}

func (s *RefreshStore) Create(ctx context.Context, userID, device string) (string, error) {
    refresh := randomToken(32)
    key := "refresh:" + refresh
    value := map[string]interface{}{
        "user_id":     userID,
        "device":      device,
        "created_at":  time.Now().Unix(),
        "reused":      0,
    }
    // 只在首次登录时创建,过期由 Redis TTL 控制
    if err := s.rdb.HSet(ctx, key, value).Err(); err != nil {
        return "", err
    }
    return refresh, s.rdb.Expire(ctx, key, s.cfg.RefreshTTL).Err()
}

3. 刷新接口:一次性轮换而不是无限续期 ​

刷新时必须读取并删除旧 Refresh Token,再签发新的 Access Token 和 Refresh Token。为了降低并发刷新误杀,可给旧 Token 设置几十秒宽限,或在客户端加单飞锁;但不能让旧 Token 长期有效。

go
var ErrRefreshReused = errors.New("refresh token reused")

func (s *RefreshStore) Rotate(ctx context.Context, oldRefresh, device string) (string, string, time.Time, error) {
    oldKey := "refresh:" + oldRefresh

    // Lua 保证“读取、校验、删除”原子执行,避免多实例并发重复刷新
    script := redis.NewScript(`
        local data = redis.call("HGETALL", KEYS[1])
        if #data == 0 then
          return {1}
        end
        redis.call("DEL", KEYS[1])
        return {0, data[2], data[4]}
    `)

    res, err := script.Run(ctx, s.rdb, []string{oldKey}).Result()
    if err != nil {
        return "", "", time.Time{}, err
    }
    arr := res.([]interface{})
    if arr[0].(int64) == 1 {
        // 不存在可能是过期、已轮换,也可能是攻击者使用了被盗 Token
        return "", "", time.Time{}, ErrRefreshReused
    }

    userID := arr[1].(string)
    newRefresh, err := s.Create(ctx, userID, device)
    if err != nil {
        return "", "", time.Time{}, err
    }
    access, exp, err := s.cfg.SignAccess(userID, "user")
    if err != nil {
        return "", "", time.Time{}, err
    }
    return access, newRefresh, exp, nil
}

如果发现一个已经轮换过的 Refresh Token 再次出现,应视为“复用攻击”:立即吊销该用户全部 Refresh Token,记录用户 ID、设备、IP、UA,并强制重新登录。

4. 鉴权中间件:验签后检查黑名单 ​

注销 Access Token 时,黑名单 TTL 应等于该 Token 的剩余有效期,而不是永久保存。这样既能立即失效,也不会让 Redis 无限膨胀。

go
func BearerMiddleware(cfg *AuthConfig, rdb *redis.Client, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        auth := r.Header.Get("Authorization")
        if len(auth) < 8 || auth[:7] != "Bearer " {
            http.Error(w, "missing token", http.StatusUnauthorized)
            return
        }

        tokenString := auth[7:]
        claims := &Claims{}
        token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
            if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
                return nil, fmt.Errorf("unexpected alg: %s", t.Method.Alg())
            }
            return cfg.JWTSecret, nil
        })
        if err != nil || !token.Valid {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return
        }

        blackKey := "jwt:block:" + claims.TokenID
        n, err := rdb.Exists(r.Context(), blackKey).Result()
        if err != nil {
            http.Error(w, "auth dependency unavailable", http.StatusServiceUnavailable)
            return
        }
        if n > 0 {
            http.Error(w, "token revoked", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func RevokeAccess(ctx context.Context, rdb *redis.Client, tokenString string, cfg *AuthConfig) error {
    claims := &Claims{}
    _, _, err := jwt.NewParser().ParseUnverified(tokenString, claims)
    if err != nil {
        return err
    }
    ttl := time.Until(claims.ExpiresAt.Time)
    if ttl <= 0 {
        return nil
    }
    return rdb.Set(ctx, "jwt:block:"+claims.TokenID, "1", ttl).Err()
}

5. 泄露处置:从单设备注销到全局止血 ​

安全事件一般分三级处理:

  • 用户主动退出:拉黑当前 Access Token,删除当前 Refresh Token。
  • 单设备异常:按设备 ID 删除对应 Refresh 记录,要求该设备重新认证。
  • 账号疑似被盗:吊销该用户全部 Refresh Token,提升用户安全版本号,已有 Access Token 在下次请求时拒绝;必要时触发密码重置和二次验证。

如果不想在 Access Token 中查 Redis 大集合,可以加入 ver 声明。用户改密或全局踢出时递增 Redis 中的用户版本号,中间件比较 ver 是否一致。黑名单仍用于单个 Token 注销,版本号用于批量失效。

避坑总结:多实例 JWT 的高频错误 ​

1. 只在本机内存做黑名单 ​

错误现象:调用实例 A 退出后,请求被负载均衡转发到实例 B,接口仍然可以访问。原因是 map 或本地缓存不会跨实例同步。修正方法是使用 Redis Cluster、Valkey 或内部鉴权服务统一保存吊销状态,并把检查放在所有实例共用的中间件中。

2. Refresh Token 不轮换,被盗后长期可用 ​

错误现象:抓包拿到 Refresh Token 后,攻击者可以不断换新 Access Token,即使用户重新登录也不受影响。修正方法是刷新即失效旧 Token,服务端只保存 Token 哈希或随机串,检测到旧 Token 重复使用时吊销整个授权族,并记录安全审计日志。

3. JWT 不设置过期时间,或把权限变更完全交给前端 ​

错误现象:管理员降权、员工离职后,旧 Token 在数天内仍能访问高权限接口;甚至有人只在前端隐藏菜单。修正方法是 Access Token 必须短 TTL,权限以后端接口校验为准;批量权限变更使用用户版本号、角色版本号或短轮询缓存,而不是等待 Token 自然过期。

4. 黑名单永久写入,Redis 内存持续上涨 ​

错误现象:上线几个月后 Redis 内存报警,里面堆满早已过期的 JWT ID。修正方法是注销时只按 exp - now 设置 TTL;同时不要把完整 Token 原样存入 Redis,保存 jti、用户 ID、吊销原因即可。

5. 验签库允许 alg=none 或随意接受公钥当 HMAC 密钥 ​

错误现象:攻击者伪造 Header 和声明即可通过校验。修正方法是显式固定签名算法,使用密钥 ID(kid)做密钥轮换;非对称方案中严格区分私钥签名服务和公钥验签服务,不要从 Token Header 动态选择可信密钥。

结语 ​

JWT 的价值不是“完全不需要服务端状态”,而是把高频验签做成可水平扩展的本地计算,同时把注销、刷新和泄露处置收敛到少量共享状态。多实例部署下,短 TTL Access Token、可轮换 Refresh Token、Redis 黑名单和用户版本号组合起来,才能同时兼顾性能、体验和安全。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。