在微服务和前后端分离架构下,接口鉴权不能依赖服务端Session,JWT(JSON Web Token)以紧凑的JSON格式携带用户身份和授权信息,成为Go后端常用的鉴权方案。Go生态中有多个JWT库,其中github.com/golang-jwt/jwt是jwt-go的官方维护续作,API稳定、功能完善。本文会从JWT的结构讲起,给出生成和解析令牌的Go代码,再配合Gin框架实现中间件,最后讨论刷新令牌和安全细节。

JWT的结构与签名算法选择
JWT由三部分组成:头部、载荷和签名,它们用英文句点分隔,形如xxxxx.yyyyy.zzzzz。头部通常声明签名算法和令牌类型,载荷携带自定义声明和注册声明,签名则把编码后的头部与载荷用密钥计算出来,防止篡改。Go中处理JWT时,需要先定义与载荷对应的结构体,再调用库完成序列化和签名。
签名算法方面,HS256基于HMAC-SHA256,使用同一个密钥进行签名和验证,适合内部服务之间通信;RS256基于RSA非对称加密,私钥签发、公钥验证,适合需要对第三方签发或分布式验证的场景。实际项目里如果只需要内部API鉴权,HS256实现简单、性能更好;如果令牌需要由身份服务签发给多个消费者,应该选择RS256或ES256。本文示例采用HS256,换成RS256只需调整SigningMethod和密钥类型。
需要特别提醒,不要在载荷中存放密码、身份证号等敏感信息,因为JWT的载荷只是Base64Url编码,并非加密,任何人都能解码查看。敏感数据应当在服务端保存或加密后再放入令牌。
在Go中生成与解析JWT
先安装依赖:
go get github.com/golang-jwt/jwt/v5
定义一个Claims结构体,嵌入jwt.RegisteredClaims来获得exp、iss、sub等标准声明,同时增加UserID和Username两个业务字段。结构体字段名使用json标签指定序列化后的名称,这样可以保持JWT载荷的可读性。
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
生成令牌时,先构造Claims,设置过期时间、签发时间和生效时间,然后调用jwt.NewWithClaims选择HS256算法,再通过SignedString方法得到最终字符串。过期时间建议根据业务设置,普通访问令牌通常控制在15分钟到2小时之间。
func GenerateToken(userID int64, username string, secret []byte, expire time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "go-jwt-demo",
Subject: "user-auth",
Audience: []string{"web"},
ExpiresAt: jwt.NewNumericDate(now.Add(expire)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
解析令牌时,使用jwt.ParseWithClaims并传入Claims指针和一个回调函数。回调函数负责返回密钥,同时要做签名方法校验,防止攻击者把RS256换成HS256用公钥作为密钥来伪造令牌。golang-jwt库默认会拒绝alg为none的令牌,但显式检查签名方法仍然是好习惯。
func ParseToken(tokenString string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token claims")
}
这里只返回了解析后的Claims,调用方可以把UserID写入请求上下文,后续业务处理不再重复解析。
集成Gin中间件实现接口鉴权
Web框架里通常以中间件形式拦截请求,检查Authorization头并提取Bearer令牌。Gin的中间件模型很直观:先取头部,再按空格拆分,验证前缀,最后调用ParseToken。任何一步失败都直接返回401状态码,并终止后续处理。
func AuthMiddleware(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
tokenString := parts[1]
claims, err := ParseToken(tokenString, secret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Next()
}
}
使用中间件时,可以为需要保护的路由组统一挂载。下面的示例保护所有/api路径,登录接口应当放在中间件之外,否则用户拿不到令牌就无法调用后续接口。
func main() {
r := gin.Default()
secret := []byte("change-this-secret")
authorized := r.Group("/api")
authorized.Use(AuthMiddleware(secret))
authorized.GET("/profile", func(c *gin.Context) {
userID := c.GetInt64("user_id")
username := c.GetString("username")
c.JSON(http.StatusOK, gin.H{"user_id": userID, "username": username})
})
r.Run(":8080")
}
中间件校验通过后,业务处理器通过c.GetInt64和c.GetString拿到用户信息,不必再次解析JWT。如果希望更严格,可以在中间件里增加IP绑定、User-Agent校验或自定义声明检查。比如要求token中必须包含某个角色声明,否则返回403。
除了Gin,标准库net/http也可以实现同样逻辑,核心的解析部分完全复用,只要包装成http.Handler即可。中间件的价值在于把鉴权逻辑从业务代码中剥离,避免在每个接口里重复判断。
刷新令牌与安全实践
如果访问令牌有效期设得很长,一旦泄露会造成较长时间的暴露;如果设得太短,用户又需要频繁重新登录。更好的做法是引入刷新令牌:访问令牌用于调用接口,生命周期短;刷新令牌用于获取新的访问令牌,生命周期长,并且只在刷新接口使用。
func GenerateRefreshToken(userID int64, secret []byte) (string, error) {
claims := jwt.RegisteredClaims{
Subject: "refresh-token",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
刷新令牌的载荷可以只包含用户标识,但服务端最好在数据库或缓存中保存刷新令牌的哈希,以便主动撤销。当用户退出登录时,删除对应的刷新令牌,即使令牌未过期也无法再换取访问令牌。访问令牌本身无法撤销,因此保持短有效期非常重要。
安全实践中,密钥一定不能硬编码在代码库,应当从环境变量或配置中心读取,并且定期轮换。日志中不要输出完整令牌,错误信息也要避免把令牌回显给客户端。启用HTTPS可以防止令牌在传输过程中被中间人截获。对于算法混淆攻击,解析时务必校验token.Method的类型,只接受预期的签名算法。
常见问题还包括:把JWT当作加密数据存储敏感信息、不校验nbf与exp、使用弱密钥、依赖客户端删除令牌来实现登出。只要把握住签名验证、过期校验、密钥保护和刷新令牌分离这几个原则,Go服务中的JWT鉴权就能达到生产可用的安全水平。