"""Ограничение неудачных попыток ввода пароля (защита от перебора). Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn (`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это приемлемо. Ограничения устойчивости (#60): * рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после перезапуска, но окно короткое, а рестарты редки; * при уходе от одного воркера лимит делится между процессами (каждый считает своё) — тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md про SSE-шину), общий для всех воркеров. Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт (`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов (#58) не снимала защиту полностью. """ from __future__ import annotations import threading import time from collections import deque from app.core.errors import TooManyAttemptsError _WINDOW_SECONDS = 15 * 60 # Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток # с разных адресов не копил память бесконечно. _PRUNE_ABOVE = 10_000 class LoginThrottle: """Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока). Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком.""" def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None: self.window = window_seconds self._fails: dict[str, deque[float]] = {} self._lock = threading.Lock() def _recent(self, key: str, now: float) -> deque[float]: attempts = self._fails.get(key) if attempts is None: return deque() while attempts and now - attempts[0] >= self.window: attempts.popleft() if not attempts: del self._fails[key] return attempts def check(self, limits: dict[str, int]) -> None: """Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан.""" now = time.monotonic() with self._lock: for key, limit in limits.items(): attempts = self._recent(key, now) if len(attempts) >= limit: # Попытка снова примется, когда из окна выпадет неудача, после которой # в нём остаётся limit-1 записей. frees_at = attempts[len(attempts) - limit] + self.window raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1) def fail(self, keys: dict[str, int]) -> None: now = time.monotonic() with self._lock: if len(self._fails) > _PRUNE_ABOVE: for key in list(self._fails): self._recent(key, now) for key in keys: self._fails.setdefault(key, deque()).append(now) def reset(self, key: str) -> None: with self._lock: self._fails.pop(key, None) def clear(self) -> None: with self._lock: self._fails.clear() login_throttle = LoginThrottle()