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>
This commit is contained in:
@@ -131,8 +131,16 @@ func (h *Handlers) MobileLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Logout POST /v1/auth/logout(需登录)
|
||||
// 拉黑到 token 真实自然过期时间(中间件解析后存于 CtxExpires),确保即便
|
||||
// JWT_TTL_HOURS 配得很大,注销 token 也不会在固定窗口后复活(17B)。
|
||||
func (h *Handlers) Logout(c *gin.Context) {
|
||||
jti := c.GetString(CtxJTI)
|
||||
_ = h.JWT.Revoke(c, jti, time.Now().Add(8*24*time.Hour)) // 覆盖最长 TTL
|
||||
exp, _ := c.Get(CtxExpires)
|
||||
expiresAt, _ := exp.(time.Time)
|
||||
if expiresAt.IsZero() {
|
||||
// 兜底:claims 未带 exp(理论上不会发生),按当前配置 TTL 估一个上界。
|
||||
expiresAt = time.Now().Add(time.Duration(h.JWT.ttl))
|
||||
}
|
||||
_ = h.JWT.Revoke(c, jti, expiresAt)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,21 +42,32 @@ func (j *JWT) Sign(userID string) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(j.secret)
|
||||
}
|
||||
|
||||
// Parse 校验签名、过期与黑名单,返回 userID 与 jti。
|
||||
func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, err error) {
|
||||
// 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 "", "", err
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
if j.rdb != nil {
|
||||
if n, _ := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result(); n == 1 {
|
||||
return "", "", jwt.ErrTokenExpired
|
||||
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
|
||||
}
|
||||
}
|
||||
return claims.Subject, claims.ID, nil
|
||||
if claims.ExpiresAt != nil {
|
||||
expiresAt = claims.ExpiresAt.Time
|
||||
}
|
||||
return claims.Subject, claims.ID, expiresAt, nil
|
||||
}
|
||||
|
||||
// Revoke 将 jti 拉黑至 token 自然过期(logout / 踢出)。
|
||||
@@ -68,8 +80,9 @@ func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error
|
||||
}
|
||||
|
||||
const (
|
||||
CtxUserID = "auth.user_id"
|
||||
CtxJTI = "auth.jti"
|
||||
CtxUserID = "auth.user_id"
|
||||
CtxJTI = "auth.jti"
|
||||
CtxExpires = "auth.expires_at" // token 自然过期时间(time.Time),供 Logout 拉黑到期用
|
||||
)
|
||||
|
||||
// Middleware gin 鉴权中间件:Authorization: Bearer <JWT>。
|
||||
@@ -80,13 +93,31 @@ func (j *JWT) Middleware() gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
|
||||
return
|
||||
}
|
||||
uid, jti, err := j.Parse(c.Request.Context(), token)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,46 @@ func TestSignParseRevoke(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uid, jti, err := j.Parse(ctx, token)
|
||||
uid, jti, exp, err := j.Parse(ctx, token)
|
||||
if err != nil || uid != "u1" || jti == "" {
|
||||
t.Fatalf("parse failed: uid=%s jti=%s err=%v", uid, jti, err)
|
||||
}
|
||||
if exp.IsZero() || time.Until(exp) <= 0 {
|
||||
t.Fatalf("expiresAt should be in the future, got %v", exp)
|
||||
}
|
||||
|
||||
// 拉黑后解析失败
|
||||
if err := j.Revoke(ctx, jti, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := j.Parse(ctx, token); err == nil {
|
||||
if _, _, _, err := j.Parse(ctx, token); err == nil {
|
||||
t.Fatal("expect parse failure after revoke")
|
||||
}
|
||||
|
||||
// 篡改签名失败
|
||||
if _, _, err := j.Parse(ctx, token+"x"); err == nil {
|
||||
if _, _, _, err := j.Parse(ctx, token+"x"); err == nil {
|
||||
t.Fatal("expect parse failure on tampered token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFailClosed Redis 不可用时黑名单检查应 fail-closed:拒绝放行(17A)。
|
||||
func TestParseFailClosed(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
j := NewJWT("test-secret", time.Hour, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := j.Sign("u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 正常情况下放行
|
||||
if _, _, _, err := j.Parse(ctx, token); err != nil {
|
||||
t.Fatalf("parse should pass while redis up: %v", err)
|
||||
}
|
||||
// Redis 故障:Exists 返回 error → 必须拒绝(不得 fail-open)。
|
||||
mr.Close()
|
||||
if _, _, _, err := j.Parse(ctx, token); err == nil {
|
||||
t.Fatal("expect parse failure when redis is unavailable (fail-closed)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user