// login_throttle.go throttles login brute-force by counting consecutive failed // attempts per (ip, email) key in a fixed time window — the same fixed-window // token-bucket algorithm proven in internal/mcp.RateLimiter, kept local here to // avoid an import cycle (user must not depend on mcp). On too many failures // within the window the next attempt is rejected with retry-after BEFORE any // password hash is computed, so an attacker cannot keep spending CPU on bcrypt. // // Keying by ip+email (not ip alone) means one attacker cannot lock a victim out // by hammering the victim's email from arbitrary IPs, and a shared NAT IP cannot // lock every user behind it; it does mean a botnet rotating IPs is not fully // stopped — that is the documented limit of a per-(ip,email) counter. package user import ( "strings" "sync" "time" ) // LoginThrottleConfig controls the failure window. MaxFailures failed attempts // within Window blocks further attempts until the window rolls over. type LoginThrottleConfig struct { Enabled bool MaxFailures int Window time.Duration } // attemptBucket is one (ip,email) key's failure counter for the current window. type attemptBucket struct { count int windowAt time.Time } // LoginThrottle is a concurrency-safe per-key fixed-window failure limiter. type LoginThrottle struct { cfg LoginThrottleConfig mu sync.Mutex m map[string]*attemptBucket now func() time.Time // injectable for tests } // NewLoginThrottle builds a throttle. now=nil uses time.Now. A disabled config // (Enabled=false or MaxFailures<=0) yields a throttle that always allows. func NewLoginThrottle(cfg LoginThrottleConfig, now func() time.Time) *LoginThrottle { if now == nil { now = time.Now } if cfg.Window <= 0 { cfg.Window = 15 * time.Minute } return &LoginThrottle{cfg: cfg, m: map[string]*attemptBucket{}, now: now} } // key normalizes (ip, email) into a single bucket key. Email is lowercased so // case variants share one counter; IP is taken as-is. func throttleKey(ip, email string) string { return ip + ":" + strings.ToLower(strings.TrimSpace(email)) } // disabled reports whether the throttle is a no-op. func (t *LoginThrottle) disabled() bool { return t == nil || !t.cfg.Enabled || t.cfg.MaxFailures <= 0 } // Allow reports whether a login attempt for (ip,email) may proceed. When // blocked it returns the duration until the current window rolls over. Allow // does NOT itself record an attempt — call RecordFailure on bad credentials and // Reset on success. func (t *LoginThrottle) Allow(ip, email string) (bool, time.Duration) { if t.disabled() { return true, 0 } t.mu.Lock() defer t.mu.Unlock() now := t.now() b := t.m[throttleKey(ip, email)] if b == nil { return true, 0 } if now.Sub(b.windowAt) >= t.cfg.Window { // Window expired: stale counter, allow (RecordFailure will reset it). return true, 0 } if b.count >= t.cfg.MaxFailures { retry := t.cfg.Window - now.Sub(b.windowAt) if retry < 0 { retry = 0 } return false, retry } return true, 0 } // RecordFailure increments the failure counter for (ip,email), starting a new // window if the previous one has expired. func (t *LoginThrottle) RecordFailure(ip, email string) { if t.disabled() { return } t.mu.Lock() defer t.mu.Unlock() now := t.now() k := throttleKey(ip, email) b := t.m[k] if b == nil || now.Sub(b.windowAt) >= t.cfg.Window { b = &attemptBucket{windowAt: now} t.m[k] = b } b.count++ } // Reset clears the failure counter for (ip,email) after a successful login. func (t *LoginThrottle) Reset(ip, email string) { if t.disabled() { return } t.mu.Lock() defer t.mu.Unlock() delete(t.m, throttleKey(ip, email)) }