Вход админа: защита от перебора (throttle)

admin_login проходит через LoginThrottle (пара IP+логин, IP и сам аккаунт),
как вход игрока; исчерпание лимита -> 429. Неудачные попытки пишутся в аудит
(без пароля). Пароль админа — прямой путь к полному контролю, лимиты строже. #56

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:12:40 +03:00
co-authored by Claude Opus 4.8
parent 3f8667b561
commit 706eeb0af1
3 changed files with 105 additions and 2 deletions
+38
View File
@@ -417,3 +417,41 @@ def test_player_cannot_set_passwords_via_admin(client: TestClient):
headers=csrf_headers(client),
)
assert r.status_code == 401
# ─── Защита от перебора пароля администратора (#56, F1) ───────────────────────
def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch):
import app.core.ratelimit as ratelimit
now = [2000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
make_admin("boss", "secret123")
for _ in range(5):
r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"})
assert r.status_code == 401
blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
assert blocked.status_code == 429 # даже верный пароль не проверяется
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
now[0] += 15 * 60 # окно истекло
ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
assert ok.status_code == 200
def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine):
from app.models import AuditLog
make_admin("boss", "secret123")
assert client.post(
"/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"}
).status_code == 401
with Session(engine) as s:
logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all()
assert any(
log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs
)
assert all("nope-secret-guess" not in str(log.payload) for log in logs)