Вход и регистрация по логину и паролю
Игроки входили только через Telegram, а вход по нику без пароля (stub) есть лишь в dev. Теперь основной вход во всех окружениях: POST /api/auth/register и POST /api/auth/login, логин — это ник. Stub в прод не переносится: он пускает без секрета и по-прежнему живёт только в dev. Новый код лежит в прод-модуле auth/password.py и dev-модули не импортирует. Схема БД не меняется: колонка password_hash и провайдер local есть с первой миграции, на них построен вход админа. Пароль от 8 символов и не длиннее 72 байт: дальше bcrypt 5 бросает ValueError. Схема API режет тело длиннее 128 символов ещё до bcrypt. Игроком входит только role='player', так что учётка админа не открывает сессию игрока, и наоборот. Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем и получают ту же 401 INVALID_CREDENTIALS: по ответу и его времени нельзя понять, есть ли логин. От перебора — скользящее окно 15 минут в памяти процесса (рассчитано на один воркер, как SSE-шина): 5 неудач на пару «IP + логин» и 20 на IP, дальше 429 TOO_MANY_ATTEMPTS с retry_after. Пока блок стоит, пароль не проверяется вовсе. Успешный вход сбрасывает счётчик пары, но не IP. В MeRead появилось has_password: по нему фронт попросит задать пароль тех, у кого его нет. Метод password добавлен в /auth/config. #24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Вход игрока по логину (нику) и паролю.
|
||||
|
||||
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с 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
|
||||
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
|
||||
|
||||
|
||||
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()}"
|
||||
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT}
|
||||
login_throttle.check(limits)
|
||||
try:
|
||||
user = authenticate_player(session, nickname, password)
|
||||
except InvalidCredentialsError:
|
||||
login_throttle.fail(limits)
|
||||
raise
|
||||
login_throttle.reset(pair_key)
|
||||
return user
|
||||
Reference in New Issue
Block a user