Go语言JWT无感刷新与SSO单点登录实现详解

落日邮差
落日邮差
发布时间:2026-10-13| 5人浏览过
原创

针对传统Session认证在服务端负载高且难以踢下线的问题,本文介绍了基于JWT的双Token无感刷新方案。通过设置短效Access Token和长效Refresh Token,既保证了接口调用的安全性,又提升了用户体验。此外,结合Redis存储用户的当前有效Token,可实现严格的单点登录(SSO)限制,当新设备登录时自动使旧会话失效。

JWT基础与安全策略

传统的Cookie+Session模式需要在服务端存储用户状态,随着并发量增加,内存和数据库压力显著增大。JWT(JSON Web Token)将用户信息编码在Token中,由客户端保存并每次请求携带,服务端仅需验证签名,实现无状态认证。

双Token机制设计

单纯使用长有效期Token存在安全隐患,一旦泄露难以撤销。业界通用做法是采用 access token (AT) 和 refresh token (RT) 组合:

  • access token:有效期极短(如15分钟~2小时),用于日常业务请求。丢失后影响范围小。
  • refresh token:有效期较长(如7天~30天),专门用于在AT过期时获取新的AT。建议在签发时绑定 client_secret 或使用HttpOnly Cookie存储以增加安全性。

其核心交互逻辑如下:

Go语言实现JWT双Token

第1步:安装依赖库

使用官方推荐的 golang-jwt/jwt/v4 库进行开发:

go get -u github.com/golang-jwt/jwt/v4

第2步:定义结构与常量

定义自定义Claims结构体以承载用户数据,并设置全局密钥和过期时间常量:

package main
import (
	"errors"
	"time"
	"github.com/golang-jwt/jwt/v4"
)

// 配置常量
const (
	ATokenExpiredDuration  = 2 * time.Hour   // Access Token有效期2小时
	RTokenExpiredDuration  = 30 * 24 * time.Hour // Refresh Token有效期30天
	TokenIssuer            = "my-app"        // Issuer标识
)

var (
	mySecret          = []byte("change-this-to-a-long-secure-secret")
	ErrorInvalidToken = errors.New("token verification failed")
)

// 自定义Claims
// Redis SSO场景中,通常无需在此存储额外字段,除非需要存储版本校验等
// 但为了兼容原有结构,保留UserID以便刷新时使用
type MyClaim struct {
	UserID   int64  `json:"user_id"`
	Username string `json:"username"`
	jwt.RegisteredClaims
}

func getJWTTime(t time.Duration) *jwt.NumericDate {
	return jwt.NewNumericDate(time.Now().Add(t))
}

func keyFunc(token *jwt.Token) (interface{}, error) {
	return mySecret, nil
}

第3步:颁发Token函数

GenToken 负责一次性生成AT和RT。RT不携带敏感业务数据,仅作为刷新凭证:

// GenToken 颁发 access token 和 refresh token
func GenToken(UserID int64, Username string) (atoken, rtoken string, err error) {
	// 1. 生成 Access Token (包含用户信息)
	rc := jwt.RegisteredClaims{
		ExpiresAt: getJWTTime(ATokenExpiredDuration),
		Issuer:    TokenIssuer,
	}
	at := MyClaim{
		UserID,
		Username,
		rc,
	}
	atoken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, at).SignedString(mySecret)
	if err != nil {
		return "", "", err
	}

	// 2. 生成 Refresh Token (纯Claims,不含用户明细,降低泄露风险)
	rt := rc
	rt.ExpiresAt = getJWTTime(RTokenExpiredDuration)
	rtoken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, rt).SignedString(mySecret)
	return atoken, rtoken, err
}

第4步:验证与无感刷新逻辑

当请求到来时,先尝试解析AT。如果AT有效,正常放行;如果AT因过期报错,则尝试用RT刷新:

// VerifyToken 验证Token有效性
func VerifyToken(tokenID string) (*MyClaim, error) {
	var myc = new(MyClaim)
	token, err := jwt.ParseWithClaims(tokenID, myc, keyFunc)
	if err != nil {
		return nil, err
	}
	if !token.Valid {
		return nil, ErrorInvalidToken
	}
	return myc, nil
}

// RefreshToken 通过 refreshToken 刷新 accessToken
func RefreshToken(atoken, rtoken string) (newAtoken, newRtoken string, err error) {
	// 1. 检查 RT 是否合法 (未篡改、未过期)
	if _, err = jwt.Parse(rtoken, keyFunc); err != nil {
		return "", "", err
	}

	// 2. 解析旧的 AT 提取 Claims
	var claim MyClaim
	_, err = jwt.ParseWithClaims(atoken, &claim, keyFunc)
	if err != nil {
		// 非过期错误(如格式错误、签名错误),拒绝刷新
		return "", "", err
	}

	// 3. 判断是否为过期错误
	v, ok := err.(*jwt.ValidationError)
	if ok && v.Errors == jwt.ValidationErrorExpired {
		// AT过期但RT有效,重新签发新的AT和RT
		// 注意:此处选择轮换RT也是最佳实践,防止RT被无限循环使用
		return GenToken(claim.UserID, claim.Username)
	}
	return "", "", errors.New("refresh failed: invalid atoken")
}

