JWT 认证方案深度对比:单 Token 扩展刷新 vs 双 Token 验证

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分钟)

关键洞察

  1. 业务请求零 Redis 查询:两种方案都可以在业务请求中只验证 JWT 签名,不查询 Redis。Token 过期后自动失效,不需要黑名单。

  2. Token 轮换:单 Token 刷新后就是新 Token,将旧 Token 加入黑名单即实现轮换。

  3. 撤销能力:两种方案都需要 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 标准
  • 大型企业应用
  • 多系统集成

关键结论

  1. 功能等价:单 Token 扩展刷新设计可以实现双 Token 的所有核心功能,包括零 Redis 查询、Token 轮换、主动撤销等。

  2. 主要区别在于

    • 实现复杂度:单 Token 更简单
    • 概念清晰度:双 Token 更清晰
    • 标准合规性:双 Token 符合 OAuth 2.0
  3. 选择建议

    • 如果追求简单和效率:选择单 Token 扩展刷新
    • 如果需要符合标准或多系统集成:选择双 Token
  4. 单 Token 扩展刷新是一个巧妙的设计 :它通过 orig_iatMaxRefresh 的组合,实现了与双 Token 相同的功能,但实现更简单。

相关资源


本文讨论的核心观点:不要盲目追随"业界最佳实践",而应该根据实际需求选择合适的方案。单 Token 扩展刷新设计在大多数场景下是足够的,且实现更简单。

相关推荐
我是你们的明哥5 小时前
A*(A-Star)算法详解:智能路径规划的核心技术
后端·算法
曾富贵5 小时前
【Prisma】NestJS 集成与核心链路解析
数据库·后端
起风了___5 小时前
Flask生产级模板:统一返回、日志、异常、JSON编解码,开箱即用可扩展
后端·python
我是你们的明哥5 小时前
从 N 个商品中找出总价最小的 K 个方案
后端·算法
骑着bug的coder5 小时前
第4讲:现代SQL高级特性——窗口函数与CTE
后端
Dwzun5 小时前
基于SpringBoot+Vue的农产品销售系统【附源码+文档+部署视频+讲解)
数据库·vue.js·spring boot·后端·毕业设计
y1y1z5 小时前
Spring Security教程
java·后端·spring
黑客思维者5 小时前
XGW-9000系列高端新能源电站边缘网关硬件架构设计
网络·架构·硬件架构·嵌入式·新能源·计算机硬件·电站
小橙编码日志5 小时前
分布式系统推送失败补偿场景【解决方案】
后端·面试