security(jwt): distribute JWT blacklist via Redis (L1 BlackCache + L2…#2198
Open
axin8999-droid wants to merge 1 commit intoflipped-aurora:mainfrom
Open
security(jwt): distribute JWT blacklist via Redis (L1 BlackCache + L2…#2198axin8999-droid wants to merge 1 commit intoflipped-aurora:mainfrom
axin8999-droid wants to merge 1 commit intoflipped-aurora:mainfrom
Conversation
… 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.
Collaborator
|
@copilot 这个pr中存在较多ai注释 剔除掉ai注释 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
… 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 GVAsignals "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
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.