"""Вход игрока по логину (нику) и паролю. Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать. """ from __future__ import annotations from functools import lru_cache from sqlmodel import Session, select from app.core.errors import ( InvalidCredentialsError, ValidationError, WrongCurrentPasswordError, ) from app.core.ratelimit import login_throttle from app.core.security import hash_password, verify_password from app.models import User PASSWORD_MIN_CHARS = 8 # bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает # ValueError — ограничиваем явно, с понятным сообщением. _BCRYPT_MAX_BYTES = 72 # Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора # одного аккаунта; на IP — против перебора по многим логинам с одного адреса. _PAIR_LIMIT = 5 _IP_LIMIT = 20 # На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно # упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал # вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий); # успешный вход его сбрасывает. _ACCOUNT_LIMIT = 50 # Неверный текущий пароль при смене — на аккаунт. _CURRENT_PASSWORD_LIMIT = 5 # Регистраций с одного IP за окно — против спама аккаунтов (#62). _REGISTER_IP_LIMIT = 10 def validate_new_password(password: str) -> None: if len(password) < PASSWORD_MIN_CHARS: raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.") if not password.strip(): raise ValidationError("Пароль не может состоять из одних пробелов.") if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES: raise ValidationError( "Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)." ) @lru_cache def _dummy_hash() -> str: return hash_password("dummy-password-for-timing") def authenticate_player(session: Session, nickname: str, password: str) -> User: """Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie. Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин.""" user = session.exec( select(User).where(User.nickname == nickname, User.role == "player") ).first() stored = user.password_hash if user is not None else None valid = verify_password(password, stored or _dummy_hash()) if user is None or not stored or not valid: raise InvalidCredentialsError() return user def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User: """authenticate_player под защитой от перебора. Сессию открывает вызывающий.""" nickname = (nickname or "").strip() ip = ip or "unknown" pair_key = f"login:{ip}:{nickname.casefold()}" account_key = f"login-user:{nickname.casefold()}" limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT} login_throttle.check(limits) try: user = authenticate_player(session, nickname, password) except InvalidCredentialsError: login_throttle.fail(limits) raise # Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP # оставляем — он общий для всех логинов с адреса. login_throttle.reset(pair_key) login_throttle.reset(account_key) return user def throttle_register(ip: str | None) -> None: """Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62). Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов с уникальными никами упирается в предел так же, как повторы по занятому нику. Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе.""" ip = ip or "unknown" limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT} login_throttle.check(limits) login_throttle.fail(limits) def check_current_password(user: User, current_password: str | None) -> None: """Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя: иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем.""" key = f"current-password:{user.id}" limits = {key: _CURRENT_PASSWORD_LIMIT} login_throttle.check(limits) if not current_password or not verify_password(current_password, user.password_hash or ""): login_throttle.fail(limits) raise WrongCurrentPasswordError() login_throttle.reset(key)