feat(login-guard): unlock entire scope and report deleted keys
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user