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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -181,6 +182,26 @@ def test_throttle_per_ip_across_logins(client: TestClient):
|
||||
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
||||
|
||||
|
||||
def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch):
|
||||
"""Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в
|
||||
IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся."""
|
||||
import app.core.ratelimit as ratelimit
|
||||
from app.auth.password import _ACCOUNT_LIMIT, login_player
|
||||
from app.core.errors import InvalidCredentialsError, TooManyAttemptsError
|
||||
|
||||
now = [3000.0]
|
||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||
_register(client, nickname="Жертва", password=PASSWORD)
|
||||
|
||||
with Session(engine) as s:
|
||||
for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}")
|
||||
# ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт
|
||||
with pytest.raises(TooManyAttemptsError):
|
||||
login_player(s, "Жертва", "wrong-password", ip="203.0.113.7")
|
||||
|
||||
|
||||
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
||||
|
||||
def _set_password(client: TestClient, new: str, current: str | None = None):
|
||||
|
||||
Reference in New Issue
Block a user