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
@@ -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()