Вход игрока и админа получают лимит login-user/admin-login-user, не зависящий от IP: ротация X-Forwarded-For (#58) больше не снимает защиту полностью. Успешный вход сбрасывает счётчики аккаунта. В docstring ratelimit — про сброс при рестарте и необходимость внешнего стора при нескольких воркерах. #60 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
100 lines
5.1 KiB
Python
100 lines
5.1 KiB
Python
"""Вход игрока по логину (нику) и паролю.
|
|
|
|
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с 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
|
|
|
|
|
|
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 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)
|