package auth import ( "sync" "time" ) // RateLimiter is an in-memory token bucket per key (ยง7: 10 attempts per // 15 min per IP+email). Buckets refill continuously and idle entries are // swept opportunistically. type RateLimiter struct { mu sync.Mutex buckets map[string]*bucket capacity float64 window time.Duration now func() time.Time // injectable for tests lastGC time.Time } type bucket struct { tokens float64 last time.Time } func NewRateLimiter(attempts int, window time.Duration) *RateLimiter { return &RateLimiter{ buckets: make(map[string]*bucket), capacity: float64(attempts), window: window, now: time.Now, } } // Allow consumes one attempt for key, reporting whether it was within the // limit. func (rl *RateLimiter) Allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := rl.now() rl.gcLocked(now) b, ok := rl.buckets[key] if !ok { b = &bucket{tokens: rl.capacity, last: now} rl.buckets[key] = b } refill := now.Sub(b.last).Seconds() * (rl.capacity / rl.window.Seconds()) b.tokens = min(rl.capacity, b.tokens+refill) b.last = now if b.tokens < 1 { return false } b.tokens-- return true } // gcLocked drops buckets that have fully refilled (idle for >= one window); // runs at most once per window to keep Allow O(1) amortized. func (rl *RateLimiter) gcLocked(now time.Time) { if now.Sub(rl.lastGC) < rl.window { return } rl.lastGC = now for k, b := range rl.buckets { if now.Sub(b.last) >= rl.window { delete(rl.buckets, k) } } }