前言

现代 Web 应用几乎都离不开身份认证。从早期的 Session-Cookie 模式,到 Token 化架构,再到 OAuth 2.0 与 OpenID Connect,认证方案一直在演进。其中 JWT(JSON Web Token)凭借无状态、易扩展、跨语言等优势,成为微服务和前后端分离项目的首选方案。

然而,JWT 的「无状态」也是一把双刃剑:一旦签发,在过期前无法主动失效。为了解决这个问题,业界普遍采用「Access Token + Refresh Token」双Token 架构,并结合 Refresh Token Rotation(刷新令牌轮转)来平衡安全性与用户体验。

本文将从实际工程角度出发,使用 Go 语言完整演示这一方案的实现。

一、JWT 核心原理快速回顾

JWT 由三段组成,用点号分隔:

Header.Payload.Signature
  • Header:声明算法(如 HS256、RS256)与 Token 类型
  • Payload:携带用户 ID、过期时间等声明(Claims)
  • Signature:对前两部分签名,防篡改

常用库:Go 推荐 golang-jwt/jwt/5,Node.js 用 jsonwebtoken,Python 用 PyJWT

二、双Token架构设计

Token有效期用途存储
Access Token15min接口认证内存/前端状态
Refresh Token7d续签 Access TokenHttpOnly Cookie / DB

为什么这样设计?Access Token 有效期极短,即使泄露,攻击窗口也很有限;Refresh Token 用于换取新的 Access Token,不需要频繁传输,适合持久化存储。

三、Go 实现:签发与验证

3.1 定义 Claims

type UserClaims struct {
    UserID   uint   `json:"uid"`
    Username string `json:"uname"`
    jwt.RegisteredClaims
}

3.2 签发 Access Token

func GenAccessToken(uid uint, name string) (string, error) {
    claims := UserClaims{
        UserID:   uid,
        Username: name,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "ybb-demo",
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.NewString([]byte(config.JwtKey))
}

3.3 验证中间件

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        t := c.GetHeader("Authorization")
        t = strings.TrimPrefix(t, "Bearer ")
        claims := &UserClaims{}
        token, err := jwt.ParseWithClaims(t, claims, func(t *jwt.Token) (any, error) {
            return []byte(config.JwtKey), nil
        })
        if err != nil || !token.Valid {
            c.AbortWithStatusJSON(401, gin.H{"msg": "未登录"})
            return
        }
        c.Set("user", claims)
        c.Next()
    }
}

四、Refresh Token Rotation 实现

轮转的核心思路:每次通过 Refresh Token 获取新 Access Token 时,同时颁发一个新的 Refresh Token,旧 Refresh Token 作废。如果检测到同一个 Refresh Token 被重复使用,说明可能被盗用,应吊销与该用户相关的全部令牌。

// Refresh 接口核心逻辑
func Refresh(c *gin.Context) {
    oldRT := c.Cookie("refresh_token")
    // 1. 从 Redis/DB 校验 oldRT 是否存在
    stored, _ := cache.Get(fmt.Sprintf("rt:%s", oldRT))
    if stored == nil {
        c.AbortWithStatus(401)
        return
    }
    // 2. 立即删除旧 RT(一次性使用)
    cache.Del(oldRT)
    // 3. 生成新 token 对
    newAT, _ := GenAccessToken(stored.UserID, stored.Name)
    newRT, _ := GenRefreshToken(stored.UserID)
    // 4. 持久化新 RT
    cache.Set(newRT, stored.UserID, 7*24*time.Hour)
    c.JSON(200, gin.H{"access_token": newAT})
    c.SetCookie("refresh_token", newRT, 7*24*3600, "/", "", true, true)
}

五、Refresh Token 安全的三个关键细节

  1. HttpOnly + Secure Cookie:杜绝 XSS 读取 Refresh Token,这是最基础也是最容易被忽略的配置
  2. 轮换 + 重放检测:每次使用后立即作废旧 RT,如果发现有重复使用的 RT,说明可能已被盗用,立即吊销该用户所有 RT
  3. Token 黑名单:用户登出或修改密码时,将未到期的 AT 密钥标识写入 Redis 黑名单,过期时间设为 AT 剩余有效期

六、整体流程总结

  1. 用户登录 → 颁发 AT(15min)+ RT(7d)
  2. 请求接口 → 携带 AT → 中间件验证签名和过期时间
  3. AT 过期 / 401 → 前端调 /refresh → 使用 RT 换新 token 对
  4. RT 也过期 → /401 → 跳转登录页重新认证
  5. 异常检测(RT 重用攻击)→ 全量吊销该用户 token,强制重新登录

七、常见问题排查

  • 时钟偏移(Clock Skew):分布式节点时间差可能导致「刚签发就过期」,建议校验 exp 时留几分钟 buffer
  • Token 泄露紧急处理:立即在缓存中清除该用户所有 RT 会话记录,使所有 Token 失效,强制用户重新登录
  • 多设备场景:为每个设备维护独立的 RT 家族(Device Family),可通过设备指纹或 User-Agent 区分
  • Redis 可用性:RT 存储依赖 Redis,建议对 RT 操作加熔断降级,Redis 不可用时回退到数据库查询

八、总结

JWT 不是银弹,双 Token + 轮转也不是唯一解法。但在中小规模系统里,这套方案实现简单、性能足够、安全可控。核心原则总结如下:

  • Access Token 尽量短(15min 以内)
  • Refresh Token 只走安全存储(HttpOnly Cookie 或后端持久化)
  • 轮换 + 检测 = 防止令牌窃取

如果需要更高级的授权能力,可以进一步探索 OAuth 2.0 的 Device Code 流程或 OpenID Connect 的身份层设计。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部