From 706eeb0af1ad0d92f134122a8a00626133469833 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:12:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20(throttle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin_login проходит через LoginThrottle (пара IP+логин, IP и сам аккаунт), как вход игрока; исчерпание лимита -> 429. Неудачные попытки пишутся в аудит (без пароля). Пароль админа — прямой путь к полному контролю, лимиты строже. #56 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/admin_login.py | 49 +++++++++++++++++++++++++++++ backend/app/routers/admin.py | 20 ++++++++++-- backend/tests/test_password_auth.py | 38 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 backend/app/auth/admin_login.py diff --git a/backend/app/auth/admin_login.py b/backend/app/auth/admin_login.py new file mode 100644 index 0000000..7e1d0d9 --- /dev/null +++ b/backend/app/auth/admin_login.py @@ -0,0 +1,49 @@ +"""Вход администратора под защитой от перебора (#56). + +Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин» +и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`). +Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный +барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого. +""" +from __future__ import annotations + +from sqlmodel import Session + +from app.core.errors import InvalidCredentialsError +from app.core.ratelimit import login_throttle +from app.models import User +from app.services import admin_service + +# Неудач за окно LoginThrottle (15 минут): +_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса +_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса +# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно +# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход +# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий). +_ACCOUNT_LIMIT = 50 + + +def _keys(ip: str, username: str) -> dict[str, int]: + pair_key = f"admin-login:{ip}:{username.casefold()}" + return { + pair_key: _PAIR_LIMIT, + f"admin-login-ip:{ip}": _IP_LIMIT, + f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT, + } + + +def login_admin(session: Session, username: str, password: str, ip: str | None) -> User: + """authenticate_admin под защитой от перебора. Сессию открывает вызывающий.""" + username = (username or "").strip() + ip = ip or "unknown" + limits = _keys(ip, username) + login_throttle.check(limits) + try: + admin = admin_service.authenticate_admin(session, username, password) + except InvalidCredentialsError: + login_throttle.fail(limits) + raise + # Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит. + for key in limits: + login_throttle.reset(key) + return admin diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 06aa7e3..a3e5314 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi from fastapi.responses import FileResponse from sqlmodel import Session +from app.auth.admin_login import login_admin from app.auth.deps import get_current_admin from app.core import security from app.core.security import client_ip -from app.core.errors import NotFoundError +from app.core.errors import InvalidCredentialsError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User @@ -40,7 +41,22 @@ def admin_login( response: Response, session: Session = Depends(get_session), ) -> s.AdminMe: - admin = admin_service.authenticate_admin(session, body.username, body.password) + try: + admin = login_admin(session, body.username, body.password, client_ip(request)) + except InvalidCredentialsError: + # Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к + # полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56). + audit_service.record( + session, + actor_id=None, + action="login_failed", + entity_type="admin", + payload={"username": (body.username or "")[:64]}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + session.commit() + raise security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type] audit_service.record( session, diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index be34705..b5ad43a 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -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)