Gin + JWT 认证机制详解:构建安全的Go Web应用

前言

在现代Web开发中,用户身份认证是几乎所有应用的核心功能。随着前后端分离架构的普及,基于Token的认证机制(如JWT)因其++无状态++ 、++可扩展性++强等特点,逐渐成为主流。本文将详细介绍如何在Go语言的Gin框架中集成JWT(JSON Web Token)实现安全的用户认证。

1. 什么是JWT?

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地传输信息作为JSON对象。它通常用于身份验证和信息交换。

一个JWT由三部分组成,用点(.)分隔:

text 复制代码
xxxxx.yyyyy.zzzzz

Header(头部):包含令牌类型和使用的签名算法(如HS256)。

Payload(负载):包含声明(claims),如用户ID、过期时间等。

Signature(签名):对前两部分的签名,用于验证消息未被篡改。
https://www.jwt.io/

2. Access Token 和 Refresh Token 的区别
Access Token(访问令牌):
  • 用于访问受保护的资源(如 API 接口、用户数据等)。
  • 客户端每次向服务器请求资源时,都需要在请求头中携带 access token,服务器验证通过后才允许访问。
  • 生命周期短,通常为几分钟到几小时(如 15 分钟、1 小时)
  • 可以存储在客户端内存或 localStorage 中(但有 XSS 风险)
Refresh Token(刷新令牌):
  • 用于获取新的 access token。
  • 当 access token 过期后,客户端可以使用 refresh token 向授权服务器申请一个新的 access token,而无需用户重新登录。
  • 生命周期长,可以是几天、几周甚至几个月。但通常只在用户长时间未活动或主动登出时才会失效。
  • 必须更安全地存储,如 HTTP Only Cookie、安全的后端存储。

二、集成JWT

1. 安装依赖
bash 复制代码
go get -u github.com/golang-jwt/jwt/v5
2.配置JWT密钥和过期时间
go 复制代码
type Config struct {
	//...
	JWT struct {
		Secret             string `mapstructure:"secret"`
		AccessTokenExpire  int    `mapstructure:"access_token_expire"`  // 单位: 小时
		RefreshTokenExpire int    `mapstructure:"refresh_token_expire"` // 单位: 小时
	} `mapstructure:"jwt"`
}
3. 创建JWT工具函数
go 复制代码
// utils/jwt
import (
	"context"
	"errors"
	"gin/global"
	"github.com/golang-jwt/jwt/v5"
	"time"
)

// 定义 Token 相关常量
const (
	TokenTypeAccess      = "access"
	TokenTypeRefresh     = "refresh"
	TokenBlacklistPrefix = "jwt:blacklist:"
)

// Claims 自定义 JWT 声明
type Claims struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	Type     string `json:"type"` // token类型: access 或 refresh
	jwt.RegisteredClaims
}

// GenerateToken 生成 JWT Token
func GenerateToken(userID uint, username string, tokenType string, expireTime time.Duration) (string, error) {
	// 创建声明
	claims := Claims{
		UserID:   userID,
		Username: username,
		Type:     tokenType,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireTime)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
		},
	}

	// 创建 token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// 签名并获取完整的编码后的字符串 token
	tokenString, err := token.SignedString([]byte(global.Config.App.Name))
	if err != nil {
		return "", err
	}

	return tokenString, nil
}

// ParseToken 解析 JWT Token
func ParseToken(tokenString string) (*Claims, error) {
	if err != nil {
		return nil, err
	}
	if isBlacklisted {
		return nil, errors.New("token has been blacklisted")
	}

	// 解析 token
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		// 验证签名算法
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, errors.New("unexpected signing method")
		}
		return []byte(global.Config.App.Name), nil
	})

	if err != nil {
		return nil, err
	}

	// 提取声明
	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid {
		return nil, errors.New("invalid token")
	}

	return claims, nil
}

