Files
sundynix-micro-be/app/zero-gateway/internal/middleware/auth.go
T

143 lines
4.1 KiB
Go

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,
})
}