Files
ForbiddenStarsApp/backend/app/auth/password.py
T
NotBigGhostandClaude Opus 5 c73b4519cb Пароль и привязка Telegram в профиле, пароль игрока из админки
PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.

POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.

PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.

#24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
2026-09-13 14:02:19 +03:00

91 lines
4.2 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
# Неверный текущий пароль при смене — на аккаунт.
_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()}"
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
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)