Files
agentic-coding-workflow/internal/user/login_throttle.go
T
2026-06-22 08:55:57 +08:00

121 lines
3.6 KiB
Go

// 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))
}