Gin + JWT 认证实战——完整的登录/权限验证/Token 刷新实现
Gin + JWT 认证实战——完整的登录/权限验证/Token 刷新实现
适读人群:用Gin做Web后端、需要实现认证体系的工程师 | 阅读时长:约20分钟 | 核心价值:完整可用的JWT认证代码,直接可以用在项目里
一、阿强的「Token续期」困境
阿强在一家教育公司做Go后端,上线了一个Gin+JWT的认证系统。用户登录获取Token,请求时带上Token,一切顺利。
但很快有用户投诉:「我正在做题,突然说我没有登录了,得重新登录,题目进度全丢了!」
原因是Token设置了2小时有效期,用户做题时间长了Token过期,请求被拒绝。他想做无感知的Token刷新,但发现自己不知道怎么安全地实现Refresh Token机制,网上的文章说法各异,把他搞蒙了。
我帮他梳理了一套完整的方案,从登录到Token刷新,这篇完整复现。
二、JWT的基本原理
JWT(JSON Web Token)由三部分组成:
Header.Payload.Signature- Header:算法类型(HS256等)
- Payload:Claims(用户ID、角色、过期时间等)
- Signature:用密钥对Header+Payload做的签名,防篡改
Java对比: Java常用 io.jsonwebtoken:jjwt 库,Go常用 golang-jwt/jwt,概念完全相同,API略有差异。
JWT的关键特性:
- 无状态:服务器不存储Session,Token本身携带所有信息
- 自验证:服务器只需要密钥就能验证Token有效性
- 不可撤销:Token有效期内无法主动失效(除非配合黑名单)
三、完整实现:登录 + 访问Token + 刷新Token
3.1 项目依赖
go get github.com/gin-gonic/gin
go get github.com/golang-jwt/jwt/v53.2 Token工具包
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// 实际项目中从配置读取,不要硬编码
var (
accessTokenSecret = []byte("access_secret_change_in_production")
refreshTokenSecret = []byte("refresh_secret_change_in_production")
)
const (
AccessTokenDuration = 2 * time.Hour
RefreshTokenDuration = 7 * 24 * time.Hour
)
type AccessClaims struct {
UserID int64 `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type RefreshClaims struct {
UserID int64 `json:"user_id"`
jwt.RegisteredClaims
}
// GenerateAccessToken 生成访问Token
func GenerateAccessToken(userID int64, role string) (string, error) {
claims := AccessClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "laozhang-app",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(accessTokenSecret)
}
// GenerateRefreshToken 生成刷新Token(有效期更长)
func GenerateRefreshToken(userID int64) (string, error) {
claims := RefreshClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "laozhang-app",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(refreshTokenSecret)
}
// ParseAccessToken 解析并验证访问Token
func ParseAccessToken(tokenStr string) (*AccessClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &AccessClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return accessTokenSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*AccessClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
// ParseRefreshToken 解析并验证刷新Token
func ParseRefreshToken(tokenStr string) (*RefreshClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &RefreshClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return refreshTokenSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*RefreshClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}3.3 Handler实现
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"your-project/auth"
)
// 模拟用户数据(实际项目查数据库)
type UserInfo struct {
ID int64
Username string
Password string // 实际应存哈希值
Role string
}
var mockUsers = map[string]*UserInfo{
"laowang": {ID: 1, Username: "laowang", Password: "123456", Role: "admin"},
"laoli": {ID: 2, Username: "laoli", Password: "123456", Role: "user"},
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // 秒
}
// Login 登录接口
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
user, exists := mockUsers[req.Username]
if !exists || user.Password != req.Password {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "用户名或密码错误"})
return
}
accessToken, err := auth.GenerateAccessToken(user.ID, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成Token失败"})
return
}
refreshToken, err := auth.GenerateRefreshToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成RefreshToken失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "登录成功",
"data": TokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(auth.AccessTokenDuration.Seconds()),
},
})
}
// RefreshToken 刷新Token接口
func RefreshToken(c *gin.Context) {
refreshTokenStr := c.GetHeader("X-Refresh-Token")
if refreshTokenStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少Refresh Token"})
return
}
claims, err := auth.ParseRefreshToken(refreshTokenStr)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "Refresh Token无效或已过期,请重新登录"})
return
}
// 根据userID重新查询用户(权限可能变化)
var role string
for _, u := range mockUsers {
if u.ID == claims.UserID {
role = u.Role
break
}
}
newAccessToken, err := auth.GenerateAccessToken(claims.UserID, role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成Token失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "Token刷新成功",
"data": gin.H{
"access_token": newAccessToken,
"expires_in": int(auth.AccessTokenDuration.Seconds()),
},
})
}
// GetProfile 受保护的接口示例
func GetProfile(c *gin.Context) {
userID, _ := c.Get("userID")
role, _ := c.Get("userRole")
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": gin.H{
"user_id": userID,
"role": role,
},
})
}3.4 认证中间件
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"your-project/auth"
)
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "请先登录",
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "Authorization格式应为 Bearer <token>",
})
return
}
claims, err := auth.ParseAccessToken(parts[1])
if err != nil {
// Token过期:提示客户端用RefreshToken换新Token
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 4011, // 特殊code,客户端据此触发Token刷新
"message": "Token已过期,请刷新Token",
})
return
}
c.Set("userID", claims.UserID)
c.Set("userRole", claims.Role)
c.Next()
}
}
// RequireRole 角色权限验证中间件
func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("userRole")
if !exists {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": 403,
"message": "无权限",
})
return
}
role := userRole.(string)
for _, r := range roles {
if r == role {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": 403,
"message": "角色权限不足",
})
}
}3.5 路由配置
package main
import (
"github.com/gin-gonic/gin"
"your-project/handler"
"your-project/middleware"
)
func setupRouter() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery(), gin.Logger())
// 公开接口
r.POST("/api/login", handler.Login)
r.POST("/api/refresh", handler.RefreshToken)
// 需要登录的接口
authenticated := r.Group("/api")
authenticated.Use(middleware.JWTAuth())
{
authenticated.GET("/profile", handler.GetProfile)
// 普通用户和管理员都能访问
authenticated.GET("/articles", handler.ListArticles)
}
// 需要管理员角色
adminGroup := authenticated.Group("/admin")
adminGroup.Use(middleware.RequireRole("admin"))
{
adminGroup.GET("/users", handler.AdminListUsers)
adminGroup.DELETE("/users/:id", handler.AdminDeleteUser)
}
return r
}
func main() {
r := setupRouter()
r.Run(":8080")
}四、踩坑实录
坑1:Token密钥硬编码在代码里
现象: 代码里写着 var secret = []byte("mySecret123"),代码开源或者内网泄漏,所有Token都可以伪造。
解法: 从环境变量或配置中心读取密钥,且密钥长度建议32字节以上:
import (
"os"
"crypto/rand"
"encoding/base64"
)
func getSecret() []byte {
secret := os.Getenv("JWT_ACCESS_SECRET")
if secret == "" {
panic("JWT_ACCESS_SECRET环境变量未设置")
}
return []byte(secret)
}
// 生成随机密钥(运行一次存起来)
func generateSecret() string {
b := make([]byte, 32)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}坑2:RefreshToken和AccessToken用同一个密钥
现象: 有人拿到AccessToken,强行当RefreshToken用,绕过刷新逻辑。
解法: AccessToken和RefreshToken使用不同密钥,或者在Claims里加type字段区分。
坑3:RefreshToken存在客户端localStorage,被XSS盗取
现象: 前端把RefreshToken存在localStorage,XSS漏洞导致RefreshToken泄露,攻击者可以长期维持访问权限。
解法: RefreshToken应存在HttpOnly的Cookie里(JS无法读取),或者存服务端数据库并绑定IP/设备指纹。
五、Token黑名单:实现主动注销
JWT最大的问题是「无法主动失效」,退出登录后Token在有效期内仍然可用。
解决方案:用Redis维护Token黑名单。
package auth
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type TokenBlacklist struct {
rdb *redis.Client
}
func NewTokenBlacklist(rdb *redis.Client) *TokenBlacklist {
return &TokenBlacklist{rdb: rdb}
}
// Revoke 将Token加入黑名单(退出登录时调用)
func (tb *TokenBlacklist) Revoke(ctx context.Context, tokenStr string, expiry time.Duration) error {
key := "blacklist:" + tokenStr[:16] // 只存前16字符作为key(防止key太长)
return tb.rdb.Set(ctx, key, tokenStr, expiry).Err()
}
// IsRevoked 检查Token是否在黑名单中
func (tb *TokenBlacklist) IsRevoked(ctx context.Context, tokenStr string) bool {
key := "blacklist:" + tokenStr[:16]
result := tb.rdb.Get(ctx, key)
return result.Err() == nil
}在JWTAuth中间件里加上黑名单检查:
if blacklist.IsRevoked(c.Request.Context(), parts[1]) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": 401, "message": "Token已被注销",
})
return
}六、Java Spring Security vs Gin+JWT 对比
| 功能 | Spring Security | Gin + 手写 |
|---|---|---|
| 认证配置 | 注解+配置类 | 中间件函数 |
| Token生成 | 内置或jjwt | golang-jwt |
| 路由权限 | @PreAuthorize | RequireRole中间件 |
| Session管理 | 内置 | Redis黑名单(手动) |
| 代码量 | 少(框架做了很多) | 多(但完全透明可控) |
| 学习曲线 | 高(黑箱) | 中(逻辑清晰) |
七、总结
完整的JWT认证体系需要:
- 登录接口:验证用户名密码,返回AccessToken + RefreshToken
- 认证中间件:验证AccessToken,失败时返回特殊code提示刷新
- 刷新接口:用RefreshToken换新AccessToken
- 角色中间件:在认证之后做细粒度权限控制
- 黑名单:Redis存已注销的Token,支持主动退出
阿强按这套方案改完之后,Token刷新完全无感知,用户做了4个小时题都没被踢出去,投诉消失了。
