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

相关推荐
zpf_叶绿体学编程20 小时前
一命速通:Go 语言操作 Office Excel 文档,从入门到实战解析
开发语言·golang·excel
星星点点洲1 天前
【Golang】数据设计模式
开发语言·设计模式·golang
ZNineSun1 天前
第二章:Java到Go的思维转变
java·golang
程序员爱钓鱼1 天前
Go语言实战案例-项目实战篇:编写一个轻量级在线聊天室
开发语言·后端·golang
数据知道1 天前
Go基础:Go语言中的指针详解:在什么情况下应该使用指针?
开发语言·后端·golang·指针·go语言
小羊在睡觉1 天前
Go语言爬虫:爬虫入门
数据库·后端·爬虫·golang·go
CAir21 天前
go引入自定义mod
开发语言·golang
T0uken1 天前
【Golang】Gin:静态服务与模板
开发语言·golang·gin
张童瑶1 天前
Linux Cent OS7离线安装Go环境(最新版本)
linux·运维·golang