JWT 认证方案深度对比:单 Token 扩展刷新 vs 双 Token 验证
本文源于一次代码 Review 过程中的深度讨论,从一个"疑似 Bug"出发,逐步深入探讨了两种 JWT 认证方案的设计理念和实现差异。
背景
在 Review hertz-contrib/jwt 项目时,我发现了一个有趣的问题:在 RefreshToken 方法中,每次刷新时都会重置 orig_iat 为当前时间,这导致 MaxRefresh 窗口不断重置。
go
// 原始代码(有问题)
newClaims["exp"] = expire.Unix()
newClaims["orig_iat"] = mw.TimeFunc().Unix() // 每次刷新都重置
根据代码注释的设计意图,MaxRefresh 应该限制 Token 的最大有效期:
go
// This field allows clients to refresh their token until MaxRefresh has passed.
// Note that clients can refresh their token in the last moment of MaxRefresh.
// This means that the maximum validity timespan for a token is TokenTime + MaxRefresh.
MaxRefresh time.Duration
修复后的代码应该保留原始的 orig_iat:
go
// 修复后
newClaims["exp"] = expire.Unix()
if origIat, exists := claims["orig_iat"]; exists {
newClaims["orig_iat"] = origIat // 保留原始值
} else {
newClaims["orig_iat"] = mw.TimeFunc().Unix()
}
这个问题引发了我对单 Token 扩展刷新设计和双 Token 设计的深入思考。
两种方案概述
方案一:单 Token 扩展刷新
这是 hertz-contrib/jwt 采用的方案,特点是:
- 只有一个 Token
- Token 有短期有效期(如 15 分钟或 1 小时)
- 在
MaxRefresh窗口内可以刷新 Token - 刷新时使用同一个 Token 作为凭证
go
// 配置示例
Timeout: 15 * time.Minute, // Token 有效期:15分钟
MaxRefresh: 7 * 24 * time.Hour, // 刷新窗口:7天
方案二:双 Token 验证
OAuth 2.0 推荐的标准方案,特点是:
- Access Token:短期有效(如 15 分钟),用于业务请求
- Refresh Token:长期有效(如 7 天),仅用于刷新 Access Token
- 两个 Token 职责分离
go
// 配置示例
AccessTokenTimeout: 15 * time.Minute,
RefreshTokenTimeout: 7 * 24 * time.Hour,
核心功能对比
经过深入讨论,我们发现两种方案都能实现相同的核心功能:
| 功能 | 单 Token | 双 Token |
|---|---|---|
| 业务请求零 Redis 查询 | ✅ | ✅ |
| 刷新请求查 Redis | ✅ | ✅ |
| Token 轮换 | ✅ | ✅ |
| 主动撤销 | ✅ | ✅ |
| 短泄露影响时间 | ✅(可设置15分钟) | ✅ |
关键洞察
-
业务请求零 Redis 查询:两种方案都可以在业务请求中只验证 JWT 签名,不查询 Redis。Token 过期后自动失效,不需要黑名单。
-
Token 轮换:单 Token 刷新后就是新 Token,将旧 Token 加入黑名单即实现轮换。
-
撤销能力:两种方案都需要 Redis 来实现撤销功能,没有本质区别。
完整示例代码
单 Token 扩展刷新实现
go
package main
import (
"context"
"net/http"
"time"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/go-redis/redis/v8"
"github.com/hertz-contrib/jwt"
)
var (
redisClient *redis.Client
identityKey = "identity"
)
func main() {
h := server.Default()
// 初始化 Redis
redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// JWT 中间件配置
authMiddleware, _ := jwt.New(&jwt.HertzJWTMiddleware{
Realm: "single-token-demo",
Key: []byte("secret-key"),
Timeout: 15 * time.Minute, // Token 有效期:15分钟
MaxRefresh: 7 * 24 * time.Hour, // 刷新窗口:7天
IdentityKey: identityKey,
// 认证器:验证用户名密码
Authenticator: func(ctx context.Context, c *app.RequestContext) (interface{}, error) {
var loginVals struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.BindAndValidate(&loginVals); err != nil {
return nil, jwt.ErrMissingLoginValues
}
if loginVals.Username == "admin" && loginVals.Password == "admin" {
return map[string]interface{}{
"user_id": "123",
"username": loginVals.Username,
}, nil
}
return nil, jwt.ErrFailedAuthentication
},
// Payload:设置 Token 中的数据
PayloadFunc: func(data interface{}) jwt.MapClaims {
if v, ok := data.(map[string]interface{}); ok {
return jwt.MapClaims{
identityKey: v["user_id"],
"username": v["username"],
}
}
return jwt.MapClaims{}
},
// 身份处理器
IdentityHandler: func(ctx context.Context, c *app.RequestContext) interface{} {
claims := jwt.ExtractClaims(ctx, c)
return claims[identityKey]
},
// 授权器
Authorizator: func(data interface{}, ctx context.Context, c *app.RequestContext) bool {
return data != nil
},
// 未授权响应
Unauthorized: func(ctx context.Context, c *app.RequestContext, code int, message string) {
c.JSON(code, map[string]interface{}{
"code": code,
"message": message,
})
},
})
// 路由配置
h.POST("/login", authMiddleware.LoginHandler)
h.POST("/logout", LogoutHandler(authMiddleware))
auth := h.Group("/auth")
auth.GET("/refresh", RefreshHandler(authMiddleware))
auth.Use(authMiddleware.MiddlewareFunc())
{
auth.GET("/profile", ProfileHandler)
}
h.Spin()
}
// 业务接口:零 Redis 查询
func ProfileHandler(ctx context.Context, c *app.RequestContext) {
// 只验证 JWT,不查询 Redis
claims := jwt.ExtractClaims(ctx, c)
c.JSON(http.StatusOK, map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"user_id": claims["identity"],
"username": claims["username"],
},
})
}
// 刷新接口:查 Redis 黑名单 + 轮换
func RefreshHandler(authMiddleware *jwt.HertzJWTMiddleware) app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
// 获取当前 Token
oldToken := jwt.GetToken(ctx, c)
// 检查黑名单(轮换检查)
if exists, _ := redisClient.Exists(ctx, "blacklist:"+oldToken).Result(); exists > 0 {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": "token has been revoked",
})
return
}
// 调用原始刷新逻辑
tokenString, expire, err := authMiddleware.RefreshToken(ctx, c)
if err != nil {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": err.Error(),
})
return
}
// 将旧 Token 加入黑名单(轮换)
redisClient.Set(ctx, "blacklist:"+oldToken, "revoked", 7*24*time.Hour)
c.JSON(http.StatusOK, map[string]interface{}{
"code": 200,
"token": tokenString,
"expire": expire.Format(time.RFC3339),
})
}
}
// 登出接口:将 Token 加入黑名单
func LogoutHandler(authMiddleware *jwt.HertzJWTMiddleware) app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
token := jwt.GetToken(ctx, c)
// 将 Token 加入黑名单
redisClient.Set(ctx, "blacklist:"+token, "revoked", 7*24*time.Hour)
// 调用原始登出逻辑
authMiddleware.LogoutHandler(ctx, c)
}
}
双 Token 验证实现
go
package main
import (
"context"
"net/http"
"time"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/go-redis/redis/v8"
"github.com/golang-jwt/jwt/v4"
)
var (
redisClient *redis.Client
accessTokenSecret = []byte("access-secret")
refreshTokenSecret = []byte("refresh-secret")
accessTokenTimeout = 15 * time.Minute
refreshTokenTimeout = 7 * 24 * time.Hour
)
type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func main() {
h := server.Default()
// 初始化 Redis
redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 路由配置
h.POST("/login", LoginHandler)
h.POST("/logout", LogoutHandler)
h.POST("/refresh", RefreshHandler)
auth := h.Group("/auth")
auth.Use(AuthMiddleware())
{
auth.GET("/profile", ProfileHandler)
}
h.Spin()
}
// 登录接口:返回 Access Token + Refresh Token
func LoginHandler(ctx context.Context, c *app.RequestContext) {
var loginVals struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.BindAndValidate(&loginVals); err != nil {
c.JSON(http.StatusBadRequest, map[string]interface{}{
"code": 400,
"message": "invalid request",
})
return
}
if loginVals.Username != "admin" || loginVals.Password != "admin" {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": "invalid credentials",
})
return
}
// 生成 Access Token
accessToken, _ := generateAccessToken(loginVals.Username, "123")
// 生成 Refresh Token
refreshToken, _ := generateRefreshToken(loginVals.Username, "123")
c.JSON(http.StatusOK, map[string]interface{}{
"code": 200,
"access_token": accessToken,
"refresh_token": refreshToken,
"expires_in": int(accessTokenTimeout.Seconds()),
})
}
// 业务中间件:只验证 Access Token,不查 Redis
func AuthMiddleware() app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
tokenString := c.GetHeader("Authorization")
if len(tokenString) < 7 || string(tokenString[:7]) != "Bearer " {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": "missing token",
})
c.Abort()
return
}
// 只验证 JWT 签名和过期时间,不查 Redis
token, err := jwt.ParseWithClaims(string(tokenString[7:]), &Claims{}, func(token *jwt.Token) (interface{}, error) {
return accessTokenSecret, nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": "invalid token",
})
c.Abort()
return
}
claims := token.Claims.(*Claims)
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Next(ctx)
}
}
// 业务接口:零 Redis 查询
func ProfileHandler(ctx context.Context, c *app.RequestContext) {
c.JSON(http.StatusOK, map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"user_id": c.GetString("user_id"),
"username": c.GetString("username"),
},
})
}
// 刷新接口:验证 Refresh Token,查 Redis 检查撤销状态
func RefreshHandler(ctx context.Context, c *app.RequestContext) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.BindAndValidate(&req); err != nil {
c.JSON(http.StatusBadRequest, map[string]interface{}{
"code": 400,
"message": "invalid request",
})
return
}
// 解析 Refresh Token
token, err := jwt.ParseWithClaims(req.RefreshToken, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return refreshTokenSecret, nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": "invalid refresh token",
})
return
}
// 检查 Refresh Token 是否被撤销(查 Redis)
if exists, _ := redisClient.Exists(ctx, "revoked:"+req.RefreshToken).Result(); exists > 0 {
c.JSON(http.StatusUnauthorized, map[string]interface{}{
"code": 401,
"message": "refresh token has been revoked",
})
return
}
claims := token.Claims.(*Claims)
// 撤销旧的 Refresh Token(轮换)
redisClient.Set(ctx, "revoked:"+req.RefreshToken, "revoked", refreshTokenTimeout)
// 生成新的 Access Token
newAccessToken, _ := generateAccessToken(claims.Username, claims.UserID)
// 生成新的 Refresh Token(轮换)
newRefreshToken, _ := generateRefreshToken(claims.Username, claims.UserID)
c.JSON(http.StatusOK, map[string]interface{}{
"code": 200,
"access_token": newAccessToken,
"refresh_token": newRefreshToken,
"expires_in": int(accessTokenTimeout.Seconds()),
})
}
// 登出接口:撤销 Refresh Token
func LogoutHandler(ctx context.Context, c *app.RequestContext) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.BindAndValidate(&req); err != nil {
c.JSON(http.StatusBadRequest, map[string]interface{}{
"code": 400,
"message": "invalid request",
})
return
}
// 撤销 Refresh Token
redisClient.Set(ctx, "revoked:"+req.RefreshToken, "revoked", refreshTokenTimeout)
c.JSON(http.StatusOK, map[string]interface{}{
"code": 200,
"message": "logged out",
})
}
// 生成 Access Token
func generateAccessToken(username, userID string) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenTimeout)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(accessTokenSecret)
}
// 生成 Refresh Token
func generateRefreshToken(username, userID string) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenTimeout)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(refreshTokenSecret)
}
方案对比总结
单 Token 扩展刷新
优点:
- 实现简单,只需管理一个 Token
- 客户端逻辑简单,只需存储一个 Token
- 可以实现所有双 Token 的功能
- 向后兼容性好
缺点:
- 概念不如双 Token 清晰
- 不符合 OAuth 2.0 标准
适用场景:
- 中小型应用
- 快速开发
- 追求简单实现
- 移动端应用
双 Token 验证
优点:
- 职责分离,概念清晰
- 符合 OAuth 2.0 标准
- 业界广泛采用
缺点:
- 实现复杂,需要管理两个 Token
- 客户端需要处理两个 Token 的生命周期
- 需要处理两个 Token 的存储和同步
适用场景:
- 需要符合 OAuth 2.0 标准
- 大型企业应用
- 多系统集成
关键结论
-
功能等价:单 Token 扩展刷新设计可以实现双 Token 的所有核心功能,包括零 Redis 查询、Token 轮换、主动撤销等。
-
主要区别在于:
- 实现复杂度:单 Token 更简单
- 概念清晰度:双 Token 更清晰
- 标准合规性:双 Token 符合 OAuth 2.0
-
选择建议:
- 如果追求简单和效率:选择单 Token 扩展刷新
- 如果需要符合标准或多系统集成:选择双 Token
-
单 Token 扩展刷新是一个巧妙的设计 :它通过
orig_iat和MaxRefresh的组合,实现了与双 Token 相同的功能,但实现更简单。
相关资源
- 项目地址:hertz-contrib/jwt
- Bug 修复 PR:保留原始
orig_iat值以维护 MaxRefresh 窗口 - Hertz 框架:cloudwego/hertz
本文讨论的核心观点:不要盲目追随"业界最佳实践",而应该根据实际需求选择合适的方案。单 Token 扩展刷新设计在大多数场景下是足够的,且实现更简单。