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
+21
View File
@@ -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):