b5ab92a57e
来自 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>
127 lines
3.9 KiB
Go
127 lines
3.9 KiB
Go
// 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 <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) }
|