package httpapi import ( "strings" "sync" "time" ) // Email-keyed login lockout (in-process, per API replica). // // Complements IP RateLimitAuth: rotating IPs still hit the same email budget. // ASSUMPTION (Product 10): a single API instance (or acknowledged per-replica // memory) is acceptable — same posture as HTTP rate limiters in ratelimit.go. // RATE_LIMIT_REPLICAS does not divide this lockout; multi-replica hard caps need edge/WAF. // Captcha is deferred; lockout + IP RPM are the primary login abuse controls. const ( loginLockoutMaxFails = 5 loginLockoutDuration = 15 * time.Minute ) type loginLockState struct { fails int windowStart time.Time lockedUntil time.Time } // loginAttemptLockout tracks failed password attempts by normalized email. type loginAttemptLockout struct { mu sync.Mutex maxFails int lockFor time.Duration state map[string]*loginLockState } func newLoginAttemptLockout(maxFails int, lockFor time.Duration) *loginAttemptLockout { if maxFails < 1 { maxFails = loginLockoutMaxFails } if lockFor <= 0 { lockFor = loginLockoutDuration } return &loginAttemptLockout{ maxFails: maxFails, lockFor: lockFor, state: make(map[string]*loginLockState), } } func normalizeLoginEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } // locked reports whether email is currently locked and Retry-After seconds. func (l *loginAttemptLockout) locked(email string) (bool, int) { key := normalizeLoginEmail(email) if key == "" || l == nil { return false, 0 } now := time.Now() l.mu.Lock() defer l.mu.Unlock() st := l.state[key] if st == nil { return false, 0 } if st.lockedUntil.After(now) { sec := int(st.lockedUntil.Sub(now).Seconds()) + 1 if sec < 1 { sec = 1 } return true, sec } if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) { // Lock expired — reset failure window. delete(l.state, key) } return false, 0 } // recordFailure increments the failure count for email; locks after maxFails // within the lock window. No-ops for empty email. func (l *loginAttemptLockout) recordFailure(email string) { key := normalizeLoginEmail(email) if key == "" || l == nil { return } now := time.Now() l.mu.Lock() defer l.mu.Unlock() st := l.state[key] if st == nil { st = &loginLockState{windowStart: now} l.state[key] = st } if st.lockedUntil.After(now) { return } if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) { st.fails = 0 st.windowStart = now st.lockedUntil = time.Time{} } if now.Sub(st.windowStart) > l.lockFor { st.fails = 0 st.windowStart = now } st.fails++ if st.fails >= l.maxFails { st.lockedUntil = now.Add(l.lockFor) st.fails = 0 st.windowStart = now } } // clear resets failures and lock for email (successful login). func (l *loginAttemptLockout) clear(email string) { key := normalizeLoginEmail(email) if key == "" || l == nil { return } l.mu.Lock() defer l.mu.Unlock() delete(l.state, key) } func (s *Server) loginAttempts() *loginAttemptLockout { if s == nil { return newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration) } s.loginLockoutOnce.Do(func() { s.loginLockout = newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration) }) return s.loginLockout }