集成Redis实现单点登录(SSO)

实现思路

为了实现“同一账号只允许一个设备登录”,利用Redis的原子操作特性:

  1. 存储:用户使用用户名或ID作为Key,当前有效的Access Token作为Value存入Redis。设置合理的TTL。
  2. 校验:每次API请求通过中间件拦截。
  3. 冲突检测:从Redis取出该用户的当前Token。如果请求带来的Token与Redis中的不一致,说明该账号已在其他地方登录,当前请求视为无效(踢下线)。

实战代码:中间件处理

假设使用Gin框架编写中间件,整合JWT验证与Redis SSO逻辑:

import (
    "net/http"
    // 引入你的 Redis 封装和序列化层
)

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. 获取 Header 中的 Bearer Token
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
            c.Abort()
            return
        }

        // 简化取 part[1] 的逻辑
        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || parts[0] != "Bearer" {
             c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid format"})
             c.Abort()
             return
        }

        rawToken := parts[1]
        var mc *MyClaim

        // 2. 优先尝试直接验证
        mc, err := jwt.VerifyToken(rawToken)
        isExpired := false
        if err != nil {
            // 3. 如果不是普通错误,可能是过期了,尝试刷新
            // 假设前端同时也传回了 refreshToken
            refreshToken := c.GetHeader("X-Refresh-Token") 
            if refreshToken == "" {
                c.JSON(http.StatusUnauthorized, gin.H{"error": "auth failed"})
                c.Abort()
                return
            }
            
            newAT, newRT, refreshErr := jwt.RefreshToken(rawToken, refreshToken)
            if refreshErr != nil {
                 c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh failed"})
                 c.Abort()
                 return
            }
            
            // 刷新成功,更新上下文中的Claims以便后续使用
            // 注意:这里需要重新解析新token或者复用GenToken返回的信息
            // 简单起见,这里假设RefreshToken逻辑内部能返回mc,或者我们再次Parse
            // 实际生产中建议 RefreshToken 也返回 mc
            mc, _ = jwt.VerifyToken(newAT)
            
            // 回写新 Token 给前端
            c.Header("X-New-Access-Token", newAT)
            c.Header("X-New-Refresh-Token", newRT)
            
            // 同步更新 Redis 中的 Token
            if mc != nil {
               redis.SetSingleUserToken(mc.UserID, newAT) 
            }
            isExpired = true // 标记已刷新,跳过一次SSO比对或执行SSO
        } else {
            // 正常情况:直接去Redis做SSO校验
        }

        // 4. SSO 核心校验
        if mc == nil {
             c.Abort()
             return
        }

        currentTokenInDB, dbErr := redis.GetSingleUserToken(mc.UserID)
        if dbErr != nil {
            // 数据库中没记录?可能是首次登录或者缓存击穿,视业务而定
            // 如果严格SSO,通常需要写入初始值。这里假设之前已经写入
             c.JSON(http.StatusInternalServerError, gin.H{"error": "server busy"})
             c.Abort()
             return
        }

        // 比较当前请求Token与Redis中的Token
        // 注意:如果是刚刷新的情况,currentTokenInDB应该已经被UpdateMiddleware更新了
        if currentTokenInDB != (isExpired ? newAT : rawToken) {
            // Token不一致,说明已被其他终端登录,当前终端踢出
            c.JSON(http.StatusForbidden, gin.H{"error": "account logged in elsewhere"})
            c.Abort()
            return
        }

        // 校验通过,继续请求
        c.Next()
    }
}

关键逻辑说明

  • Redis Key设计:建议使用 sso:user:{userID} 作为Key,存储Value为 accessToken。
  • 原子性:在登录成功时,必须使用 SET key value EX ttl 命令确保设置过期的原子性,防止TTl丢失导致僵尸会话。
  • 一致性:如果实现了Token刷新,必须确保在刷新成功后,立即更新Redis中的Value,否则下一次请求会因SSO比对失败而被误杀。

总结

通过结合Go语言的JWT库与Redis,我们构建了一个兼顾安全与体验的认证体系:短效AT保证高频请求的低开销校验,长效RT配合无感刷新机制提升用户体验;而基于Redis的单点登录校验则有效解决了账号异地登录的安全隐患。在实际生产环境中,还需注意Secret的安全性保管以及Redis集群下的同步策略。

以上就是GoLang JWT无感刷新与SSO单点登录限制解除方法的详细内容,更多关于Go Lang JWT双Token无感刷新及SSO单用户登录控制的技术资料请关注本站其它相关文章!

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。