"""Pause password guessing: after too many failed logins from one address, that address's logins are refused for a while. Keyed by client address (steward/core/proxy.py: behind a trusted proxy, the address it reports), not by username, so nobody can lock a user out by failing logins in their name. Held in memory: a restart forgets it, which costs an attacker nothing they couldn't get by waiting, and keeps it free of the database. The limits are Settings → General (login.max_failures, login.lockout_minutes). """ from __future__ import annotations import time from collections import deque class LoginThrottle: def __init__(self) -> None: self._failures: dict[str, deque[float]] = {} def _recent(self, key: str, window_s: float, now: float) -> deque[float]: times = self._failures.get(key) if times is None: return deque() while times and times[0] <= now - window_s: times.popleft() if not times: del self._failures[key] return times def retry_after(self, key: str, max_failures: int, window_s: float, now: float | None = None) -> int: """Seconds until `key` may try again; 0 when it may try now.""" now = time.monotonic() if now is None else now times = self._recent(key, window_s, now) if len(times) < max_failures: return 0 return max(1, int(times[-max_failures] + window_s - now) + 1) def failed(self, key: str, now: float | None = None) -> None: now = time.monotonic() if now is None else now self._failures.setdefault(key, deque()).append(now) def succeeded(self, key: str) -> None: self._failures.pop(key, None) login_throttle = LoginThrottle()