// RefreshToken 刷新 Token
func RefreshToken(refreshToken string) (string, string, error) {
	// 解析 refresh token
	claims, err := ParseToken(refreshToken)
	if err != nil {
		return "", "", err
	}

	// 检查是否为 refresh token
	if claims.Type != TokenTypeRefresh {
		return "", "", errors.New("invalid refresh token")
	}

	// 生成新的 access token (有效期短)
	accessToken, err := GenerateToken(claims.UserID, claims.Username, TokenTypeAccess, time.Hour*time.Duration(global.Config.JWT.AccessTokenExpire))
	if err != nil {
		return "", "", err
	}

	// 生成新的 refresh token (有效期长)
	newRefreshToken, err := GenerateToken(claims.UserID, claims.Username, TokenTypeRefresh, time.Hour*time.Duration(global.Config.JWT.RefreshTokenExpire))
	if err != nil {
		return "", "", err
	}

	return accessToken, newRefreshToken, nil
}
4.创建JWT中间件
go 复制代码
// middleware/auth
// JWTAuth 认证中间件
func JWTAuth() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 从 Authorization 头中获取 token
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"message": "Authorization header is required"})
			c.Abort()
			return
		}

		// 检查 token 格式
		tokenParts := strings.Split(authHeader, " ")
		if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
			c.JSON(http.StatusUnauthorized, gin.H{"message": "Invalid authorization format"})
			c.Abort()
			return
		}

		// 解析 token (ParseToken 函数已经包含了黑名单检查)
		claims, err := utils.ParseToken(tokenParts[1])
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"message": err.Error()})
			c.Abort()
			return
		}

		// 检查 token 类型是否为 access token
		if claims.Type != utils.TokenTypeAccess {
			c.JSON(http.StatusUnauthorized, gin.H{"message": "Invalid token type"})
			c.Abort()
			return
		}

		// 将用户信息存储在上下文中
		c.Set("userId", claims.UserID)
		c.Set("username", claims.Username)

		// 继续处理请求
		c.Next()
	}
}
5. 用户路由与认证
go 复制代码
// 用户登录
func Login(c *gin.Context) {
	var req LoginReq
	err := c.ShouldBindJSON(&req)
	if err != nil {
		ResponseValidateErr(c, err)
		return
	}

	var user models.User
	count := global.DB.Where("username = ?", req.Username).First(&user).RowsAffected
	if count == 0 {
		res.FailWithMessage(c, "用户名或密码错误")
		return
	}

	// 验证密码
	if !utils.CheckPassword(req.Password, user.Password) {
		res.FailWithMessage(c, "用户名或密码错误")
		return
	}

	// 生成 access token (24小时过期)
	accessToken, err := utils.GenerateToken(user.ID, user.Username, utils.TokenTypeAccess, time.Duration(global.Config.JWT.AccessTokenExpire)*time.Hour)
	if err != nil {
		res.FailWithMessage(c, "生成Token失败")
		return
	}

	// 生成 refresh token (7天过期)
	refreshToken, err := utils.GenerateToken(user.ID, user.Username, utils.TokenTypeRefresh, time.Duration(global.Config.JWT.RefreshTokenExpire)*time.Hour)
	if err != nil {
		res.FailWithMessage(c, "生成Token失败")
		return
	}

	// 返回 token
	res.Success(c, gin.H{
		"access_token":  accessToken,
		"refresh_token": refreshToken,
		"token_type":    "Bearer",
		"expires_in":    24 * 3600, // 秒
	})
}

给受保护的API 增加认证中间件如:

go 复制代码
func InitUserRouter(Router *gin.RouterGroup) {
	userRouter := Router.Group("user")
	{
		userRouter.POST("register", api.Register)
		userRouter.POST("login", api.Login)
		userRouter.POST("refresh", api.RefreshToken)
		userRouter.POST("logout",middleware.JWTAuth(), api.Logout)
		userRouter.GET("list", middleware.JWTAuth(), api.GetUserList)
	}
}
6.启动服务并测试

登录接口

bash 复制代码
curl -X POST http://localhost:8080/api/v1/user/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user1", "password":"password1"}'

受保护的接口

bash 复制代码
curl -X GET http://localhost:8080/api/v1/user/list\
  -H "Authorization: Bearer <your-token>"

三、加入黑名单功能

1. 为什么需要Token黑名单?

JWT的核心优势是无状态 ,服务器无需存储会话。但这带来了挑战:

无法主动使Token失效:一旦签发,直到过期前都有效。

安全风险:用户登出、密码修改后,旧Token仍可使用。

黑名单机制通过一个中心化存储(如Redis),记录所有已注销但尚未过期的Token,每次请求时检查该列表,从而实现"伪状态化"的注销功能。

