diff --git a/apps/ops-web/src/views/AdminUsersView.vue b/apps/ops-web/src/views/AdminUsersView.vue index ae51815..594082c 100644 --- a/apps/ops-web/src/views/AdminUsersView.vue +++ b/apps/ops-web/src/views/AdminUsersView.vue @@ -138,7 +138,7 @@ - + (null) async function resetLoginLock(row: AdminUserRow) { resetLoginLockLoadingId.value = row.id try { - await http.post(`/admin-users/${row.id}/reset-login-lock`) - message.success('登录保护已重置,用户可立即重新登录') + const result = await http.post(`/admin-users/${row.id}/reset-login-lock`) + if (result.redis_deleted_keys > 0) { + message.success(`登录锁定已清除,已删除 ${result.redis_deleted_keys} 个 Redis key`) + } else if (result.fallback_deleted_keys > 0) { + message.success(`登录锁定已清除,已清理 ${result.fallback_deleted_keys} 个本机保护状态`) + } else { + message.warning('未发现登录锁 Redis key,请检查 ops-api 与 admin-web 使用的 Redis 地址和 DB 是否一致') + } } catch (error) { if (error instanceof OpsApiError) message.error(error.detail || error.message) } finally { diff --git a/server/internal/ops/app/admin_user.go b/server/internal/ops/app/admin_user.go index bc603e2..40fa975 100644 --- a/server/internal/ops/app/admin_user.go +++ b/server/internal/ops/app/admin_user.go @@ -19,15 +19,15 @@ import ( ) const ( - ActionAdminUserCreate = "admin_user.create" - ActionAdminUserUpdateProfile = "admin_user.update_profile" - ActionAdminUserChangePlan = "admin_user.change_plan" - ActionAdminUserChangeRole = "admin_user.change_role" - ActionAdminUserChangeExpiry = "admin_user.change_expiry" - ActionAdminUserResetUsage = "admin_user.reset_usage" - ActionAdminUserSetKOL = "admin_user.set_kol" - ActionAdminUserDisable = "admin_user.disable" - ActionAdminUserEnable = "admin_user.enable" + ActionAdminUserCreate = "admin_user.create" + ActionAdminUserUpdateProfile = "admin_user.update_profile" + ActionAdminUserChangePlan = "admin_user.change_plan" + ActionAdminUserChangeRole = "admin_user.change_role" + ActionAdminUserChangeExpiry = "admin_user.change_expiry" + ActionAdminUserResetUsage = "admin_user.reset_usage" + ActionAdminUserSetKOL = "admin_user.set_kol" + ActionAdminUserDisable = "admin_user.disable" + ActionAdminUserEnable = "admin_user.enable" ActionAdminUserResetPassword = "admin_user.reset_password" ActionAdminUserResetLoginLock = "admin_user.reset_login_lock" ) @@ -179,6 +179,15 @@ type ResetAdminUserPlanUsageResult struct { Reset ResetPlanUsageView `json:"reset"` } +type ResetLoginLockResult struct { + ID int64 `json:"id"` + RedisDeletedKeys int64 `json:"redis_deleted_keys"` + FallbackDeletedKeys int `json:"fallback_deleted_keys"` + RedisScanPatterns []string `json:"redis_scan_patterns"` + RedisAddr string `json:"redis_addr,omitempty"` + RedisDB int `json:"redis_db"` +} + func toAdminUserView(u *repository.AdminUserRecord) AdminUserView { return AdminUserView{ ID: u.ID, @@ -603,24 +612,31 @@ func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id i return nil } -func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) error { +func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) (*ResetLoginLockResult, error) { user, err := s.users.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { - return response.ErrNotFound(40430, "user_not_found", "用户不存在") + return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } - return response.ErrInternal(50034, "query_failed", "failed to get user") + return nil, response.ErrInternal(50034, "query_failed", "failed to get user") } + + result := &ResetLoginLockResult{ID: id} if s.loginGuard != nil { - _ = s.loginGuard.Unlock(ctx, user.Phone) - if user.Email != nil && *user.Email != "" { - _ = s.loginGuard.Unlock(ctx, *user.Email) + unlock, err := s.loginGuard.UnlockWithResult(ctx, user.Phone) + if err != nil { + return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "登录保护暂时不可用,请稍后重试") } + result.RedisDeletedKeys = unlock.RedisDeletedKeys + result.FallbackDeletedKeys = unlock.FallbackDeletedKeys + result.RedisScanPatterns = unlock.RedisScanPatterns + result.RedisAddr = unlock.RedisAddr + result.RedisDB = unlock.RedisDB } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil)) } - return nil + return result, nil } func (s *AdminUserService) mapWriteError(err error) error { diff --git a/server/internal/ops/transport/admin_user_handler.go b/server/internal/ops/transport/admin_user_handler.go index d666f8b..988640a 100644 --- a/server/internal/ops/transport/admin_user_handler.go +++ b/server/internal/ops/transport/admin_user_handler.go @@ -292,10 +292,11 @@ func resetAdminUserLoginLockHandler(svc *app.AdminUserService) gin.HandlerFunc { response.Error(c, err) return } - if err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id); err != nil { + result, err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id) + if err != nil { response.Error(c, err) return } - response.Success(c, gin.H{"id": id}) + response.Success(c, result) } } diff --git a/server/internal/shared/auth/login_guard.go b/server/internal/shared/auth/login_guard.go index 99f4cee..f875c2c 100644 --- a/server/internal/shared/auth/login_guard.go +++ b/server/internal/shared/auth/login_guard.go @@ -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 +`) diff --git a/server/internal/shared/auth/login_guard_test.go b/server/internal/shared/auth/login_guard_test.go index 93d4d79..22d2320 100644 --- a/server/internal/shared/auth/login_guard_test.go +++ b/server/internal/shared/auth/login_guard_test.go @@ -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()