"""Вход администратора под защитой от перебора (#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