☰
Gin框架JWT鉴权中间件从零实现token刷新与黑名单机制
2026/10/13 9:13:48 网站建设 项目流程

Gin框架JWT鉴权中间件从零实现token刷新与黑名单机制

导语

在前后端分离架构中,JWT(JSON Web Token)已成为最主流的无状态鉴权方案。Gin作为Go语言中最流行的Web框架,如何优雅地集成JWT鉴权、实现token自动刷新、以及构建token黑名单机制,是每一个Gin项目必须面对的核心问题。

本文将带你从零开始,在Gin框架中完整实现一个生产级的JWT鉴权中间件,涵盖token签发、自动刷新、黑名单踢人等全套功能。所有代码均经过实战验证,可直接用于生产环境。


核心技术知识点讲解

1. JWT基础结构

JWT由三部分组成,用.分隔:

Header.Payload.Signature
  • Header:指定签名算法(HS256/RS256等)
  • Payload:携带业务数据(用户ID、过期时间等)
  • Signature:对前两部分的签名,防止篡改

2. Gin中间件原理

Gin的中间件本质是gin.HandlerFunc函数,通过c.Next()控制请求流向:

funcAuthMiddleware()gin.HandlerFunc{returnfunc(c*gin.Context){// 鉴权逻辑if!validateToken(c){c.AbortWithStatusJSON(401,gin.H{"error":"unauthorized"})return}c.Next()// 放行到下一个处理器}}

3. Token刷新策略

常见刷新策略有两种:

策略优点缺点
双Token方案(Access+Refresh)安全性高,Refresh Token可设长过期客户端需维护两个Token
滑动过期(Sliding Expiration)用户体验好,无感知刷新需维护状态,不完全无状态

本文采用双Token方案,兼顾安全性与用户体验。

4. 黑名单机制设计

JWT本身是无状态的,无法实现"踢人下线"。引入Redis黑名单,在token过期前将其加入黑名单:

Key: ban:user:{userID}:{jti} TTL: 与token剩余过期时间一致

验证时先查黑名单,命中则拒绝。


实战代码演示/项目案例总结

项目结构

gin-jwt-demo/ ├── main.go ├── middleware/ │ └── jwt.go ├── handler/ │ └── auth.go ├── config/ │ └── config.go └── go.mod

核心代码实现

1. 定义JWT工具包(middleware/jwt.go)
packagemiddlewareimport("crypto/rsa""encoding/json""fmt""time""github.com/gin-gonic/gin""github.com/golang-jwt/jwt/v5""github.com/redis/go-redis/v9""net/http")var(accessTokenSecret*rsa.PrivateKey refreshTokenSecret*rsa.PrivateKey redisClient*redis.Client)// 初始化密钥(生产环境从环境变量或KMS加载)funcInitJWT(privateKey*rsa.PrivateKey,pubKey*rsa.PublicKey,rdb*redis.Client){accessTokenSecret=privateKey refreshTokenSecret=privateKey// 实际生产中可用不同密钥redisClient=rdb}// Claims 自定义载荷typeClaimsstruct{UserIDint64`json:"user_id"`Usernamestring`json:"username"`TokenTypestring`json:"token_type"`// access 或 refreshjwt.RegisteredClaims}const(TokenTypeAccess="access"TokenTypeRefresh="refresh")// GenerateTokenPair 生成Access+Refresh双TokenfuncGenerateTokenPair(userIDint64,usernamestring)(accessToken,refreshTokenstring,errerror){now:=time.Now()// Access Token:15分钟过期accessClaims:=Claims{UserID:userID,Username:username,TokenType:TokenTypeAccess,RegisteredClaims:jwt.RegisteredClaims{ID:generateJTI(),// 唯一标识,用于黑名单IssuedAt:jwt.NewNumericDate(now),ExpiresAt:jwt.NewNumericDate(now.Add(15*time.Minute)),},}access:=jwt.NewWithClaims(jwt.SigningMethodRS256,accessClaims)accessToken,err=access.SignedString(accessTokenSecret)iferr!=nil{return"","",fmt.Errorf("生成Access Token失败: %w",err)}// Refresh Token:7天过期refreshClaims:=Claims{UserID:userID,Username:username,TokenType:TokenTypeRefresh,RegisteredClaims:jwt.RegisteredClaims{ID:generateJTI(),IssuedAt:jwt.NewNumericDate(now),ExpiresAt:jwt.NewNumericDate(now.Add(7*24*time.Hour)),},}refresh:=jwt.NewWithClaims(jwt.SigningMethodRS256,refreshClaims)refreshToken,err=refresh.SignedString(refreshTokenSecret)iferr!=nil{return"","",fmt.Errorf("生成Refresh Token失败: %w",err)}returnaccessToken,refreshToken,nil}// ParseToken 解析并验证TokenfuncParseToken(tokenStringstring,tokenTypestring)(*Claims,error){token,err:=jwt.ParseWithClaims(tokenString,&Claims{},func(token*jwt.Token)(interface{},error){returnaccessTokenSecret.Public(),nil})iferr!=nil{returnnil,fmt.Errorf("Token解析失败: %w",err)}claims,ok:=token.Claims.(*Claims)if!ok||!token.Valid{returnnil,fmt.Errorf("Token无效")}// 验证Token类型是否匹配ifclaims.TokenType!=tokenType{returnnil,fmt.Errorf("Token类型不匹配,期望:%s 实际:%s",tokenType,claims.TokenType)}returnclaims,nil}// AddToBlacklist 将Token加入黑名单(踢人下线)funcAddToBlacklist(jtistring,userIDint64,expireAt time.Time)error{key:=fmt.Sprintf("ban:user:%d:%s",userID,jti)ttl:=time.Until(expireAt)ifttl<=0{returnnil// Token已过期,无需加入黑名单}returnredisClient.Set(ctx,key,"1",ttl).Err()}// IsInBlacklist 检查Token是否在黑名单中funcIsInBlacklist(jtistring,userIDint64)(bool,error){key:=fmt.Sprintf("ban:user:%d:%s",userID,jti)exists,err:=redisClient.Exists(ctx,key).Result()returnexists>0,err}// 生成唯一JTI(JWT ID)funcgenerateJTI()string{returnfmt.Sprintf("%d-%s",time.Now().UnixNano(),randomString(8))}
2. JWT鉴权中间件
// JWTAuthMiddleware Gin鉴权中间件funcJWTAuthMiddleware()gin.HandlerFunc{returnfunc(c*gin.Context){// 1. 从Header提取TokenauthHeader:=c.GetHeader("Authorization")ifauthHeader==""{c.AbortWithStatusJSON(http.StatusUnauthorized,gin.H{"code":401,"message":"缺少Authorization Header",})return}// 2. 解析 Bearer Tokenparts:=strings.SplitN(authHeader," ",2)iflen(parts)!=2||parts[0]!="Bearer"{c.AbortWithStatusJSON(http.StatusUnauthorized,gin.H{"code":401,"message":"Authorization格式错误,应为: Bearer {token}",})return}// 3. 解析并验证Tokenclaims,err:=ParseToken(parts[1],TokenTypeAccess)iferr!=nil{c.AbortWithStatusJSON(http.StatusUnauthorized,gin.H{"code":401,"message":fmt.Sprintf("Token无效: %v",err),})return}// 4. 检查黑名单banned,err:=IsInBlacklist(claims.ID,claims.UserID)iferr!=nil{c.AbortWithStatusJSON(http.StatusInternalServerError,gin.H{"code":500,"message":"鉴权服务异常",})return}ifbanned{c.AbortWithStatusJSON(http.StatusUnauthorized,gin.H{"code":401,"message":"Token已被吊销,请重新登录",})return}// 5. 将用户信息注入上下文c.Set("userID",claims.UserID)c.Set("username",claims.Username)c.Set("jti",claims.ID)c.Next()}}
3. Token刷新接口(handler/auth.go)
packagehandlerimport("github.com/gin-gonic/gin""net/http")typeRefreshRequeststruct{RefreshTokenstring`json:"refresh_token" binding:"required"`}typeAuthResponsestruct{AccessTokenstring`json:"access_token"`RefreshTokenstring`json:"refresh_token"`ExpiresInint64`json:"expires_in"`// 秒}// RefreshToken 刷新Access TokenfuncRefreshToken(c*gin.Context){varreq RefreshRequestiferr:=c.ShouldBindJSON(&req);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"code":400,"message":"参数错误: "+err.Error(),})return}// 解析Refresh Tokenclaims,err:=middleware.ParseToken(req.RefreshToken,middleware.TokenTypeRefresh)iferr!=nil{c.JSON(http.StatusUnauthorized,gin.H{"code":401,"message":"Refresh Token无效或已过期",})return}// 检查Refresh Token是否在黑名单(用户主动退出登录)banned,_:=middleware.IsInBlacklist(claims.ID,claims.UserID)ifbanned{c.JSON(http.StatusUnauthorized,gin.H{"code":401,"message":"Refresh Token已被吊销",})return}// 生成新的Token对accessToken,refreshToken,err:=middleware.GenerateTokenPair(claims.UserID,claims.Username)iferr!=nil{c.JSON(http.StatusInternalServerError,gin.H{"code":500,"message":"生成Token失败",})return}// 将旧的Refresh Token加入黑名单(一次性使用)_=middleware.AddToBlacklist(claims.ID,claims.UserID,claims.ExpiresAt.Time)c.JSON(http.StatusOK,gin.H{"code":0,"message":"刷新成功","data":AuthResponse{AccessToken:accessToken,RefreshToken:refreshToken,ExpiresIn:15*60,// 15分钟},})}// Logout 退出登录(将当前Token加入黑名单)funcLogout(c*gin.Context){jti,_:=c.Get("jti")userID,_:=c.Get("userID")exp,_:=c.Get("exp")// 需从claims中提取过期时间// 将当前Access Token和Refresh Token都加入黑名单// 实际实现中需前端同时传递Refresh Token_=middleware.AddToBlacklist(jti.(string),userID.(int64),exp.(time.Time))c.JSON(http.StatusOK,gin.H{"code":0,"message":"退出登录成功",})}
4. 主程序注册路由(main.go)
packagemainimport("crypto/rsa""github.com/gin-gonic/gin""github.com/redis/go-redis/v9""gin-jwt-demo/handler""gin-jwt-demo/middleware")funcmain(){// 1. 加载RSA密钥(示例使用gin内置的密钥生成)privateKey,_:=rsa.GenerateKey(rand.Reader,2048)publicKey:=&privateKey.PublicKey// 2. 初始化Redisrdb:=redis.NewClient(&redis.Options{Addr:"localhost:6379",})// 3. 初始化JWTmiddleware.InitJWT(privateKey,publicKey,rdb)// 4. 注册路由r:=gin.Default()// 公开路由public:=r.Group("/api/v1"){public.POST("/login",handler.Login)public.POST("/refresh",handler.RefreshToken)}// 受保护路由protected:=r.Group("/api/v1")protected.Use(middleware.JWTAuthMiddleware()){protected.GET("/profile",handler.GetProfile)protected.POST("/logout",handler.Logout)}r.Run(":8080")}

开发痛点与报错避坑指南

坑1:crypto/rsa密钥加载失败

报错信息:

panic: crypto/rsa: invalid key

原因:直接从字符串加载PEM格式密钥时,未正确使用bytes.NewBuffer或Decode。

正确做法:

// 从文件加载私钥privateKeyBytes,_:=os.ReadFile("private_key.pem")block,_:=pem.Decode(privateKeyBytes)privateKey,err:=x509.ParsePKCS1PrivateKey(block.Bytes)

生产环境建议使用环境变量或密钥管理服务(如AWS KMS、阿里云KMS)。

坑2:Claims类型断言失败

报错信息:

interface conversion: *jwt.Token is not *middleware.Claims

原因:ParseWithClaims传入的Claims实例类型,必须与后续类型断言一致。

正确做法:

// 正确:传入指针类型claims:=&Claims{}token,err:=jwt.ParseWithClaims(tokenString,claims,keyFunc)// 直接使用claims,无需类型断言

坑3:中间件中使用c.Set后c.Get返回nil

原因:在中间件调用c.Set()后,若在c.Next()之后获取值,需注意c.Copy()的上下文隔离。

正确做法:

// 在中间件中设置c.Set("userID",userID)c.Next()// 在后续处理器中通过c.Get("userID")获取,而非c.Copy()

坑4:Redis黑名单Key过期时间计算错误

问题:Token剩余有效期计算错误,导致黑名单Key提前过期,失去拦截效果。

正确做法:

// 计算Token剩余TTLttl:=time.Until(claims.ExpiresAt.Time)ifttl>0{redisClient.Set(ctx,key,"1",ttl)}

坑5:Refresh Token被盗用导致安全漏洞

问题:Refresh Token若被截获,攻击者可无限刷新。

解决方案:

  1. Refresh Token设置一次性使用(使用后立即加入黑名单)
  2. 记录Refresh Token的使用IP和设备指纹
  3. 支持用户查看并吊销所有活跃会话

全文总结+技术进阶展望

本文从零实现了一个生产级Gin+JWT鉴权方案,核心要点总结:

  1. 双Token设计:Access Token短过期保障安全,Refresh Token长过期保障体验
  2. RSA签名:相比HS256,RSA支持密钥分发,适合微服务场景
  3. 黑名单机制:通过Redis实现token吊销,弥补JWT无状态缺陷
  4. 中间件封装:统一的鉴权中间件,对业务代码零侵入

进阶方向:

  • 引入gin-jwt开源库(https://github.com/appleboy/gin-jwt)减少重复造轮子
  • 结合go-oauth2实现OAuth2.0完整授权流程
  • 使用paseto(Platform-Agnostic Security Tokens)替代JWT,避免算法混淆漏洞
  • 在网关层(如Kong/APISIX)统一处理鉴权,应用层只关注业务逻辑

参考文献

  1. JWT官方规范:https://tools.ietf.org/html/rfc7519
  2. golang-jwt/jwt库:https://github.com/golang-jwt/jwt
  3. Gin中间件文档:https://gin-gonic.com/zh-cn/docs/examples/middleware/
  4. OWASP JWT安全备忘单:https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
  5. Redis官方文档:https://redis.io/docs/
  6. RSA密钥管理最佳实践:https://crypto.stackexchange.com/questions/81512/

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询