一、从一个常见的认证坑说起:为什么抄的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 注意事项

  1. JWT签名密钥绝对不能硬编码,必须存在环境变量或配置中心,防止泄露;
  2. 刷新令牌的有效期要比访问令牌长,不能存在客户端的LocalStorage以外的地方,防止被XSS攻击窃取;
  3. 黑名单的过期时间必须和对应的令牌有效期一致,避免Redis里存大量过期的无效数据;
  4. 必须校验JWT的签名算法,拒绝None算法,防止黑客绕过签名校验。

六、总结

要做一个安全的JWT认证中间件,不能只抄代码,要从三个方面入手:一是校验令牌的完整性(签名、格式、算法),二是防重放(黑名单+唯一jti),三是防越界(角色校验);再搭配短令牌+刷新令牌的策略,平衡安全和用户体验。这样就算遇到黑客,也能把攻击的风险降到最低。