package middleware import ( "encoding/json" "net/http" "strings" "time" jwtUtil "sundynix-micro-go/common/utils/jwt" jwtv5 "github.com/golang-jwt/jwt/v5" "github.com/zeromicro/go-zero/core/logx" ) // RefreshTokenHeader 续期后新 Token 放在此响应头里,前端读取后静默替换 const RefreshTokenHeader = "X-Refresh-Token" // AuthMiddleware 网关鉴权 + 自动续期中间件 type AuthMiddleware struct { jwtSecret string whitelist map[string]bool } func NewAuthMiddleware(jwtSecret string, whitelist []string) *AuthMiddleware { wl := make(map[string]bool, len(whitelist)) for _, p := range whitelist { wl[p] = true } return &AuthMiddleware{ jwtSecret: jwtSecret, whitelist: wl, } } func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // OPTIONS 预检直接放行 if r.Method == http.MethodOptions { next(w, r) return } // 白名单路径放行(支持精确匹配和 /* 前缀通配) if m.isWhitelisted(r.URL.Path) { next(w, r) return } // 解析 Authorization 头 authHeader := r.Header.Get("Authorization") if authHeader == "" { writeUnauthorized(w, "缺少 Authorization 请求头") return } tokenStr := jwtUtil.GetTokenFromHeader(authHeader) if tokenStr == "" { writeUnauthorized(w, "Token 格式错误") return } j := jwtUtil.NewJWT(m.jwtSecret) claims, err := j.ParseToken(tokenStr) if err != nil { logx.Infof("[zero-gateway] JWT 解析失败: %v, path: %s", err, r.URL.Path) writeUnauthorized(w, err.Error()) return } // 将用户信息透传到上游,避免上游重复解析 JWT r.Header.Set("X-User-Id", claims.BaseClaims.ID) r.Header.Set("X-User-Account", claims.BaseClaims.Account) // ---- 滑动窗口续期 ---- // 剩余有效时间 < BufferTime(存储在 token claims 里),说明进入缓冲窗口 if newToken, ok := m.tryRefresh(j, claims); ok { // 在响应头写入新 Token,前端收到后静默替换本地存储的 Token w.Header().Set(RefreshTokenHeader, newToken) logx.Infof("[zero-gateway] Token 已续期, userId: %s", claims.BaseClaims.ID) } next(w, r) } } // tryRefresh 判断是否需要续期,需要则签发新 Token 并返回 // 续期规则:剩余有效时间 < BufferTime → 以原始有效时长(ExpiresAt - NotBefore)重新签发 func (m *AuthMiddleware) tryRefresh(j *jwtUtil.JWT, claims *jwtUtil.CustomClaims) (string, bool) { bufferTime := time.Duration(claims.BufferTime) * time.Second expiresAt := claims.RegisteredClaims.ExpiresAt.Time remaining := time.Until(expiresAt) // 未进入缓冲窗口,无需续期 if remaining >= bufferTime { return "", false } // 计算原始有效时长:ExpiresAt - NotBefore ≈ 当初登录时配置的 activeTimeout notBefore := claims.RegisteredClaims.NotBefore.Time originalDuration := expiresAt.Sub(notBefore) // 构建新 Claims,保持 BaseClaims 和 BufferTime 不变,重新计算有效期 newClaims := jwtUtil.CustomClaims{ BaseClaims: claims.BaseClaims, BufferTime: claims.BufferTime, RegisteredClaims: jwtv5.RegisteredClaims{ Audience: claims.RegisteredClaims.Audience, Issuer: claims.RegisteredClaims.Issuer, NotBefore: jwtv5.NewNumericDate(time.Now()), ExpiresAt: jwtv5.NewNumericDate(time.Now().Add(originalDuration)), }, } newToken, err := j.CreateToken(newClaims) if err != nil { logx.Errorf("[zero-gateway] Token 续期失败: %v", err) return "", false } return newToken, true } // isWhitelisted 支持精确匹配和 /* 前缀通配 func (m *AuthMiddleware) isWhitelisted(path string) bool { if m.whitelist[path] { return true } for p := range m.whitelist { if strings.HasSuffix(p, "/*") && strings.HasPrefix(path, strings.TrimSuffix(p, "*")) { return true } } return false } // writeUnauthorized 返回统一的 401 响应 func writeUnauthorized(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]interface{}{ "code": 401, "msg": msg, }) }