You've already forked agentic-coding-workflow
121 lines
3.6 KiB
Go
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))
|
|
}
|