前言
在现代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,每次验证时检查。
撤销列表