diff --git a/server/internal/shared/auth/login_guard.go b/server/internal/shared/auth/login_guard.go new file mode 100644 index 0000000..4485522 --- /dev/null +++ b/server/internal/shared/auth/login_guard.go @@ -0,0 +1,702 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9" +) + +type LoginGuardBlockReason string + +const ( + LoginGuardBlockedRateLimited LoginGuardBlockReason = "login_rate_limited" + LoginGuardBlockedLocked LoginGuardBlockReason = "login_locked" + LoginGuardBlockedInProgress LoginGuardBlockReason = "login_in_progress" +) + +var ErrLoginGuardUnavailable = errors.New("login guard unavailable") + +type LoginGuardBlockedError struct { + Reason LoginGuardBlockReason + RetryAfter time.Duration +} + +func (e *LoginGuardBlockedError) Error() string { + return string(e.Reason) +} + +func (e *LoginGuardBlockedError) RetryAfterSeconds() int { + if e.RetryAfter <= 0 { + return 1 + } + return max(1, int(math.Ceil(e.RetryAfter.Seconds()))) +} + +type LoginGuardConfig struct { + Scope string + + Enabled bool + + RequestIPLimit int + RequestIPWindow time.Duration + RequestIdentifierLimit int + RequestIdentifierWindow time.Duration + RequestPairLimit int + RequestPairWindow time.Duration + FailureIdentifierLimit int + FailurePairLimit int + FailureWindow time.Duration + FailureLockBaseTTL time.Duration + FailureLockMaxTTL time.Duration + ConcurrentAttemptLockTTL time.Duration +} + +func DefaultLoginGuardConfig(scope string) LoginGuardConfig { + cfg := LoginGuardConfig{ + Scope: scope, + Enabled: true, + RequestIPLimit: 60, + RequestIPWindow: time.Minute, + RequestIdentifierLimit: 12, + RequestIdentifierWindow: 5 * time.Minute, + RequestPairLimit: 6, + RequestPairWindow: time.Minute, + FailureIdentifierLimit: 8, + FailurePairLimit: 5, + FailureWindow: 15 * time.Minute, + FailureLockBaseTTL: 5 * time.Minute, + FailureLockMaxTTL: time.Hour, + ConcurrentAttemptLockTTL: 15 * time.Second, + } + + if strings.EqualFold(strings.TrimSpace(scope), "ops") { + cfg.RequestIPLimit = 30 + cfg.RequestIdentifierLimit = 8 + cfg.RequestPairLimit = 5 + cfg.FailureIdentifierLimit = 5 + cfg.FailurePairLimit = 3 + cfg.FailureLockBaseTTL = 10 * time.Minute + cfg.FailureLockMaxTTL = 2 * time.Hour + } + + return cfg +} + +type LoginGuard struct { + rdb *redis.Client + cfg LoginGuardConfig + fallback *memoryLoginGuard +} + +func NewLoginGuard(rdb *redis.Client, cfg LoginGuardConfig) *LoginGuard { + cfg = normalizeLoginGuardConfig(cfg) + return &LoginGuard{ + rdb: rdb, + cfg: cfg, + fallback: newMemoryLoginGuard(cfg), + } +} + +type LoginGuardSubject struct { + Identifier string + IP string +} + +type LoginPermit struct { + guard *LoginGuard + keys loginGuardKeys + token string + fallbackPermit *memoryLoginPermit + done atomic.Bool +} + +func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*LoginPermit, error) { + if g == nil || !g.cfg.Enabled { + return nil, nil + } + + keys := g.keysFor(subject) + token, err := randomLoginGuardToken() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrLoginGuardUnavailable, err) + } + if g.rdb == nil { + return g.acquireFallback(keys, token) + } + + result, err := loginGuardAcquireScript.Run( + ctx, + g.rdb, + []string{ + keys.lockIdentifier, + keys.lockPair, + keys.inflightIdentifier, + keys.requestIP, + keys.requestIdentifier, + keys.requestPair, + }, + token, + g.cfg.ConcurrentAttemptLockTTL.Milliseconds(), + g.cfg.RequestIPLimit, + g.cfg.RequestIPWindow.Milliseconds(), + g.cfg.RequestIdentifierLimit, + g.cfg.RequestIdentifierWindow.Milliseconds(), + g.cfg.RequestPairLimit, + g.cfg.RequestPairWindow.Milliseconds(), + ).Slice() + if err != nil { + return g.acquireFallback(keys, token) + } + if len(result) < 3 { + return nil, fmt.Errorf("%w: unexpected acquire result", ErrLoginGuardUnavailable) + } + + allowed, _ := result[0].(int64) + reason, _ := result[1].(string) + retryAfter := redisMillisToDuration(result[2]) + if allowed == 1 { + return &LoginPermit{guard: g, keys: keys, token: token}, nil + } + + switch LoginGuardBlockReason(reason) { + case LoginGuardBlockedLocked, LoginGuardBlockedInProgress, LoginGuardBlockedRateLimited: + return nil, &LoginGuardBlockedError{ + Reason: LoginGuardBlockReason(reason), + RetryAfter: retryAfter, + } + default: + return nil, fmt.Errorf("%w: unexpected block reason %q", ErrLoginGuardUnavailable, reason) + } +} + +func (g *LoginGuard) acquireFallback(keys loginGuardKeys, token string) (*LoginPermit, error) { + if g.fallback == nil { + return nil, fmt.Errorf("%w: fallback guard missing", ErrLoginGuardUnavailable) + } + permit, err := g.fallback.acquire(keys, token) + if err != nil { + return nil, err + } + return &LoginPermit{ + guard: g, + keys: keys, + token: token, + fallbackPermit: permit, + }, nil +} + +func (p *LoginPermit) RecordFailure(ctx context.Context) { + if p == nil || p.guard == nil || !p.done.CompareAndSwap(false, true) { + return + } + if p.fallbackPermit != nil { + p.guard.fallback.recordFailure(p.fallbackPermit) + return + } + _, _ = loginGuardFailureScript.Run( + ctx, + p.guard.rdb, + []string{ + p.keys.failureIdentifier, + p.keys.failurePair, + p.keys.lockIdentifier, + p.keys.lockPair, + p.keys.inflightIdentifier, + }, + p.token, + p.guard.cfg.FailureWindow.Milliseconds(), + p.guard.cfg.FailureIdentifierLimit, + p.guard.cfg.FailurePairLimit, + p.guard.cfg.FailureLockBaseTTL.Milliseconds(), + p.guard.cfg.FailureLockMaxTTL.Milliseconds(), + ).Result() +} + +func (p *LoginPermit) RecordSuccess(ctx context.Context) { + if p == nil || p.guard == nil || !p.done.CompareAndSwap(false, true) { + return + } + if p.fallbackPermit != nil { + p.guard.fallback.recordSuccess(p.fallbackPermit) + return + } + _, _ = loginGuardSuccessScript.Run( + ctx, + p.guard.rdb, + []string{ + p.keys.failureIdentifier, + p.keys.failurePair, + p.keys.lockIdentifier, + p.keys.lockPair, + p.keys.inflightIdentifier, + }, + p.token, + ).Result() +} + +func (p *LoginPermit) Release(ctx context.Context) { + if p == nil || p.guard == nil || !p.done.CompareAndSwap(false, true) { + return + } + if p.fallbackPermit != nil { + p.guard.fallback.release(p.fallbackPermit) + return + } + _, _ = loginGuardReleaseScript.Run(ctx, p.guard.rdb, []string{p.keys.inflightIdentifier}, p.token).Result() +} + +type loginGuardKeys struct { + requestIP string + requestIdentifier string + requestPair string + failureIdentifier string + failurePair string + lockIdentifier string + lockPair string + inflightIdentifier string +} + +func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys { + scope := normalizeLoginGuardScope(g.cfg.Scope) + identifier := normalizeLoginIdentifier(subject.Identifier) + ip := normalizeLoginIP(subject.IP) + identifierHash := hashLoginGuardKey(identifier) + ipHash := hashLoginGuardKey(ip) + pairHash := hashLoginGuardKey(ip + "|" + identifier) + prefix := "auth:login:" + scope + ":" + + return loginGuardKeys{ + requestIP: prefix + "req:ip:" + ipHash, + requestIdentifier: prefix + "req:id:" + identifierHash, + requestPair: prefix + "req:pair:" + pairHash, + failureIdentifier: prefix + "fail:id:" + identifierHash, + failurePair: prefix + "fail:pair:" + pairHash, + lockIdentifier: prefix + "lock:id:" + identifierHash, + lockPair: prefix + "lock:pair:" + pairHash, + inflightIdentifier: prefix + "inflight:id:" + identifierHash, + } +} + +func normalizeLoginGuardConfig(cfg LoginGuardConfig) LoginGuardConfig { + defaults := DefaultLoginGuardConfig(cfg.Scope) + if strings.TrimSpace(cfg.Scope) == "" { + cfg.Scope = defaults.Scope + } + if cfg.RequestIPLimit <= 0 { + cfg.RequestIPLimit = defaults.RequestIPLimit + } + if cfg.RequestIPWindow <= 0 { + cfg.RequestIPWindow = defaults.RequestIPWindow + } + if cfg.RequestIdentifierLimit <= 0 { + cfg.RequestIdentifierLimit = defaults.RequestIdentifierLimit + } + if cfg.RequestIdentifierWindow <= 0 { + cfg.RequestIdentifierWindow = defaults.RequestIdentifierWindow + } + if cfg.RequestPairLimit <= 0 { + cfg.RequestPairLimit = defaults.RequestPairLimit + } + if cfg.RequestPairWindow <= 0 { + cfg.RequestPairWindow = defaults.RequestPairWindow + } + if cfg.FailureIdentifierLimit <= 0 { + cfg.FailureIdentifierLimit = defaults.FailureIdentifierLimit + } + if cfg.FailurePairLimit <= 0 { + cfg.FailurePairLimit = defaults.FailurePairLimit + } + if cfg.FailureWindow <= 0 { + cfg.FailureWindow = defaults.FailureWindow + } + if cfg.FailureLockBaseTTL <= 0 { + cfg.FailureLockBaseTTL = defaults.FailureLockBaseTTL + } + if cfg.FailureLockMaxTTL <= 0 { + cfg.FailureLockMaxTTL = defaults.FailureLockMaxTTL + } + if cfg.ConcurrentAttemptLockTTL <= 0 { + cfg.ConcurrentAttemptLockTTL = defaults.ConcurrentAttemptLockTTL + } + if cfg.FailureLockMaxTTL < cfg.FailureLockBaseTTL { + cfg.FailureLockMaxTTL = cfg.FailureLockBaseTTL + } + return cfg +} + +func normalizeLoginGuardScope(scope string) string { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return "default" + } + var b strings.Builder + for _, r := range scope { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_': + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "default" + } + return b.String() +} + +func normalizeLoginIdentifier(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return "unknown" + } + return normalized +} + +func normalizeLoginIP(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "unknown" + } + if host, _, err := net.SplitHostPort(trimmed); err == nil { + trimmed = host + } + if ip := net.ParseIP(trimmed); ip != nil { + return ip.String() + } + return strings.ToLower(trimmed) +} + +func hashLoginGuardKey(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:32] +} + +func randomLoginGuardToken() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func redisMillisToDuration(value any) time.Duration { + switch v := value.(type) { + case int64: + if v <= 0 { + return time.Second + } + return time.Duration(v) * time.Millisecond + case string: + if v == "" { + return time.Second + } + var n int64 + if _, err := fmt.Sscan(v, &n); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return time.Second +} + +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 +} + +type memoryLoginCounter struct { + count int + expiresAt time.Time +} + +type memoryLoginInflight struct { + token string + expiresAt time.Time +} + +type memoryLoginPermit struct { + keys loginGuardKeys + token string +} + +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), + } +} + +func (g *memoryLoginGuard) acquire(keys loginGuardKeys, token string) (*memoryLoginPermit, error) { + now := time.Now() + g.mu.Lock() + defer g.mu.Unlock() + + 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 retryAfter := g.inflightRetryAfterLocked(keys.inflightIdentifier, now); retryAfter > 0 { + return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedInProgress, RetryAfter: retryAfter} + } + + g.inflight[keys.inflightIdentifier] = memoryLoginInflight{ + token: token, + expiresAt: now.Add(g.cfg.ConcurrentAttemptLockTTL), + } + + if retryAfter := g.consumeRequestLocked(keys.requestIP, g.cfg.RequestIPLimit, g.cfg.RequestIPWindow, now); retryAfter > 0 { + delete(g.inflight, keys.inflightIdentifier) + return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedRateLimited, RetryAfter: retryAfter} + } + if retryAfter := g.consumeRequestLocked(keys.requestIdentifier, g.cfg.RequestIdentifierLimit, g.cfg.RequestIdentifierWindow, now); retryAfter > 0 { + delete(g.inflight, keys.inflightIdentifier) + return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedRateLimited, RetryAfter: retryAfter} + } + if retryAfter := g.consumeRequestLocked(keys.requestPair, g.cfg.RequestPairLimit, g.cfg.RequestPairWindow, now); retryAfter > 0 { + delete(g.inflight, keys.inflightIdentifier) + return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedRateLimited, RetryAfter: retryAfter} + } + + return &memoryLoginPermit{keys: keys, token: token}, nil +} + +func (g *memoryLoginGuard) recordFailure(permit *memoryLoginPermit) { + if permit == nil { + return + } + now := time.Now() + g.mu.Lock() + defer g.mu.Unlock() + + g.bumpFailureLocked(permit.keys.failureIdentifier, permit.keys.lockIdentifier, g.cfg.FailureIdentifierLimit, now) + g.bumpFailureLocked(permit.keys.failurePair, permit.keys.lockPair, g.cfg.FailurePairLimit, now) + g.releaseLocked(permit.keys.inflightIdentifier, permit.token) +} + +func (g *memoryLoginGuard) recordSuccess(permit *memoryLoginPermit) { + if permit == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + + delete(g.failures, permit.keys.failureIdentifier) + delete(g.failures, permit.keys.failurePair) + delete(g.locks, permit.keys.lockIdentifier) + delete(g.locks, permit.keys.lockPair) + g.releaseLocked(permit.keys.inflightIdentifier, permit.token) +} + +func (g *memoryLoginGuard) release(permit *memoryLoginPermit) { + if permit == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + g.releaseLocked(permit.keys.inflightIdentifier, permit.token) +} + +func (g *memoryLoginGuard) lockRetryAfterLocked(key string, now time.Time) time.Duration { + expiresAt, ok := g.locks[key] + if !ok { + return 0 + } + if !expiresAt.After(now) { + delete(g.locks, key) + return 0 + } + return expiresAt.Sub(now) +} + +func (g *memoryLoginGuard) inflightRetryAfterLocked(key string, now time.Time) time.Duration { + current, ok := g.inflight[key] + if !ok { + return 0 + } + if !current.expiresAt.After(now) { + delete(g.inflight, key) + return 0 + } + return current.expiresAt.Sub(now) +} + +func (g *memoryLoginGuard) consumeRequestLocked(key string, limit int, window time.Duration, now time.Time) time.Duration { + if limit <= 0 { + return 0 + } + current := g.requests[key] + if !current.expiresAt.After(now) { + current = memoryLoginCounter{expiresAt: now.Add(window)} + } + current.count++ + g.requests[key] = current + if current.count <= limit { + return 0 + } + return current.expiresAt.Sub(now) +} + +func (g *memoryLoginGuard) bumpFailureLocked(failureKey, lockKey string, threshold int, now time.Time) { + if threshold <= 0 { + return + } + current := g.failures[failureKey] + if !current.expiresAt.After(now) { + current = memoryLoginCounter{expiresAt: now.Add(g.cfg.FailureWindow)} + } + current.count++ + g.failures[failureKey] = current + if current.count < threshold { + return + } + steps := (current.count - threshold) / threshold + lockTTL := g.cfg.FailureLockBaseTTL + for i := 0; i < steps; i++ { + lockTTL *= 2 + if lockTTL >= g.cfg.FailureLockMaxTTL { + lockTTL = g.cfg.FailureLockMaxTTL + break + } + } + g.locks[lockKey] = now.Add(lockTTL) +} + +func (g *memoryLoginGuard) releaseLocked(key, token string) { + current, ok := g.inflight[key] + if !ok || current.token != token { + return + } + delete(g.inflight, key) +} + +var loginGuardAcquireScript = redis.NewScript(` +local token = ARGV[1] +local inflight_ttl = tonumber(ARGV[2]) +local ip_limit = tonumber(ARGV[3]) +local ip_window = tonumber(ARGV[4]) +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 function positive_ttl(key, fallback) + local ttl = redis.call("PTTL", key) + if ttl <= 0 then + return fallback + end + return ttl +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)} +end + +if not redis.call("SET", KEYS[3], token, "PX", inflight_ttl, "NX") then + return {0, "login_in_progress", positive_ttl(KEYS[3], inflight_ttl)} +end + +local function consume(key, limit, window) + if limit <= 0 then + return 0 + end + local count = redis.call("INCR", key) + if count == 1 then + redis.call("PEXPIRE", key, window) + end + if count > limit then + return positive_ttl(key, window) + end + return 0 +end + +local ttl = consume(KEYS[4], ip_limit, ip_window) +if ttl <= 0 then + ttl = consume(KEYS[5], id_limit, id_window) +end +if ttl <= 0 then + ttl = consume(KEYS[6], pair_limit, pair_window) +end +if ttl > 0 then + redis.call("DEL", KEYS[3]) + return {0, "login_rate_limited", ttl} +end + +return {1, "ok", 0} +`) + +var loginGuardFailureScript = redis.NewScript(` +local token = ARGV[1] +local failure_window = tonumber(ARGV[2]) +local id_threshold = tonumber(ARGV[3]) +local pair_threshold = tonumber(ARGV[4]) +local base_ttl = tonumber(ARGV[5]) +local max_ttl = tonumber(ARGV[6]) + +local function bump(fail_key, lock_key, threshold) + if threshold <= 0 then + return 0 + end + local count = redis.call("INCR", fail_key) + redis.call("PEXPIRE", fail_key, failure_window) + if count < threshold then + return 0 + end + local steps = math.floor((count - threshold) / threshold) + local ttl = base_ttl * (2 ^ steps) + if ttl > max_ttl then + ttl = max_ttl + end + redis.call("PSETEX", lock_key, ttl, tostring(count)) + return ttl +end + +local id_ttl = bump(KEYS[1], KEYS[3], id_threshold) +local pair_ttl = bump(KEYS[2], KEYS[4], pair_threshold) +if redis.call("GET", KEYS[5]) == token then + redis.call("DEL", KEYS[5]) +end +if id_ttl > pair_ttl then + return id_ttl +end +return pair_ttl +`) + +var loginGuardSuccessScript = redis.NewScript(` +redis.call("DEL", KEYS[1], KEYS[2], KEYS[3], KEYS[4]) +if redis.call("GET", KEYS[5]) == ARGV[1] then + redis.call("DEL", KEYS[5]) +end +return 1 +`) + +var loginGuardReleaseScript = redis.NewScript(` +if redis.call("GET", KEYS[1]) == ARGV[1] then + redis.call("DEL", KEYS[1]) + return 1 +end +return 0 +`) diff --git a/server/internal/shared/auth/login_guard_test.go b/server/internal/shared/auth/login_guard_test.go new file mode 100644 index 0000000..93d4d79 --- /dev/null +++ b/server/internal/shared/auth/login_guard_test.go @@ -0,0 +1,167 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newFailingRedisClient(t *testing.T) *goredis.Client { + t.Helper() + client := goredis.NewClient(&goredis.Options{ + Addr: "127.0.0.1:1", + MaxRetries: 0, + DialTimeout: 10 * time.Millisecond, + ReadTimeout: 10 * time.Millisecond, + WriteTimeout: 10 * time.Millisecond, + }) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func newTestLoginGuard(t *testing.T, mutate func(*LoginGuardConfig)) (*LoginGuard, *miniredis.Miniredis) { + t.Helper() + + mr := miniredis.RunT(t) + client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + cfg := DefaultLoginGuardConfig("test") + cfg.RequestIPLimit = 100 + cfg.RequestIdentifierLimit = 100 + cfg.RequestPairLimit = 100 + cfg.RequestIPWindow = time.Minute + cfg.RequestIdentifierWindow = time.Minute + cfg.RequestPairWindow = time.Minute + cfg.FailureIdentifierLimit = 100 + cfg.FailurePairLimit = 100 + cfg.FailureWindow = 15 * time.Minute + cfg.FailureLockBaseTTL = 2 * time.Minute + cfg.FailureLockMaxTTL = 10 * time.Minute + cfg.ConcurrentAttemptLockTTL = 10 * time.Second + if mutate != nil { + mutate(&cfg) + } + + return NewLoginGuard(client, cfg), mr +} + +func TestLoginGuardFallsBackWhenRedisUnavailable(t *testing.T) { + cfg := DefaultLoginGuardConfig("test") + cfg.RequestPairLimit = 100 + cfg.FailurePairLimit = 2 + cfg.ConcurrentAttemptLockTTL = 10 * time.Second + guard := NewLoginGuard(newFailingRedisClient(t), cfg) + ctx := context.Background() + subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"} + + permit, err := guard.Acquire(ctx, subject) + require.NoError(t, err) + require.NotNil(t, permit) + + _, err = guard.Acquire(ctx, subject) + var blocked *LoginGuardBlockedError + require.ErrorAs(t, err, &blocked) + assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason) + + permit.RecordFailure(ctx) + permit, err = guard.Acquire(ctx, subject) + require.NoError(t, err) + permit.RecordFailure(ctx) + + _, err = guard.Acquire(ctx, subject) + require.ErrorAs(t, err, &blocked) + assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason) +} + +func TestLoginGuardRejectsConcurrentIdentifierAttempt(t *testing.T) { + guard, _ := newTestLoginGuard(t, nil) + ctx := context.Background() + subject := LoginGuardSubject{Identifier: "Admin@Geo.Local", IP: "127.0.0.1"} + + permit, err := guard.Acquire(ctx, subject) + require.NoError(t, err) + require.NotNil(t, permit) + + _, err = guard.Acquire(ctx, subject) + var blocked *LoginGuardBlockedError + require.ErrorAs(t, err, &blocked) + assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason) + assert.Greater(t, blocked.RetryAfterSeconds(), 0) + + permit.Release(ctx) + next, err := guard.Acquire(ctx, subject) + require.NoError(t, err) + require.NotNil(t, next) + next.Release(ctx) +} + +func TestLoginGuardRateLimitsRequestWindow(t *testing.T) { + guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { + cfg.RequestPairLimit = 1 + cfg.RequestPairWindow = time.Minute + }) + ctx := context.Background() + subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"} + + permit, err := guard.Acquire(ctx, subject) + require.NoError(t, err) + permit.RecordSuccess(ctx) + + _, err = guard.Acquire(ctx, subject) + var blocked *LoginGuardBlockedError + require.ErrorAs(t, err, &blocked) + assert.Equal(t, LoginGuardBlockedRateLimited, blocked.Reason) + assert.Greater(t, blocked.RetryAfterSeconds(), 0) +} + +func TestLoginGuardLocksAfterFailedAttempts(t *testing.T) { + guard, mr := newTestLoginGuard(t, func(cfg *LoginGuardConfig) { + cfg.FailurePairLimit = 2 + cfg.FailureLockBaseTTL = time.Minute + }) + ctx := context.Background() + subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"} + + 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) + + mr.FastForward(time.Minute + time.Second) + 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() + subject := LoginGuardSubject{Identifier: "ADMIN@Geo.Local ", IP: "127.0.0.1:43120"} + keys := guard.keysFor(subject) + + permit, err := guard.Acquire(ctx, subject) + require.NoError(t, err) + permit.RecordFailure(ctx) + require.True(t, mr.Exists(keys.failureIdentifier)) + require.True(t, mr.Exists(keys.failurePair)) + + permit, err = guard.Acquire(ctx, subject) + require.NoError(t, err) + permit.RecordSuccess(ctx) + + assert.False(t, mr.Exists(keys.failureIdentifier)) + assert.False(t, mr.Exists(keys.failurePair)) +}