Вход и регистрация по логину и паролю
Игроки входили только через Telegram, а вход по нику без пароля (stub) есть лишь в dev. Теперь основной вход во всех окружениях: POST /api/auth/register и POST /api/auth/login, логин — это ник. Stub в прод не переносится: он пускает без секрета и по-прежнему живёт только в dev. Новый код лежит в прод-модуле auth/password.py и dev-модули не импортирует. Схема БД не меняется: колонка password_hash и провайдер local есть с первой миграции, на них построен вход админа. Пароль от 8 символов и не длиннее 72 байт: дальше bcrypt 5 бросает ValueError. Схема API режет тело длиннее 128 символов ещё до bcrypt. Игроком входит только role='player', так что учётка админа не открывает сессию игрока, и наоборот. Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем и получают ту же 401 INVALID_CREDENTIALS: по ответу и его времени нельзя понять, есть ли логин. От перебора — скользящее окно 15 минут в памяти процесса (рассчитано на один воркер, как SSE-шина): 5 неудач на пару «IP + логин» и 20 на IP, дальше 429 TOO_MANY_ATTEMPTS с retry_after. Пока блок стоит, пароль не проверяется вовсе. Успешный вход сбрасывает счётчик пары, но не IP. В MeRead появилось has_password: по нему фронт попросит задать пароль тех, у кого его нет. Метод password добавлен в /auth/config. #24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||
|
||||
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
|
||||
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user