Throttle: IP-независимый лимит на аккаунт + заметка об устойчивости

Вход игрока и админа получают лимит login-user/admin-login-user, не зависящий
от IP: ротация X-Forwarded-For (#58) больше не снимает защиту полностью.
Успешный вход сбрасывает счётчики аккаунта. В docstring ratelimit — про сброс
при рестарте и необходимость внешнего стора при нескольких воркерах. #60

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
2026-09-13 17:15:04 +03:00
co-authored by Claude Opus 4.8
parent 706eeb0af1
commit 038a788f99
3 changed files with 42 additions and 3 deletions
+10 -1
View File
@@ -27,6 +27,11 @@ _BCRYPT_MAX_BYTES = 72
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
_PAIR_LIMIT = 5
_IP_LIMIT = 20
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
# успешный вход его сбрасывает.
_ACCOUNT_LIMIT = 50
# Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5
@@ -67,14 +72,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None)
nickname = (nickname or "").strip()
ip = ip or "unknown"
pair_key = f"login:{ip}:{nickname.casefold()}"
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT}
account_key = f"login-user:{nickname.casefold()}"
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
login_throttle.check(limits)
try:
user = authenticate_player(session, nickname, password)
except InvalidCredentialsError:
login_throttle.fail(limits)
raise
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
# оставляем — он общий для всех логинов с адреса.
login_throttle.reset(pair_key)
login_throttle.reset(account_key)
return user
+11 -2
View File
@@ -1,7 +1,16 @@
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
Счётчики живут в памяти процесса — как и 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