# JWT认证与授权:完整实现方案
摘要:JWT无状态认证完整实现,涵盖JWT结构原理、Go jwt库签发与验证、认证中间件、Token刷新机制,附时钟偏移踩坑经验。
团队从单体拆成微服务后,认证方案要重新设计。原来用Session存Redis,每个服务都要查一次Redis验证用户身份。微服务一多,Redis查询量翻了好几倍,每个服务还要配Redis连接,维护成本高。
后来改用JWT,服务端签发Token后不需要存储,每个服务自己验证签名就行,去掉了Redis依赖。但JWT也有它的问题,今天就聊聊完整的实现方案。
JWT结构原理
JWT由三部分组成,用点号分隔,格式是Header.Payload.Signature。Header指定签名算法,Payload存放用户数据,Signature是对前两部分的签名。三部分做Base64URL编码后拼接成最终字符串。
Payload只是Base64编码,任何人都能解码,千万别放密码这类敏感信息。
签发与验证
Go里用github.com/golang-jwt/jwt/v5库处理JWT。
packagemainimport("fmt""time""github.com/golang-jwt/jwt/v5")// JWT密钥,生产环境从配置读取varsecretKey=[]byte("my-secret-key-change-in-production")// Claims定义JWT的载荷结构typeClaimsstruct{UserIDint64`json:"user_id"`// 用户IDUsernamestring`json:"username"`// 用户名Rolestring`json:"role"`// 用户角色jwt.RegisteredClaims// 嵌入标准声明}// GenerateToken签发JWT TokenfuncGenerateToken(userIDint64,username,rolestring)(string,error){claims:=Claims{UserID:userID,Username:username,Role:role,RegisteredClaims:jwt.RegisteredClaims{ExpiresAt:jwt.NewNumericDate(time.Now().Add(2*time.Hour)),// 2小时过期IssuedAt:jwt.NewNumericDate(time.Now()),Issuer:"my-app",},}token:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims)returntoken.SignedString(secretKey)// 用密钥签名}// ParseToken验证并解析JWT TokenfuncParseToken(tokenStringstring)(*Claims,error){claims:=&Claims{}token,err:=jwt.ParseWithClaims(tokenString,claims,func(token*jwt.Token)(interface{},error){// 验证签名算法必须是HS256if_,ok:=token.Method.(*jwt.SigningMethodHMAC);!ok{returnnil,fmt.Errorf("意外的签名算法")}returnsecretKey,nil})iferr!=nil||!token.Valid{returnnil,fmt.Errorf("Token无效")}returnclaims,nil}认证中间件
JWT验证逻辑放在中间件里统一拦截,这里用Gin演示。
packagemainimport("net/http""strings""github.com/gin-gonic/gin")// AuthMiddleware 认证中间件funcAuthMiddleware()gin.HandlerFunc{returnfunc(c*gin.Context){// 从Authorization头获取TokenauthHeader:=c.GetHeader("Authorization")parts:=strings.SplitN(authHeader," ",2)iflen(parts)!=2||parts[0]!="Bearer"{c.AbortWithStatusJSON(http.StatusUnauthorized,gin.H{"error":"认证格式错误"})return}// 验证Tokenclaims,err:=ParseToken(parts[1])iferr!=nil{c.AbortWithStatusJSON(http.StatusUnauthorized,gin.H{"error":"Token无效或已过期"})return}// 用户信息存入Contextc.Set("user_id",claims.UserID)c.Set("role",claims.Role)c.Next()}}// AdminMiddleware 角色权限校验funcAdminMiddleware()gin.HandlerFunc{returnfunc(c*gin.Context){role,_:=c.Get("role")ifrole!="admin"{c.AbortWithStatusJSON(http.StatusForbidden,gin.H{"error":"需要管理员权限"})return}c.Next()}}funcmain(){r:=gin.Default()// 登录接口r.POST("/login",func(c*gin.Context){token,_:=GenerateToken(123,"zhangsan","admin")c.JSON(http.StatusOK,gin.H{"token":token})})// 认证路由组auth:=r.Group("/api").Use(AuthMiddleware()){auth.GET("/profile",func(c*gin.Context){c.JSON(http.StatusOK,gin.H{"user_id":c.GetInt64("user_id")})})// 管理员路由auth.Group("/admin").Use(AdminMiddleware()).GET("/users",func(c*gin.Context){c.JSON(http.StatusOK,gin.H{"msg":"用户列表"})})}r.Run(":8080")}Token刷新机制
JWT签发后无法撤回,泄露后过期前一直有效。方案是短有效期加Refresh Token。AccessToken 15分钟过期,RefreshToken 7天过期且只用于换取AccessToken。
// GenerateAccessToken 15分钟访问TokenfuncGenerateAccessToken(userIDint64,username,rolestring)(string,error){claims:=Claims{UserID:userID,Username:username,Role:role,RegisteredClaims:jwt.RegisteredClaims{ExpiresAt:jwt.NewNumericDate(time.Now().Add(15*time.Minute)),IssuedAt:jwt.NewNumericDate(time.Now()),},}returnjwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString(secretKey)}// GenerateRefreshToken 7天刷新Token,不携带业务数据funcGenerateRefreshToken(userIDint64)(string,error){claims:=jwt.RegisteredClaims{Subject:fmt.Sprintf("%d",userID),// 只存用户IDExpiresAt:jwt.NewNumericDate(time.Now().Add(7*24*time.Hour)),IssuedAt:jwt.NewNumericDate(time.Now()),}returnjwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString(secretKey)}AccessToken泄露最多影响15分钟,RefreshToken泄露可在服务端加黑名单使其失效。
独家踩坑:时钟偏移导致认证失败
上线后偶尔有用户反馈登录后马上Token过期,复现概率5%。怀疑前端问题,查了半天没发现异常。
报错请求都来自同一台服务器。有一台NTP同步出了问题,时钟快了90秒。JWT验证时当前时间超过exp就判过期,时钟快90秒Token就提前过期,有效期边界的请求就偶发性失败。
双管齐下,修NTP同步确保时钟一致,验证时加偏移容忍。
// 修复,允许60秒偏差funcParseTokenWithLeeway(tokenStringstring)(*Claims,error){claims:=&Claims{}// 通过Parser设置leeway容忍偏差parser:=jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}),jwt.WithLeeway(60*time.Second),// 允许60秒偏差)token,err:=parser.ParseWithClaims(tokenString,claims,func(t*jwt.Token)(interface{},error){returnsecretKey,nil})iferr!=nil||!token.Valid{returnnil,fmt.Errorf("Token无效")}returnclaims,nil}jwt.WithLeeway(60*time.Second)让时间检查容忍60秒偏差,生产环境建议设30到60秒。
对比分析
| 方案 | 状态存储 | 跨服务 | 撤回能力 | 性能 |
|---|---|---|---|---|
| Session+Redis | 服务端存储 | 需共享Redis | 强(直接删Session) | 中 |
| JWT | 无状态 | 天然支持 | 弱(需黑名单) | 高 |
| Session Cookie | 服务端存储 | 单域名 | 强 | 中 |
JWT最大的优势是无状态,每个服务独立验证Token不依赖共享存储,代价是撤回困难。Java Spring Security内置刷新和黑名单但配置复杂。Go的golang-jwt库更轻量,高级功能自己实现。
总结
JWT适合微服务无状态认证,核心三件事,签发、验证、刷新。时钟偏移这个坑很常见,生产环境配NTP同步加leeway。下一篇聊OAuth2.0登录。