Skip to content

security(jwt): distribute JWT blacklist via Redis (L1 BlackCache + L2…#2198

Open
axin8999-droid wants to merge 1 commit intoflipped-aurora:mainfrom
axin8999-droid:feat/jwt-distributed-blacklist
Open

security(jwt): distribute JWT blacklist via Redis (L1 BlackCache + L2…#2198
axin8999-droid wants to merge 1 commit intoflipped-aurora:mainfrom
axin8999-droid:feat/jwt-distributed-blacklist

Conversation

@axin8999-droid
Copy link
Copy Markdown

… Redis)

Problem
JwtService only writes revoked tokens to global.BlackCache (a process-local
sync.Map wrapper). In any multi-replica deployment (horizontal scaling,
blue-green rollout, k8s with replicas > 1), a token revoked on instance A
remains valid on instance B until that instance's next LoadAll(). For an
attacker who stole a token and is being kicked out, the eviction is
effectively racy — the revocation is only reliable on a single node.

Fix
Layered lookup with DB as source of truth:
- L1: global.BlackCache (unchanged, process-local, zero IO).
- L2: Redis (new) — SHA-256-keyed, TTL derived from token exp.
- SoT: DB (unchanged) — LoadAll() on boot still rehydrates L1 and now also fires an async HydrateRedisBlacklistFromDB so a fresh node catches up to the other replicas immediately.

New JwtService.IsInBlacklist(ctx, token):
- L1 hit → true
- L1 miss, L2 hit → backfill L1, return true
- Redis down → fail-open (false), degrading to legacy
single-instance behaviour; never breaks auth.

JsonInBlacklist now writes DB + Redis + BlackCache. Redis write failure
only logs a warn — DB has persisted the revocation, so any node that
reads through (LoadAll / Hydrate) will reconcile.

Graceful degradation (required for projects that don't use Redis)
Everything gates on global.GVA_REDIS == nil, which is exactly how GVA
signals "Redis not configured". In that mode:
- SetJWTBlacklistRedis returns nil (no-op).
- IsJWTBlacklistedRedis returns ErrBlacklistRedisUnavailable.
- IsInBlacklist falls back to BlackCache-only,
identical to pre-patch behaviour.
No config changes are required; existing single-node deployments see
zero behavioural difference.

middleware/jwt.go
isBlacklist(token) → isBlacklist(c, token). The *gin.Context is
threaded purely so the Redis lookup honours request cancellation /
timeout. The function now delegates to JwtService.IsInBlacklist.

Redis key & TTL design
Key: "gva:jwt:blacklist:" + sha256(token) (fixed 64-hex, avoids
putting 2KB+ tokens on the hot key path).
TTL: derived from the token's own exp claim (ParseUnverified — we're
not re-checking the signature, just reading exp). Default 24h
when unparseable, 1s when already expired (just wide enough to
defeat concurrent replays), 30d hard ceiling.
Timeouts: 200ms read, 500ms write — tight enough that a sick Redis
can't stall the auth middleware.

Tests
New package tests using alicebob/miniredis/v2:
- TTL derivation (normal / unparseable / already-expired tokens).
- Set/Is round-trip through Redis.
- IsJWTBlacklistedRedis returns ErrBlacklistRedisUnavailable when global.GVA_REDIS is nil.
- IsInBlacklist L1/L2 layering + L1 backfill from L2 hits.
- IsInBlacklist fail-open when Redis is down.
- HydrateRedisBlacklistFromDB batch behaviour. All 8 tests pass under -race.

Files

  • server/service/system/jwt_black_list_redis.go (new)
  • server/service/system/jwt_black_list_redis_test.go (new)
    M server/service/system/jwt_black_list.go
    M server/middleware/jwt.go
    M server/go.mod, server/go.sum (miniredis test dep)

No schema changes, no config changes, no API changes.

… Redis)

Problem
  JwtService only writes revoked tokens to global.BlackCache (a process-local
  sync.Map wrapper). In any multi-replica deployment (horizontal scaling,
  blue-green rollout, k8s with replicas > 1), a token revoked on instance A
  remains valid on instance B until that instance's next LoadAll(). For an
  attacker who stole a token and is being kicked out, the eviction is
  effectively racy — the revocation is only reliable on a single node.

Fix
  Layered lookup with DB as source of truth:
    - L1: global.BlackCache (unchanged, process-local, zero IO).
    - L2: Redis (new) — SHA-256-keyed, TTL derived from token exp.
    - SoT: DB (unchanged) — LoadAll() on boot still rehydrates L1 and
           now also fires an async HydrateRedisBlacklistFromDB so a
           fresh node catches up to the other replicas immediately.

  New JwtService.IsInBlacklist(ctx, token):
    - L1 hit          → true
    - L1 miss, L2 hit → backfill L1, return true
    - Redis down      → fail-open (false), degrading to legacy
                        single-instance behaviour; never breaks auth.

  JsonInBlacklist now writes DB + Redis + BlackCache. Redis write failure
  only logs a warn — DB has persisted the revocation, so any node that
  reads through (LoadAll / Hydrate) will reconcile.

Graceful degradation (required for projects that don't use Redis)
  Everything gates on `global.GVA_REDIS == nil`, which is exactly how GVA
  signals "Redis not configured". In that mode:
    - SetJWTBlacklistRedis       returns nil (no-op).
    - IsJWTBlacklistedRedis      returns ErrBlacklistRedisUnavailable.
    - IsInBlacklist              falls back to BlackCache-only,
                                  identical to pre-patch behaviour.
  No config changes are required; existing single-node deployments see
  zero behavioural difference.

middleware/jwt.go
  isBlacklist(token) → isBlacklist(c, token). The *gin.Context is
  threaded purely so the Redis lookup honours request cancellation /
  timeout. The function now delegates to JwtService.IsInBlacklist.

Redis key & TTL design
  Key:  "gva:jwt:blacklist:" + sha256(token)  (fixed 64-hex, avoids
        putting 2KB+ tokens on the hot key path).
  TTL:  derived from the token's own exp claim (ParseUnverified — we're
        not re-checking the signature, just reading exp). Default 24h
        when unparseable, 1s when already expired (just wide enough to
        defeat concurrent replays), 30d hard ceiling.
  Timeouts: 200ms read, 500ms write — tight enough that a sick Redis
  can't stall the auth middleware.

Tests
  New package tests using alicebob/miniredis/v2:
    - TTL derivation (normal / unparseable / already-expired tokens).
    - Set/Is round-trip through Redis.
    - IsJWTBlacklistedRedis returns ErrBlacklistRedisUnavailable when
      global.GVA_REDIS is nil.
    - IsInBlacklist L1/L2 layering + L1 backfill from L2 hits.
    - IsInBlacklist fail-open when Redis is down.
    - HydrateRedisBlacklistFromDB batch behaviour.
  All 8 tests pass under -race.

Files
  + server/service/system/jwt_black_list_redis.go       (new)
  + server/service/system/jwt_black_list_redis_test.go  (new)
  M server/service/system/jwt_black_list.go
  M server/middleware/jwt.go
  M server/go.mod, server/go.sum  (miniredis test dep)

No schema changes, no config changes, no API changes.
@pixelmaxQm
Copy link
Copy Markdown
Collaborator

@copilot 这个pr中存在较多ai注释 剔除掉ai注释

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants