// Package auth JWT 签发/校验 + jti 黑名单。微信 OAuth 在 wechat.go(5B/5C)。 package auth import ( "context" "log/slog" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/redis/go-redis/v9" "dudu/server/internal/store" "dudu/server/pkg/protocol" ) type JWT struct { secret []byte ttl time.Duration rdb *redis.Client } func NewJWT(secret string, ttl time.Duration, rdb *redis.Client) *JWT { return &JWT{secret: []byte(secret), ttl: ttl, rdb: rdb} } type Claims struct { jwt.RegisteredClaims } func (j *JWT) Sign(userID string) (string, error) { now := time.Now() claims := Claims{RegisteredClaims: jwt.RegisteredClaims{ Subject: userID, ID: uuid.NewString(), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)), }} return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(j.secret) } // Parse 校验签名、过期与黑名单,返回 userID、jti 与 token 自然过期时间。 // 黑名单检查 fail-closed:Redis 不可用(Exists 返回 error)时拒绝放行, // 宁可误拒也不能让已登出/被踢出的 token 在 Redis 故障窗口复活(17A)。 func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, expiresAt time.Time, err error) { var claims Claims _, err = jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) { return j.secret, nil }, jwt.WithValidMethods([]string{"HS256"})) if err != nil { return "", "", time.Time{}, err } if j.rdb != nil { n, exErr := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result() if exErr != nil { // fail-closed:无法确认黑名单状态时拒绝,避免故障期放行被撤销的 token。 slog.Warn("jwt blacklist check failed, rejecting (fail-closed)", "jti", claims.ID, "err", exErr) return "", "", time.Time{}, exErr } if n == 1 { return "", "", time.Time{}, jwt.ErrTokenExpired } } if claims.ExpiresAt != nil { expiresAt = claims.ExpiresAt.Time } return claims.Subject, claims.ID, expiresAt, nil } // Revoke 将 jti 拉黑至 token 自然过期(logout / 踢出)。 func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error { ttl := time.Until(expiresAt) if ttl <= 0 { return nil } return j.rdb.Set(ctx, store.KeyJwtBlock(jti), 1, ttl).Err() } const ( CtxUserID = "auth.user_id" CtxJTI = "auth.jti" CtxExpires = "auth.expires_at" // token 自然过期时间(time.Time),供 Logout 拉黑到期用 ) // Middleware gin 鉴权中间件:Authorization: Bearer 。 func (j *JWT) Middleware() gin.HandlerFunc { return func(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized)) return } uid, jti, exp, err := j.Parse(c.Request.Context(), token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized)) return } c.Set(CtxUserID, uid) c.Set(CtxJTI, jti) c.Set(CtxExpires, exp) c.Next() } } // OptionalMiddleware 可选鉴权(17F):带合法 Authorization 时注入 user_id; // 无 token 或解析失败(含 Redis 故障导致的 fail-closed)一律放行匿名,不拦截。 // 用于 /v1/metrics/batch 等既接受匿名打点、又希望登录用户能关联 user_id 的端点。 func (j *JWT) OptionalMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if token != "" { if uid, jti, exp, err := j.Parse(c.Request.Context(), token); err == nil { c.Set(CtxUserID, uid) c.Set(CtxJTI, jti) c.Set(CtxExpires, exp) } } c.Next() } } // UserID 从 gin 上下文取当前用户。 func UserID(c *gin.Context) string { return c.GetString(CtxUserID) }