2.黑名单管理工具
go 复制代码
// BlacklistToken 将 token 加入黑名单
func BlacklistToken(tokenString string, expireTime time.Duration) error {
	// 获取 token 的过期时间
	claims, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		return []byte(global.Config.App.Name), nil
	})

	if err != nil {
		// 如果 token 已经过期,我们仍然将其加入黑名单,但设置较短的过期时间
		if errors.Is(err, jwt.ErrTokenExpired) {
			// 设置一个默认的过期时间,比如1小时
			return global.Redis.Set(
				context.Background(),
				TokenBlacklistPrefix+tokenString,
				"blacklisted",
				time.Hour,
			).Err()
		}
		return err
	}

	// 如果 token 有效,获取其过期时间
	if c, ok := claims.Claims.(*Claims); ok && claims.Valid {
		// 计算剩余有效期
		remainingTime := time.Until(c.ExpiresAt.Time)
		if remainingTime > 0 {
			expireTime = remainingTime
		}
	}

	// 将 token 加入黑名单,过期时间设为 token 的剩余有效期
	return global.Redis.Set(
		context.Background(),
		TokenBlacklistPrefix+tokenString,
		"blacklisted",
		expireTime,
	).Err()
}

// IsTokenBlacklisted 检查 token 是否在黑名单中
func IsTokenBlacklisted(tokenString string) (bool, error) {
	exists, err := global.Redis.Exists(context.Background(), TokenBlacklistPrefix+tokenString).Result()
	return exists > 0, err
}
3.增强JWT解析函数
go 复制代码
func ParseToken(tokenString string) (*Claims, error) {
	//1. 检查token是否在黑名单中
	isBlacklisted, err := IsTokenBlacklisted(tokenString)
	if err != nil {
		return nil, err
	}
	if isBlacklisted {
		return nil, errors.New("token has been blacklisted")
	}
	//2. 解析 token
	// ...
}
4.登出接口
go 复制代码
func Logout(c *gin.Context) {
	// 从 Authorization 头中获取 token
	authHeader := c.GetHeader("Authorization")
	if authHeader == "" {
		res.FailWithMessage(c, "Authorization header is required")
		return
	}

	// 检查 token 格式
	tokenParts := strings.Split(authHeader, " ")
	if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
		res.FailWithMessage(c, "Invalid authorization format")
		return
	}

	// 获取 access token
	tokenString := tokenParts[1]

	// 将 token 加入黑名单,设置过期时间为 access token 的有效期
	err := utils.BlacklistToken(tokenString, time.Duration(global.Config.JWT.AccessTokenExpire)*time.Hour)
	if err != nil {
		res.FailWithMessage(c, "Logout failed")
		return
	}
	// 处理 refresh token 也加入黑名单
	// ...

	res.Success(c, nil)
}
5.启动服务测试

四、总结

1 密钥管理

绝不硬编码:生产环境使用环境变量或密钥管理服务。

使用强密钥:至少32字节的随机字符串。

2 Token过期策略

设置合理的过期时间(如15分钟访问Token + 7天刷新Token)。

实现Token刷新机制。

3 防止Token滥用

HTTPS:始终通过HTTPS传输Token。

HttpOnly Cookie:对于Web应用,考虑将Token存储在HttpOnly Cookie中防止XSS攻击。

CORS:正确配置CORS策略。

4 处理Token注销

由于JWT是无状态的,实现注销需要额外机制:

短过期时间 + 刷新Token

Token黑名单:将已注销的Token存入Redis,每次验证时检查。

撤销列表

参考代码

gitee

相关推荐
古城小栈32 分钟前
Go 底层代码的完整分类
开发语言·后端·golang
耳冉鹅36 分钟前
Go无锁共享内存环形缓冲区设计
开发语言·golang
fy121631 天前
GO 快速升级Go版本
开发语言·redis·golang
童话ing1 天前
【Golang】Golang Map数据结构底层原理
数据结构·golang·哈希算法
GDAL1 天前
go.mod 文件讲解
golang·go.mod
Java面试题总结1 天前
Go图像处理基础: image包深度指南
图像处理·算法·golang
robch1 天前
golang container/heap 是一个为任意类型实现堆(优先队列)接口的包
数据结构·算法·golang
leonkay2 天前
Golang语言闭包完全指南
开发语言·数据结构·后端·算法·架构·golang
echome8882 天前
Go 语言并发编程实战:用 Goroutine 和 Channel 构建高性能任务调度器
开发语言·后端·golang
yuanlaile2 天前
2026后端趋势:Java 老了?Go 才是未来?
java·后端·golang·go与java·后端学什么