Files
ForbiddenStarsApp/backend/app/core/errors.py
T
NotBigGhostandClaude Opus 5 82cf7a3393 Вход и регистрация по логину и паролю
Игроки входили только через 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
2026-09-13 13:57:10 +03:00

170 lines
5.0 KiB
Python

"""Доменные исключения и единый конверт ошибок API.
Формат ответа: {"error": {"code": "...", "message": "...", "details": {...}}}
"""
from __future__ import annotations
from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
"""Базовая ошибка приложения со стабильным машинным кодом."""
status_code: int = 400
code: str = "BAD_REQUEST"
def __init__(
self,
message: str,
*,
code: str | None = None,
status_code: int | None = None,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
if code is not None:
self.code = code
if status_code is not None:
self.status_code = status_code
self.details = details
def to_response(self) -> JSONResponse:
return JSONResponse(
status_code=self.status_code,
content={
"error": {
"code": self.code,
"message": self.message,
"details": self.details,
}
},
)
class NotFoundError(AppError):
status_code = 404
code = "NOT_FOUND"
class ForbiddenError(AppError):
status_code = 403
code = "FORBIDDEN"
class ValidationError(AppError):
status_code = 422
code = "VALIDATION_ERROR"
class ConflictError(AppError):
status_code = 409
code = "CONFLICT"
class AuthError(AppError):
status_code = 401
code = "UNAUTHORIZED"
# ─── Конкретные ошибки со стабильными кодами для фронта ───────────────────────
class NoGroupError(AppError):
status_code = 409
code = "NO_GROUP"
def __init__(self) -> None:
super().__init__("Вы не состоите ни в одной группе.")
class NotGroupMemberError(ForbiddenError):
code = "NOT_GROUP_MEMBER"
def __init__(self) -> None:
super().__init__("Вы не являетесь участником этой группы.")
class NicknameTakenError(ConflictError):
code = "NICKNAME_TAKEN"
def __init__(self) -> None:
super().__init__("Никнейм уже занят.")
class TelegramNicknameRequiredError(ConflictError):
"""Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен —
фронт должен показать выбор ника и повторить регистрацию через /telegram/register.
`suggested` идёт в details для предзаполнения поля."""
code = "TELEGRAM_NICKNAME_REQUIRED"
def __init__(self, suggested: str, message: str | None = None) -> None:
super().__init__(
message or f"Ник «{suggested}» уже занят — выберите другой.",
details={"suggested": suggested},
)
class FactionNotAvailableError(ValidationError):
code = "FACTION_NOT_AVAILABLE"
def __init__(self) -> None:
super().__init__("Фракция недоступна выбранной группе.")
class DuplicateParticipantError(ValidationError):
code = "DUPLICATE_PARTICIPANT"
def __init__(self) -> None:
super().__init__("Игрок или фракция повторяются в партии.")
class InvalidRankingError(ValidationError):
code = "INVALID_RANKING"
def __init__(self, message: str = "Некорректная расстановка мест.") -> None:
super().__init__(message)
class InvalidCredentialsError(AuthError):
code = "INVALID_CREDENTIALS"
def __init__(self) -> None:
super().__init__("Неверный логин или пароль.")
class TooManyAttemptsError(AppError):
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
ближайшая попытка снова будет принята."""
status_code = 429
code = "TOO_MANY_ATTEMPTS"
def __init__(self, retry_after: int) -> None:
minutes = max(1, -(-retry_after // 60))
super().__init__(
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
details={"retry_after": retry_after},
)
class TelegramAlreadyLinkedError(ConflictError):
code = "TELEGRAM_ALREADY_LINKED"
def __init__(self) -> None:
super().__init__("К аккаунту уже привязан Telegram.")
class TelegramTakenError(ConflictError):
code = "TELEGRAM_TAKEN"
def __init__(self) -> None:
super().__init__("Этот Telegram уже привязан к другому аккаунту.")
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
return exc.to_response()