一、从一个常见的认证坑说起:为什么抄的JWT中间件会被黑客绕?
很多后端开发者做项目时,都会直接搜个JWT中间件的代码粘进项目,看起来能用,实际一测就踩坑:比如黑客拿个过期的Token还能访问接口,普通用户能偷偷调用管理员的接口,甚至截获一次有效Token后,能反复用它刷数据。这都是因为JWT中间件的实现只做了“身份校验”,没做攻防细节的处理——比如防重放、防权限越界,还有令牌过期后的刷新和失效机制。
1.1 为什么裸奔的JWT中间件这么危险?
JWT本质是个“加密的门禁卡”,卡上写了用户ID、角色这些信息,服务端不需要存,只需要用密钥校验签名对不对。但如果中间件只校验签名,不做其他处理,就等于没给门禁加“过期时间”和“临时挂失功能”:黑客捡了过期但还没被服务端标记的卡,照样能进楼。
二、Gin里带安全校验的JWT中间件实现
我们用Go + Gin + JWT v5 + Redis的技术栈,写一个能防重放、防越界的中间件,每个步骤都带注释,你可以直接套进项目里。
2.1 环境准备
先确保项目里装了需要的依赖,用Go模块管理的话,直接在项目根目录执行:
# 安装Gin、JWT库和Redis客户端
go get -u github.com/gin-gonic/gin
go get -u github.com/golang-jwt/jwt/v5
go get -u github.com/go-redis/redis/v8
实际项目里,Redis要配置地址、密码,密钥不能硬编码,要存在环境变量里,这里简化演示。
2.2 核心带安全校验的中间件代码
这个中间件会做4件事:拿令牌、校验格式、查黑名单防重放、存用户信息到上下文,完整代码:
// 技术栈:Go + Gin + JWT v5 + Redis
package main
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/go-redis/redis/v8"
"github.com/google/uuid" // 用来生成唯一的令牌标识,防止重放
)
// 全局Redis客户端
var rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379", // 实际改成你自己的Redis地址
Password: "", // 实际改成你的Redis密码,没密码就留空
DB: 0, // 使用0号数据库
})
// JWT签名密钥,必须是随机字符串,实际存在环境变量里,不能硬编码!
var jwtSecret = []byte("your-project-secret-key-2024-safe-code")
// JWTAuthMiddleware 带黑名单校验的JWT中间件,防重放和身份伪造
func JWTAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 从请求头拿Authorization,格式是Bearer 空格 令牌
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供登录令牌,请先登录"})
c.Abort() // 终止后续请求,不往下走
return
}
// 2. 拆分令牌,确保格式正确
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌格式错误,正确格式:Bearer xxx"})
c.Abort()
return
}
tokenStr := parts[1]
// 3. 解析并校验令牌签名,同时拒绝其他加密算法(防止算法混淆攻击)
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
// 只允许用HS256签名,防止黑客把算法改成None,直接绕过签名校验
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.NewValidationError("无效的签名算法", jwt.ValidationErrorSignatureInvalid)
}
return jwtSecret, nil
})
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌解析失败:" + err.Error()})
c.Abort()
return
}
// 4. 提取令牌里的自定义信息,校验是否有效
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌已过期或无效,请重新登录"})
c.Abort()
return
}
// 5. 核心:查Redis黑名单,防重放攻击!jti是令牌的唯一标识,每个令牌都不一样
jti := claims["jti"].(string)
exist, err := rdb.Exists(context.Background(), "blacklist:"+jti).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器出错,请稍后再试"})
c.Abort()
return
}
if exist == 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌已失效,可能您已登出,请重新登录"})
c.Abort()
return
}
// 6. 把用户信息存到Gin上下文,后续接口直接拿,不用再解析令牌
c.Set("userID", int(claims["userID"].(float64)))
c.Set("role", claims["role"].(string))
c.Next() // 继续往下走处理请求
}
}
三、令牌过期刷新与黑名单:防重放和越界的核心策略
刚才的中间件解决了防重放,但还有两个问题:一是令牌泄露的风险,如果令牌设成7天有效期,黑客偷到后能用7天;二是用户登出后,令牌没地方失效,还是能被用。这时候就要用短令牌+刷新令牌+黑名单的组合拳。
3.1 令牌刷新:平衡安全和体验的关键
我们给用户发两个令牌:访问令牌(1小时过期,短有效期,就算泄露也很快失效)和刷新令牌(7天过期,用来换访问令牌,不用重新登录)。生成令牌的代码:
// GenerateTokens 生成访问令牌和刷新令牌,技术栈同上
func GenerateTokens(userID int, role string) (string, string, error) {
// 生成两个唯一的jti,每个令牌对应唯一标识,用来查黑名单
accessJTI := uuid.New().String()
refreshJTI := uuid.New().String()
// 1. 生成访问令牌,有效期1小时,存用户ID和角色
accessClaims := jwt.MapClaims{
"userID": userID,
"role": role,
"exp": time.Now().Add(time.Hour).Unix(), // 过期时间转时间戳
"jti": accessJTI,
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenStr, err := accessToken.SignedString(jwtSecret)
if err != nil {
return "", "", err
}
// 2. 生成刷新令牌,有效期7天,只存唯一标识,不存敏感信息
refreshClaims := jwt.MapClaims{
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
"jti": refreshJTI,
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenStr, err := refreshToken.SignedString(jwtSecret)
if err != nil {
return "", "", err
}
// 3. 把刷新令牌的jti存在Redis,过期时间和刷新令牌一致,防止重复使用
err = rdb.Set(context.Background(), "refresh:"+refreshJTI, userID, 7*24*time.Hour).Err()
if err != nil {
return "", "", err
}
return accessTokenStr, refreshTokenStr, nil
}
3.2 黑名单机制:让失效的令牌彻底没用
当用户登出时,把他的访问令牌和刷新令牌都加入黑名单,这样就算黑客拿到,也没法再用。另外,访问令牌过期后,自动把它的jti加入黑名单,节省Redis空间。登出接口代码:
// LogoutHandler 用户登出接口,把令牌加入黑名单
func LogoutHandler(c *gin.Context) {
refreshTokenStr := c.PostForm("refresh_token")
// 解析刷新令牌,拿到它的jti
refreshToken, err := jwt.Parse(refreshTokenStr, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "刷新令牌无效"})
return
}
refreshClaims, ok := refreshToken.Claims.(jwt.MapClaims)
if !ok || !refreshToken.Valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "刷新令牌已过期,请重新登录"})
return
}
refreshJTI := refreshClaims["jti"].(string)
// 拿到当前访问令牌的jti,加入黑名单(过期时间设为访问令牌的剩余时间)
authHeader := c.GetHeader("Authorization")
parts := strings.SplitN(authHeader, " ", 2)
accessJTI := ""
var accessClaims jwt.MapClaims
if len(parts) == 2 && parts[0] == "Bearer" {
accessTokenStr := parts[1]
accessToken, err := jwt.Parse(accessTokenStr, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err == nil {
accessClaims, ok = accessToken.Claims.(jwt.MapClaims)
if ok && accessToken.Valid {
accessJTI = accessClaims["jti"].(string)
}
}
}
// 把两个令牌的jti加入黑名单,防止后续使用
if accessJTI != "" {
// 黑名单过期时间设为访问令牌的剩余有效期,避免占空间
accessExp := time.Until(time.Unix(int64(accessClaims["exp"].(float64)), 0))
rdb.Set(context.Background(), "blacklist:"+accessJTI, "1", accessExp)
}
// 删除Redis里的刷新令牌记录,防止用这个刷新令牌换新的访问令牌
rdb.Del(context.Background(), "refresh:"+refreshJTI)
c.JSON(http.StatusOK, gin.H{"message": "登出成功,请重新登录"})
}
四、权限越界的处理:不让普通用户碰管理员接口
权限越界是另一个常见坑:比如普通用户的Token能调用管理员的删除接口,这时候要在中间件里加角色校验,不让越权访问。
4.1 角色校验中间件
写一个简单的角色校验中间件,比如只有admin角色能访问管理员接口:
// AdminAuthMiddleware 管理员权限校验中间件
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 从之前的JWT中间件存的上下文里拿角色信息
role, exists := c.Get("role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort()
return
}
// 只有角色是admin的用户能访问,其他角色直接返回403
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "权限不足,仅管理员可访问该接口"})
c.Abort()
return
}
// 权限校验通过,继续处理请求
c.Next()
}
}
4.2 实战使用方式
在Gin路由里,把中间件绑定到需要的接口:
func main() {
r := gin.Default()
// 不需要认证的公开接口,比如登录
r.POST("/login", LoginHandler)
// 刷新令牌接口,不用JWT中间件,用刷新令牌单独校验
r.POST("/refresh", RefreshTokenHandler)
// 需要认证的接口组,所有接口都走JWT中间件
authGroup := r.Group("/api")
authGroup.Use(JWTAuthMiddleware())
{
// 需要管理员权限的接口,加AdminAuthMiddleware
authGroup.DELETE("/admin/user/:id", AdminAuthMiddleware(), DeleteUserHandler)
// 普通用户也能访问的接口,不需要加角色校验
authGroup.GET("/user/info", GetUserInfoHandler)
}
r.Run(":8080")
}
五、技术优缺点与注意事项
5.1 优点
JWT中间件的核心优势是无状态,服务端不需要存用户会话,适合分布式部署的微服务项目,不用做会话共享,扩展性好。
5.2 缺点
令牌一旦泄露,在有效期内都能使用,所以必须配合短令牌和刷新机制,不能用太长的有效期;另外,黑名单的校验会增加Redis的查询开销,但可以通过过期策略避免空间浪费。
5.3 注意事项
- JWT签名密钥绝对不能硬编码,必须存在环境变量或配置中心,防止泄露;
- 刷新令牌的有效期要比访问令牌长,不能存在客户端的LocalStorage以外的地方,防止被XSS攻击窃取;
- 黑名单的过期时间必须和对应的令牌有效期一致,避免Redis里存大量过期的无效数据;
- 必须校验JWT的签名算法,拒绝None算法,防止黑客绕过签名校验。
六、总结
要做一个安全的JWT认证中间件,不能只抄代码,要从三个方面入手:一是校验令牌的完整性(签名、格式、算法),二是防重放(黑名单+唯一jti),三是防越界(角色校验);再搭配短令牌+刷新令牌的策略,平衡安全和用户体验。这样就算遇到黑客,也能把攻击的风险降到最低。
评论
围绕“基于Gin实现JWT认证中间件的攻防细节与实战场景,令牌过期刷新与黑名单校验如何防止重放攻击和权限越界的处理策略”参与讨论