Files
wangjia b5ab92a57e
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s
fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端:
- server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、
  billing 行锁、redis Lua 过期与设备槽刷新、config 解析
- desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询
- android:握手时序、请求头封装、账户状态派生、按需重组
- ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:50:08 +08:00

127 lines
3.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package auth JWT 签发/校验 + jti 黑名单。微信 OAuth 在 wechat.go5B/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-closedRedis 不可用(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 <JWT>。
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) }