package auth import ( "sync" "time" ) const ( maxAttempts = 5 windowMinutes = 15 lockMinutes = 15 ) type entry struct { count int firstAt time.Time lockedUntil time.Time } // RateLimiter tracks failed login attempts per Discord ID in memory. // Resets on bot restart (by design — avoids DB writes on every auth failure). type RateLimiter struct { mu sync.Mutex entries map[string]*entry } func NewRateLimiter() *RateLimiter { return &RateLimiter{entries: make(map[string]*entry)} } // Allow returns true if the given ID is not currently rate-limited. func (r *RateLimiter) Allow(id string) bool { r.mu.Lock() defer r.mu.Unlock() e, ok := r.entries[id] if !ok { return true } if !e.lockedUntil.IsZero() { if time.Now().Before(e.lockedUntil) { return false } // Lock expired — reset delete(r.entries, id) } return true } // Record records a failed attempt. Returns true if the account is now locked. func (r *RateLimiter) Record(id string) bool { r.mu.Lock() defer r.mu.Unlock() e, ok := r.entries[id] if !ok { e = &entry{firstAt: time.Now()} r.entries[id] = e } // Reset window if outside window duration if time.Since(e.firstAt) > windowMinutes*time.Minute { e.count = 0 e.firstAt = time.Now() e.lockedUntil = time.Time{} } e.count++ if e.count >= maxAttempts { e.lockedUntil = time.Now().Add(lockMinutes * time.Minute) return true } return false } // Reset clears the rate limit entry for a Discord ID (call on successful login). func (r *RateLimiter) Reset(id string) { r.mu.Lock() defer r.mu.Unlock() delete(r.entries, id) } // RemainingLock returns the remaining lock duration (zero if not locked). func (r *RateLimiter) RemainingLock(id string) time.Duration { r.mu.Lock() defer r.mu.Unlock() e, ok := r.entries[id] if !ok || e.lockedUntil.IsZero() { return 0 } remaining := time.Until(e.lockedUntil) if remaining < 0 { return 0 } return remaining }