package authsupport import ( "sync" "time" ) // Limiter slows repeated attempts against a single key (an email // address or a client IP) instead of flatly blocking them: each // consecutive failure within the window makes the *next* attempt's // artificial delay longer, capped at maxDelay. That's enough to make // password-guessing or verification-email-spam scripts painfully slow // without needing a CAPTCHA or an IP ban list, and it degrades // gracefully — a genuine user who mistypes their password twice waits a // second, not minutes. // // State is in-memory and per-process, which is fine for enkly's // single-instance deployment (see docker-compose.yml); it resets on // restart and doesn't need a database round-trip on the hot path. type Limiter struct { mu sync.Mutex attempts map[string]*attemptState window time.Duration baseWait time.Duration maxWait time.Duration } type attemptState struct { count int lastSeen time.Time } // NewLimiter builds a Limiter. window is how long a run of failures is // remembered before resetting; baseWait/maxWait bound the artificial // delay (baseWait * 2^(failures-1), capped at maxWait). func NewLimiter(window, baseWait, maxWait time.Duration) *Limiter { return &Limiter{ attempts: make(map[string]*attemptState), window: window, baseWait: baseWait, maxWait: maxWait, } } // Delay returns how long the caller should sleep before processing the // next attempt for key, based on how many recent failures it has seen. // It does not itself sleep or record anything — call RecordFailure / // RecordSuccess to update state. func (l *Limiter) Delay(key string) time.Duration { l.mu.Lock() defer l.mu.Unlock() st, ok := l.attempts[key] if !ok || time.Since(st.lastSeen) > l.window { return 0 } if st.count <= 0 { return 0 } wait := l.baseWait << (st.count - 1) // baseWait * 2^(count-1) if wait > l.maxWait || wait <= 0 { // <=0 guards against overflow wait = l.maxWait } return wait } // RecordFailure increments key's failure streak. func (l *Limiter) RecordFailure(key string) { l.mu.Lock() defer l.mu.Unlock() st, ok := l.attempts[key] if !ok || time.Since(st.lastSeen) > l.window { st = &attemptState{} l.attempts[key] = st } st.count++ st.lastSeen = time.Now() } // RecordSuccess clears key's failure streak. func (l *Limiter) RecordSuccess(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.attempts, key) }