package customerauth import ( "context" "sync" "time" ) // Limiter throttles repeated failures for a key (login email or "reset:"-prefixed // email). It is satisfied by the in-memory LoginLimiter (single-instance / dev) // and by RedisLoginLimiter (shared, horizontally scalable). type Limiter interface { // Allowed reports whether an attempt for key may proceed, and when locked the // remaining lockout duration (for a Retry-After header). Allowed(ctx context.Context, key string) (bool, time.Duration) // RecordFailure registers a failed attempt for key. RecordFailure(ctx context.Context, key string) // Reset clears the failure history for key (call on a successful auth). Reset(ctx context.Context, key string) } // LoginLimiter is an in-memory failed-attempt limiter that locks a key out after // too many failures inside a rolling window. It is per-process — matching the // credential store's single-instance scope; a horizontally-scaled deployment // would need a shared backing store. Keys are typically the normalized email // (login) or a "reset:"-prefixed email (reset requests). type LoginLimiter struct { mu sync.Mutex attempts map[string]*attemptState max int // failures allowed in the window before lockout window time.Duration // rolling window and lockout duration } type attemptState struct { count int first time.Time lockedUntil time.Time } // NewLoginLimiter builds a limiter allowing max failures per window before a // lockout of window duration. Zero/negative values fall back to 5 per 15m. func NewLoginLimiter(max int, window time.Duration) *LoginLimiter { if max <= 0 { max = 5 } if window <= 0 { window = 15 * time.Minute } return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window} } // Allowed reports whether an attempt for key may proceed. When locked it also // returns the remaining lockout duration (suitable for a Retry-After header). A // nil limiter always allows (disabled). func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) { if l == nil { return true, 0 } now := time.Now() l.mu.Lock() defer l.mu.Unlock() st := l.attempts[key] if st == nil { return true, 0 } if now.Before(st.lockedUntil) { return false, st.lockedUntil.Sub(now) } // Window elapsed since the first failure: forget the history. if now.Sub(st.first) > l.window { delete(l.attempts, key) } return true, 0 } // RecordFailure registers a failed attempt for key, locking it for window once // max failures accumulate inside the window. func (l *LoginLimiter) RecordFailure(_ context.Context, key string) { if l == nil { return } now := time.Now() l.mu.Lock() defer l.mu.Unlock() st := l.attempts[key] if st == nil || now.Sub(st.first) > l.window { l.attempts[key] = &attemptState{count: 1, first: now} return } st.count++ if st.count >= l.max { st.lockedUntil = now.Add(l.window) } } // Reset clears the failure history for key. Call it on a successful auth. func (l *LoginLimiter) Reset(_ context.Context, key string) { if l == nil { return } l.mu.Lock() delete(l.attempts, key) l.mu.Unlock() }