前言
现代 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 Token | 15min | 接口认证 | 内存/前端状态 |
| Refresh Token | 7d | 续签 Access Token | HttpOnly 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 安全的三个关键细节
- HttpOnly + Secure Cookie:杜绝 XSS 读取 Refresh Token,这是最基础也是最容易被忽略的配置
- 轮换 + 重放检测:每次使用后立即作废旧 RT,如果发现有重复使用的 RT,说明可能已被盗用,立即吊销该用户所有 RT
- Token 黑名单:用户登出或修改密码时,将未到期的 AT 密钥标识写入 Redis 黑名单,过期时间设为 AT 剩余有效期
六、整体流程总结
- 用户登录 → 颁发 AT(15min)+ RT(7d)
- 请求接口 → 携带 AT → 中间件验证签名和过期时间
- AT 过期 / 401 → 前端调 /refresh → 使用 RT 换新 token 对
- RT 也过期 → /401 → 跳转登录页重新认证
- 异常检测(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 的身份层设计。

发表评论 取消回复