feat(login-guard): unlock entire scope and report deleted keys
Frontend CI / Frontend (push) Successful in 3m13s
Backend CI / Backend (push) Successful in 15m21s

Admin login-lock reset now SCANs and clears all auth:login:* keys (any
scope) and surfaces redis_deleted_keys / fallback_deleted_keys back to
ops-web so the operator can tell whether the cache was actually hit.
Also tracks paired keys via a per-identifier index plus an unlock marker
so legacy/in-flight pair locks get cleared on the next acquire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 20:56:38 +08:00
parent 22d0cd63a1
commit bbfeabdaa5
5 changed files with 494 additions and 39 deletions
+280 -18
View File
@@ -99,6 +99,14 @@ type LoginGuard struct {
fallback *memoryLoginGuard
}
type LoginGuardUnlockResult struct {
RedisDeletedKeys int64
RedisScanPatterns []string
RedisAddr string
RedisDB int
FallbackDeletedKeys int
}
func NewLoginGuard(rdb *redis.Client, cfg LoginGuardConfig) *LoginGuard {
cfg = normalizeLoginGuardConfig(cfg)
return &LoginGuard{
@@ -135,6 +143,7 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
return g.acquireFallback(keys, token)
}
pairIndexTTL := g.pairIndexTTL()
result, err := loginGuardAcquireScript.Run(
ctx,
g.rdb,
@@ -145,6 +154,10 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
keys.requestIP,
keys.requestIdentifier,
keys.requestPair,
keys.pairIndex,
keys.unlockIdentifier,
keys.failurePair,
keys.inflightIdentifier,
},
token,
g.cfg.ConcurrentAttemptLockTTL.Milliseconds(),
@@ -154,6 +167,7 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
g.cfg.RequestIdentifierWindow.Milliseconds(),
g.cfg.RequestPairLimit,
g.cfg.RequestPairWindow.Milliseconds(),
pairIndexTTL.Milliseconds(),
).Slice()
if err != nil {
return g.acquireFallback(keys, token)
@@ -257,15 +271,40 @@ func (p *LoginPermit) Release(ctx context.Context) {
}
func (g *LoginGuard) Unlock(ctx context.Context, identifier string) error {
_, err := g.UnlockWithResult(ctx, identifier)
return err
}
func (g *LoginGuard) UnlockWithResult(ctx context.Context, identifier string) (LoginGuardUnlockResult, error) {
var result LoginGuardUnlockResult
if g == nil || !g.cfg.Enabled {
return nil
return result, nil
}
keys := g.keysFor(LoginGuardSubject{Identifier: identifier})
prefix := "auth:login:" + normalizeLoginGuardScope(g.cfg.Scope) + ":"
if g.fallback != nil {
result.FallbackDeletedKeys = g.fallback.clearByPrefix(prefix)
}
if g.rdb == nil {
g.fallback.unlock(keys)
return nil
return result, nil
}
return g.rdb.Del(ctx, keys.lockIdentifier, keys.failureIdentifier).Err()
opts := g.rdb.Options()
if opts != nil {
result.RedisAddr = opts.Addr
result.RedisDB = opts.DB
}
for _, pattern := range g.unlockScanPatterns() {
deleted, err := g.deleteKeysByScan(ctx, pattern)
if err != nil {
return result, fmt.Errorf("%w: unlock redis keys: %v", ErrLoginGuardUnavailable, err)
}
result.RedisDeletedKeys += deleted
result.RedisScanPatterns = append(result.RedisScanPatterns, pattern)
}
return result, nil
}
type loginGuardKeys struct {
@@ -277,6 +316,8 @@ type loginGuardKeys struct {
lockIdentifier string
lockPair string
inflightIdentifier string
pairIndex string
unlockIdentifier string
}
func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
@@ -297,6 +338,98 @@ func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
lockIdentifier: prefix + "lock:id:" + identifierHash,
lockPair: prefix + "lock:pair:" + pairHash,
inflightIdentifier: prefix + "inflight:id:" + identifierHash,
pairIndex: prefix + "pairs:id:" + identifierHash,
unlockIdentifier: prefix + "unlock:id:" + identifierHash,
}
}
func (g *LoginGuard) pairIndexTTL() time.Duration {
if g == nil {
return time.Hour
}
ttl := g.cfg.FailureWindow + g.cfg.FailureLockMaxTTL
for _, candidate := range []time.Duration{
g.cfg.RequestPairWindow,
g.cfg.RequestIdentifierWindow,
g.cfg.RequestIPWindow,
g.cfg.FailureLockMaxTTL,
} {
if candidate > ttl {
ttl = candidate
}
}
if ttl <= 0 {
return time.Hour
}
return ttl
}
func (g *LoginGuard) unlockMarkerTTL() time.Duration {
if g == nil || g.cfg.FailureLockMaxTTL <= 0 {
return time.Hour
}
return g.cfg.FailureLockMaxTTL
}
func (g *LoginGuard) UnlockMarkerTTL() time.Duration {
return g.unlockMarkerTTL()
}
func (g *LoginGuard) unlockScanPatterns() []string {
prefix := "auth:login:" + normalizeLoginGuardScope(g.cfg.Scope) + ":"
return uniqueLoginGuardPatterns(prefix+"*", "auth:login:*")
}
func uniqueLoginGuardPatterns(patterns ...string) []string {
seen := make(map[string]struct{}, len(patterns))
unique := make([]string, 0, len(patterns))
for _, pattern := range patterns {
if pattern == "" {
continue
}
if _, ok := seen[pattern]; ok {
continue
}
seen[pattern] = struct{}{}
unique = append(unique, pattern)
}
return unique
}
func (g *LoginGuard) deleteKeysByScan(ctx context.Context, pattern string) (int64, error) {
var totalDeleted int64
for range 10 {
deleted, err := g.deleteKeysByScanPass(ctx, pattern)
if err != nil {
return totalDeleted, err
}
totalDeleted += deleted
if deleted == 0 {
return totalDeleted, nil
}
}
return totalDeleted, nil
}
func (g *LoginGuard) deleteKeysByScanPass(ctx context.Context, pattern string) (int64, error) {
var deleted int64
var cursor uint64
for {
keys, nextCursor, err := g.rdb.Scan(ctx, cursor, pattern, 1000).Result()
if err != nil {
return deleted, err
}
if len(keys) > 0 {
count, err := g.rdb.Del(ctx, keys...).Result()
if err != nil {
return deleted, err
}
deleted += count
}
cursor = nextCursor
if cursor == 0 {
return deleted, nil
}
}
}
@@ -424,12 +557,14 @@ func redisMillisToDuration(value any) time.Duration {
}
type memoryLoginGuard struct {
mu sync.Mutex
cfg LoginGuardConfig
requests map[string]memoryLoginCounter
failures map[string]memoryLoginCounter
locks map[string]time.Time
inflight map[string]memoryLoginInflight
mu sync.Mutex
cfg LoginGuardConfig
requests map[string]memoryLoginCounter
failures map[string]memoryLoginCounter
locks map[string]time.Time
inflight map[string]memoryLoginInflight
pairIndex map[string]map[string]struct{}
unlockMarkers map[string]time.Time
}
type memoryLoginCounter struct {
@@ -449,11 +584,13 @@ type memoryLoginPermit struct {
func newMemoryLoginGuard(cfg LoginGuardConfig) *memoryLoginGuard {
return &memoryLoginGuard{
cfg: cfg,
requests: make(map[string]memoryLoginCounter),
failures: make(map[string]memoryLoginCounter),
locks: make(map[string]time.Time),
inflight: make(map[string]memoryLoginInflight),
cfg: cfg,
requests: make(map[string]memoryLoginCounter),
failures: make(map[string]memoryLoginCounter),
locks: make(map[string]time.Time),
inflight: make(map[string]memoryLoginInflight),
pairIndex: make(map[string]map[string]struct{}),
unlockMarkers: make(map[string]time.Time),
}
}
@@ -462,11 +599,23 @@ func (g *memoryLoginGuard) acquire(keys loginGuardKeys, token string) (*memoryLo
g.mu.Lock()
defer g.mu.Unlock()
g.indexPairLocked(keys)
unlocked := g.unlockMarkerActiveLocked(keys.unlockIdentifier, now)
if unlocked {
delete(g.inflight, keys.inflightIdentifier)
}
if retryAfter := g.lockRetryAfterLocked(keys.lockIdentifier, now); retryAfter > 0 {
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
}
if retryAfter := g.lockRetryAfterLocked(keys.lockPair, now); retryAfter > 0 {
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
if !unlocked {
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
}
delete(g.locks, keys.lockPair)
delete(g.failures, keys.failurePair)
delete(g.requests, keys.requestPair)
delete(g.unlockMarkers, keys.unlockIdentifier)
}
if retryAfter := g.inflightRetryAfterLocked(keys.inflightIdentifier, now); retryAfter > 0 {
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedInProgress, RetryAfter: retryAfter}
@@ -599,6 +748,69 @@ func (g *memoryLoginGuard) unlock(keys loginGuardKeys) {
defer g.mu.Unlock()
delete(g.locks, keys.lockIdentifier)
delete(g.failures, keys.failureIdentifier)
delete(g.requests, keys.requestIdentifier)
delete(g.inflight, keys.inflightIdentifier)
for indexedKey := range g.pairIndex[keys.pairIndex] {
delete(g.locks, indexedKey)
delete(g.failures, indexedKey)
delete(g.requests, indexedKey)
}
delete(g.pairIndex, keys.pairIndex)
g.unlockMarkers[keys.unlockIdentifier] = time.Now().Add(g.unlockMarkerTTL())
}
func (g *memoryLoginGuard) clearByPrefix(prefix string) int {
if g == nil {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
deleted := 0
for key := range g.requests {
if strings.HasPrefix(key, prefix) {
delete(g.requests, key)
deleted++
}
}
for key := range g.failures {
if strings.HasPrefix(key, prefix) {
delete(g.failures, key)
deleted++
}
}
for key := range g.locks {
if strings.HasPrefix(key, prefix) {
delete(g.locks, key)
deleted++
}
}
for key := range g.inflight {
if strings.HasPrefix(key, prefix) {
delete(g.inflight, key)
deleted++
}
}
for key := range g.pairIndex {
if strings.HasPrefix(key, prefix) {
delete(g.pairIndex, key)
deleted++
}
}
for key := range g.unlockMarkers {
if strings.HasPrefix(key, prefix) {
delete(g.unlockMarkers, key)
deleted++
}
}
return deleted
}
func (g *memoryLoginGuard) unlockMarkerTTL() time.Duration {
if g == nil || g.cfg.FailureLockMaxTTL <= 0 {
return time.Hour
}
return g.cfg.FailureLockMaxTTL
}
func (g *memoryLoginGuard) releaseLocked(key, token string) {
@@ -609,6 +821,32 @@ func (g *memoryLoginGuard) releaseLocked(key, token string) {
delete(g.inflight, key)
}
func (g *memoryLoginGuard) indexPairLocked(keys loginGuardKeys) {
if keys.pairIndex == "" {
return
}
current := g.pairIndex[keys.pairIndex]
if current == nil {
current = make(map[string]struct{}, 2)
g.pairIndex[keys.pairIndex] = current
}
current[keys.lockPair] = struct{}{}
current[keys.failurePair] = struct{}{}
current[keys.requestPair] = struct{}{}
}
func (g *memoryLoginGuard) unlockMarkerActiveLocked(key string, now time.Time) bool {
expiresAt, ok := g.unlockMarkers[key]
if !ok {
return false
}
if expiresAt.After(now) {
return true
}
delete(g.unlockMarkers, key)
return false
}
var loginGuardAcquireScript = redis.NewScript(`
local token = ARGV[1]
local inflight_ttl = tonumber(ARGV[2])
@@ -618,6 +856,7 @@ local id_limit = tonumber(ARGV[5])
local id_window = tonumber(ARGV[6])
local pair_limit = tonumber(ARGV[7])
local pair_window = tonumber(ARGV[8])
local pair_index_ttl = tonumber(ARGV[9])
local function positive_ttl(key, fallback)
local ttl = redis.call("PTTL", key)
@@ -627,11 +866,23 @@ local function positive_ttl(key, fallback)
return ttl
end
redis.call("SADD", KEYS[7], KEYS[2], KEYS[9], KEYS[6])
redis.call("PEXPIRE", KEYS[7], pair_index_ttl)
local unlocked = redis.call("EXISTS", KEYS[8]) == 1
if unlocked then
redis.call("DEL", KEYS[10])
end
if redis.call("EXISTS", KEYS[1]) == 1 then
return {0, "login_locked", positive_ttl(KEYS[1], inflight_ttl)}
end
if redis.call("EXISTS", KEYS[2]) == 1 then
return {0, "login_locked", positive_ttl(KEYS[2], inflight_ttl)}
if unlocked then
redis.call("DEL", KEYS[2], KEYS[9], KEYS[6], KEYS[8])
else
return {0, "login_locked", positive_ttl(KEYS[2], inflight_ttl)}
end
end
if not redis.call("SET", KEYS[3], token, "PX", inflight_ttl, "NX") then
@@ -719,3 +970,14 @@ if redis.call("GET", KEYS[1]) == ARGV[1] then
end
return 0
`)
var loginGuardUnlockScript = redis.NewScript(`
local marker_ttl = tonumber(ARGV[1])
local indexed = redis.call("SMEMBERS", KEYS[3])
for _, key in ipairs(indexed) do
redis.call("DEL", key)
end
redis.call("DEL", KEYS[1], KEYS[2], KEYS[3], KEYS[5], KEYS[6])
redis.call("PSETEX", KEYS[4], marker_ttl, "1")
return #indexed
`)
@@ -146,6 +146,167 @@ func TestLoginGuardLocksAfterFailedAttempts(t *testing.T) {
permit.Release(ctx)
}
func TestLoginGuardUnlockClearsPairLocksForIdentifier(t *testing.T) {
guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
cfg.FailureIdentifierLimit = 100
cfg.FailurePairLimit = 2
cfg.FailureLockBaseTTL = time.Minute
})
ctx := context.Background()
subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.10"}
for i := 0; i < 2; i++ {
permit, err := guard.Acquire(ctx, subject)
require.NoError(t, err)
permit.RecordFailure(ctx)
}
_, err := guard.Acquire(ctx, subject)
var blocked *LoginGuardBlockedError
require.ErrorAs(t, err, &blocked)
assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason)
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
require.NoError(t, err)
assert.Greater(t, result.RedisDeletedKeys, int64(0))
permit, err := guard.Acquire(ctx, subject)
require.NoError(t, err)
require.NotNil(t, permit)
permit.Release(ctx)
}
func TestLoginGuardUnlockMarkerClearsLegacyPairLock(t *testing.T) {
guard, mr := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
cfg.FailureIdentifierLimit = 100
cfg.FailurePairLimit = 2
cfg.FailureLockBaseTTL = time.Minute
})
ctx := context.Background()
subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "192.0.2.20"}
keys := guard.keysFor(subject)
require.NoError(t, mr.Set(keys.lockPair, "2"))
mr.SetTTL(keys.lockPair, time.Minute)
_, err := guard.Acquire(ctx, subject)
var blocked *LoginGuardBlockedError
require.ErrorAs(t, err, &blocked)
assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason)
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
require.NoError(t, err)
assert.Greater(t, result.RedisDeletedKeys, int64(0))
require.False(t, mr.Exists(keys.lockPair))
permit, err := guard.Acquire(ctx, subject)
require.NoError(t, err)
require.NotNil(t, permit)
permit.Release(ctx)
}
func TestLoginGuardUnlockClearsRequestAndInflightState(t *testing.T) {
guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
cfg.RequestIdentifierLimit = 1
cfg.RequestIdentifierWindow = time.Minute
})
ctx := context.Background()
subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.30"}
permit, err := guard.Acquire(ctx, subject)
require.NoError(t, err)
_, err = guard.Acquire(ctx, subject)
var blocked *LoginGuardBlockedError
require.ErrorAs(t, err, &blocked)
assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason)
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
require.NoError(t, err)
assert.Greater(t, result.RedisDeletedKeys, int64(0))
permit.Release(ctx)
next, err := guard.Acquire(ctx, subject)
require.NoError(t, err)
require.NotNil(t, next)
next.Release(ctx)
}
func TestLoginGuardUnlockClearsWholeScopeProtectionCache(t *testing.T) {
guard, mr := newTestLoginGuard(t, nil)
ctx := context.Background()
otherSubject := LoginGuardSubject{Identifier: "other-user", IP: "192.0.2.40"}
otherKeys := guard.keysFor(otherSubject)
require.NoError(t, mr.Set(otherKeys.lockIdentifier, "1"))
mr.SetTTL(otherKeys.lockIdentifier, time.Minute)
require.NoError(t, mr.Set(otherKeys.lockPair, "1"))
mr.SetTTL(otherKeys.lockPair, time.Minute)
require.NoError(t, mr.Set(otherKeys.requestIdentifier, "99"))
mr.SetTTL(otherKeys.requestIdentifier, time.Minute)
require.NoError(t, mr.Set(otherKeys.requestPair, "99"))
mr.SetTTL(otherKeys.requestPair, time.Minute)
result, err := guard.UnlockWithResult(ctx, "13003088573")
require.NoError(t, err)
assert.Equal(t, int64(4), result.RedisDeletedKeys)
require.False(t, mr.Exists(otherKeys.lockIdentifier))
require.False(t, mr.Exists(otherKeys.lockPair))
require.False(t, mr.Exists(otherKeys.requestIdentifier))
require.False(t, mr.Exists(otherKeys.requestPair))
}
func TestLoginGuardUnlockClearsAllLoginScopes(t *testing.T) {
guard, mr := newTestLoginGuard(t, nil)
ctx := context.Background()
require.NoError(t, mr.Set("auth:login:tenant:lock:id:one", "1"))
require.NoError(t, mr.Set("auth:login:ops:lock:id:two", "1"))
require.NoError(t, mr.Set("auth:login:legacy:lock:id:three", "1"))
require.NoError(t, mr.Set("other:key", "1"))
result, err := guard.UnlockWithResult(ctx, "13003088573")
require.NoError(t, err)
assert.Equal(t, int64(3), result.RedisDeletedKeys)
assert.False(t, mr.Exists("auth:login:tenant:lock:id:one"))
assert.False(t, mr.Exists("auth:login:ops:lock:id:two"))
assert.False(t, mr.Exists("auth:login:legacy:lock:id:three"))
assert.True(t, mr.Exists("other:key"))
assert.Contains(t, result.RedisScanPatterns, "auth:login:*")
}
func TestLoginGuardUnlockClearsFallbackState(t *testing.T) {
cfg := DefaultLoginGuardConfig("test")
cfg.RequestPairLimit = 100
cfg.FailurePairLimit = 2
cfg.ConcurrentAttemptLockTTL = 10 * time.Second
guard := NewLoginGuard(nil, cfg)
ctx := context.Background()
subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.50"}
permit, err := guard.Acquire(ctx, subject)
require.NoError(t, err)
permit.RecordFailure(ctx)
permit, err = guard.Acquire(ctx, subject)
require.NoError(t, err)
permit.RecordFailure(ctx)
_, err = guard.Acquire(ctx, subject)
var blocked *LoginGuardBlockedError
require.ErrorAs(t, err, &blocked)
assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason)
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
require.NoError(t, err)
assert.Greater(t, result.FallbackDeletedKeys, 0)
permit, err = guard.Acquire(ctx, subject)
require.NoError(t, err)
require.NotNil(t, permit)
permit.Release(ctx)
}
func TestLoginGuardSuccessClearsFailureCounters(t *testing.T) {
guard, mr := newTestLoginGuard(t, nil)
ctx := context.Background()