Files
geo/server/internal/shared/auth/login_guard.go
T
root bbfeabdaa5
Frontend CI / Frontend (push) Successful in 3m13s
Backend CI / Backend (push) Successful in 15m21s
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>
2026-05-02 20:56:38 +08:00

984 lines
25 KiB
Go

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: 5 * time.Minute,
FailureLockBaseTTL: 2 * time.Minute,
FailureLockMaxTTL: time.Hour,
ConcurrentAttemptLockTTL: 15 * time.Second,
}
if strings.EqualFold(strings.TrimSpace(scope), "ops") {
cfg.RequestIPLimit = 30
cfg.RequestIdentifierLimit = 8
cfg.RequestPairLimit = 6
cfg.FailureIdentifierLimit = 10
cfg.FailurePairLimit = 5
cfg.FailureLockBaseTTL = 2 * time.Minute
cfg.FailureLockMaxTTL = 30 * time.Minute
}
return cfg
}
type LoginGuard struct {
rdb *redis.Client
cfg LoginGuardConfig
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{
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)
}
pairIndexTTL := g.pairIndexTTL()
result, err := loginGuardAcquireScript.Run(
ctx,
g.rdb,
[]string{
keys.lockIdentifier,
keys.lockPair,
keys.inflightIdentifier,
keys.requestIP,
keys.requestIdentifier,
keys.requestPair,
keys.pairIndex,
keys.unlockIdentifier,
keys.failurePair,
keys.inflightIdentifier,
},
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(),
pairIndexTTL.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()
}
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 result, nil
}
prefix := "auth:login:" + normalizeLoginGuardScope(g.cfg.Scope) + ":"
if g.fallback != nil {
result.FallbackDeletedKeys = g.fallback.clearByPrefix(prefix)
}
if g.rdb == nil {
return result, nil
}
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 {
requestIP string
requestIdentifier string
requestPair string
failureIdentifier string
failurePair string
lockIdentifier string
lockPair string
inflightIdentifier string
pairIndex string
unlockIdentifier 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,
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
}
}
}
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
pairIndex map[string]map[string]struct{}
unlockMarkers map[string]time.Time
}
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),
pairIndex: make(map[string]map[string]struct{}),
unlockMarkers: make(map[string]time.Time),
}
}
func (g *memoryLoginGuard) acquire(keys loginGuardKeys, token string) (*memoryLoginPermit, error) {
now := time.Now()
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 {
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}
}
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) unlock(keys loginGuardKeys) {
g.mu.Lock()
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) {
current, ok := g.inflight[key]
if !ok || current.token != token {
return
}
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])
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 pair_index_ttl = tonumber(ARGV[9])
local function positive_ttl(key, fallback)
local ttl = redis.call("PTTL", key)
if ttl <= 0 then
return fallback
end
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
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
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
`)
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
`)