Комментарии про методы входа приведены к факту (логин/пароль и Telegram везде, stub только в development): config.py, .env.example, telegram.py, dev_stub.py, dev_auth.py, login.py. admin_login.py больше не обещает «строже игроцкого» — описаны реальные лимиты; client_ip — адрес за прокси уже даёт uvicorn. Подсказки деплоя: run.ps1/run.sh (build-push на ПК, `up -d` на Pi), compose, .env.example (build-push.ps1), Caddyfile (туннель-контейнер вместо autossh); ratelimit.py ссылается на core/events.py вместо CLAUDE.md. Удалены неиспользуемые scripts/export-prod.sh (с блоком export-ignore в .gitattributes и упоминаниями в README), db/init_db.py и случайно закоммиченный файл «h -u origin dev…». #70 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
51 lines
2.6 KiB
Python
51 lines
2.6 KiB
Python
"""Вход администратора под защитой от перебора (#56).
|
|
|
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
|
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
|
|
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
|
|
включая IP.
|
|
"""
|
|
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
|