Вход админа: защита от перебора (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
+49
View File
@@ -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
+18 -2
View File
@@ -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,