package handler import ( "net/http" "sync" "time" ) // loginThrottle tracks failed login attempts per key (username and/or // client IP) in memory and locks a key out for a fixed window once it // crosses maxFailedAttempts. There is no persistent brute-force // protection anywhere else in the login path (see Login in routes.go), // so an attacker could otherwise try passwords as fast as the network // allows. type loginThrottle struct { mu sync.Mutex attempts map[string]*throttleEntry } type throttleEntry struct { failures int lockedUntil time.Time windowStart time.Time } const ( maxFailedAttempts = 5 failureWindow = 5 * time.Minute lockoutDuration = 5 * time.Minute throttleSweepEvery = 10 * time.Minute ) var globalLoginThrottle = &loginThrottle{attempts: make(map[string]*throttleEntry)} func init() { go globalLoginThrottle.sweepLoop() } // sweepLoop periodically drops stale entries so the map doesn't grow // unbounded on a long-running instance under sustained scanning. func (t *loginThrottle) sweepLoop() { for { time.Sleep(throttleSweepEvery) now := time.Now() t.mu.Lock() for k, e := range t.attempts { if now.After(e.lockedUntil) && now.Sub(e.windowStart) > failureWindow { delete(t.attempts, k) } } t.mu.Unlock() } } // allow reports whether key (typically remote IP, optionally combined // with the attempted username) is currently allowed to attempt a login. func (t *loginThrottle) allow(key string) bool { t.mu.Lock() defer t.mu.Unlock() e, ok := t.attempts[key] if !ok { return true } return time.Now().After(e.lockedUntil) } // recordFailure registers a failed attempt for key, locking it out once // maxFailedAttempts is reached within failureWindow. func (t *loginThrottle) recordFailure(key string) { t.mu.Lock() defer t.mu.Unlock() now := time.Now() e, ok := t.attempts[key] if !ok || now.Sub(e.windowStart) > failureWindow { e = &throttleEntry{windowStart: now} t.attempts[key] = e } e.failures++ if e.failures >= maxFailedAttempts { e.lockedUntil = now.Add(lockoutDuration) } } // recordSuccess clears any throttle state for key on a successful login, // so a legitimate user isn't penalized by earlier typos once they get it // right. func (t *loginThrottle) recordSuccess(key string) { t.mu.Lock() defer t.mu.Unlock() delete(t.attempts, key) } // tooManyRequests writes a 429 response for a throttled login attempt. func tooManyRequests(w http.ResponseWriter) error { w.WriteHeader(http.StatusTooManyRequests) _, err := w.Write([]byte(`{"success":false,"message":"Too many failed login attempts. Please wait a few minutes and try again."}`)) return err }