You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user