Go语言JWT无感刷新与SSO单点登录实现详解
针对传统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的原子操作特性:
- 存储:用户使用用户名或ID作为Key,当前有效的Access Token作为Value存入Redis。设置合理的TTL。
- 校验:每次API请求通过中间件拦截。
- 冲突检测:从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单用户登录控制的技术资料请关注本站其它相关文章!本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













