From 82cf7a33932aa1ebb8eafb6624e6bc6b99b569fb Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 13:57:10 +0300 Subject: [PATCH 01/13] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=D1=83=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Игроки входили только через 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 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 72 +++++++++++ backend/app/auth/registry.py | 4 +- backend/app/core/errors.py | 29 +++++ backend/app/core/ratelimit.py | 70 +++++++++++ backend/app/routers/auth.py | 44 ++++++- backend/app/routers/users.py | 1 + backend/app/schemas/api.py | 21 +++- backend/app/services/user_service.py | 22 ++++ backend/tests/conftest.py | 2 + backend/tests/test_auth.py | 9 +- backend/tests/test_password_auth.py | 179 +++++++++++++++++++++++++++ 11 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 backend/app/auth/password.py create mode 100644 backend/app/core/ratelimit.py create mode 100644 backend/tests/test_password_auth.py diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py new file mode 100644 index 0000000..8dd5377 --- /dev/null +++ b/backend/app/auth/password.py @@ -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 diff --git a/backend/app/auth/registry.py b/backend/app/auth/registry.py index 952782a..2af0588 100644 --- a/backend/app/auth/registry.py +++ b/backend/app/auth/registry.py @@ -1,6 +1,6 @@ """Доступные методы входа по окружению. -Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG). +Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development. Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода. """ from __future__ import annotations @@ -9,7 +9,7 @@ from app.core.config import settings def enabled_methods() -> list[str]: - methods = ["telegram"] + methods = ["password", "telegram"] if settings.is_development: methods.append("stub") return methods diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index b82911a..ad21e09 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -136,5 +136,34 @@ class InvalidCredentialsError(AuthError): 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() diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py new file mode 100644 index 0000000..0a6176a --- /dev/null +++ b/backend/app/core/ratelimit.py @@ -0,0 +1,70 @@ +"""Ограничение неудачных попыток ввода пароля (защита от перебора). + +Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn. +Перезапуск их обнуляет; для окна в 15 минут это приемлемо. +""" +from __future__ import annotations + +import threading +import time +from collections import deque + +from app.core.errors import TooManyAttemptsError + +_WINDOW_SECONDS = 15 * 60 +# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток +# с разных адресов не копил память бесконечно. +_PRUNE_ABOVE = 10_000 + + +class LoginThrottle: + """Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока). + + Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком.""" + + def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None: + self.window = window_seconds + self._fails: dict[str, deque[float]] = {} + self._lock = threading.Lock() + + def _recent(self, key: str, now: float) -> deque[float]: + attempts = self._fails.get(key) + if attempts is None: + return deque() + while attempts and now - attempts[0] >= self.window: + attempts.popleft() + if not attempts: + del self._fails[key] + return attempts + + def check(self, limits: dict[str, int]) -> None: + """Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан.""" + now = time.monotonic() + with self._lock: + for key, limit in limits.items(): + attempts = self._recent(key, now) + if len(attempts) >= limit: + # Попытка снова примется, когда из окна выпадет неудача, после которой + # в нём остаётся limit-1 записей. + frees_at = attempts[len(attempts) - limit] + self.window + raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1) + + def fail(self, keys: dict[str, int]) -> None: + now = time.monotonic() + with self._lock: + if len(self._fails) > _PRUNE_ABOVE: + for key in list(self._fails): + self._recent(key, now) + for key in keys: + self._fails.setdefault(key, deque()).append(now) + + def reset(self, key: str) -> None: + with self._lock: + self._fails.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._fails.clear() + + +login_throttle = LoginThrottle() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index d3c0ba2..5508402 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ -"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход. +"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход. -Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev. +Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev. """ from __future__ import annotations @@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session from app.auth.login import establish_session +from app.auth.password import login_player from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security from app.core.config import settings from app.core.errors import TelegramNicknameRequiredError +from app.core.security import client_ip from app.db.session import get_session from app.routers.users import build_me from app.schemas import api as s -from app.services import user_service +from app.services import audit_service, user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -29,6 +31,42 @@ def auth_config() -> s.AuthConfig: ) +@router.post("/register", response_model=s.MeRead) +def password_register( + body: s.PasswordRegister, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" + user = user_service.register_local(session, body.nickname, body.password) + audit_service.record( + session, + actor_id=user.id, + action="create", + entity_type="user", + entity_id=user.id, + payload={"provider": "local"}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + establish_session(session, response, request, user, "local") + return build_me(session, user) + + +@router.post("/login", response_model=s.MeRead) +def password_login( + body: s.PasswordLogin, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.""" + user = login_player(session, body.nickname, body.password, client_ip(request)) + establish_session(session, response, request, user, "local") + return build_me(session, user) + + @router.post("/telegram", response_model=s.MeRead) def telegram_login( body: s.TelegramAuthPayload, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index cb0e36a..917a16f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -36,6 +36,7 @@ def build_me(session: Session, user: User) -> s.MeRead: history_mode=user.history_mode, history_detail=user.history_detail, groups=groups, + has_password=user.password_hash is not None, ) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 4a3288e..352a836 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -12,11 +12,27 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"] # ─── Auth ──────────────────────────────────────────────────────────────────── class AuthConfig(BaseModel): - # Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве. + # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. methods: list[str] = [] telegram_bot_username: str | None = None +# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt. +# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py. +_PASSWORD_MAX_CHARS = 128 + + +class PasswordLogin(BaseModel): + # Логин — это ник игрока. + nickname: str + password: str = Field(max_length=_PASSWORD_MAX_CHARS) + + +class PasswordRegister(BaseModel): + nickname: str + password: str = Field(max_length=_PASSWORD_MAX_CHARS) + + class TelegramAuthPayload(BaseModel): # Полезная нагрузка Telegram Login Widget (проверяется по HMAC). model_config = ConfigDict(extra="allow") @@ -99,6 +115,9 @@ class UserRead(BaseModel): class MeRead(UserRead): groups: list[GroupBrief] = [] + # False — пароль ещё не задан (аккаунт из Telegram или до появления паролей): + # фронт не пускает дальше окна установки пароля. + has_password: bool = False class NicknameUpdate(BaseModel): diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 987eeda..545b242 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -7,9 +7,11 @@ from pathlib import Path from sqlmodel import Session, select +from app.auth.password import validate_new_password from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError +from app.core.security import hash_password from app.core.timeutil import utcnow from app.models import AuthIdentity, Faction, User @@ -132,6 +134,26 @@ def register_from_identity( return _create_from_identity(session, identity, nickname) +def register_local(session: Session, nickname: str, password: str) -> User: + """Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже.""" + nickname = (nickname or "").strip() + if not nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not nickname_available(session, nickname): + raise NicknameTakenError() + validate_new_password(password) + user = User( + nickname=nickname, + role="player", + auth_provider="local", + password_hash=hash_password(password), + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not nickname_format_ok(new_nickname): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 27cc48f..8d51281 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,6 +18,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402 from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 import app.models # noqa: F401,E402 (регистрация моделей) +from app.core.ratelimit import login_throttle # noqa: E402 from app.core.security import hash_password # noqa: E402 from app.db.session import get_session # noqa: E402 from app.main import app # noqa: E402 @@ -48,6 +49,7 @@ def client(engine): yield s app.dependency_overrides[get_session] = _get_session + login_throttle.clear() # счётчики неудачных входов глобальны для процесса with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 184be38..7079c1b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -10,8 +10,9 @@ from fastapi.testclient import TestClient from tests.conftest import csrf_headers -def test_auth_config_dev_has_both_methods(client: TestClient): +def test_auth_config_dev_has_all_methods(client: TestClient): cfg = client.get("/api/auth/config").json() + assert "password" in cfg["methods"] assert "telegram" in cfg["methods"] assert "stub" in cfg["methods"] # dev → доступен вход по нику @@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch): from app.core.config import settings monkeypatch.setattr(settings, "app_env", "development") - assert set(enabled_methods()) == {"telegram", "stub"} + assert set(enabled_methods()) == {"password", "telegram", "stub"} monkeypatch.setattr(settings, "app_env", "test") - assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram + assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub monkeypatch.setattr(settings, "app_env", "production") - assert enabled_methods() == ["telegram"] # prod → только Telegram + assert enabled_methods() == ["password", "telegram"] # prod → без stub def test_env_flags_and_db_path(monkeypatch): diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py new file mode 100644 index 0000000..e00e31e --- /dev/null +++ b/backend/tests/test_password_auth.py @@ -0,0 +1,179 @@ +"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора.""" +from __future__ import annotations + +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.models import User +from tests.conftest import csrf_headers + +PASSWORD = "correct-horse" + + +def _register(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD): + return client.post( + "/api/auth/register", + json={"nickname": nickname, "password": password}, + headers=csrf_headers(client), + ) + + +def _login(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD): + return client.post( + "/api/auth/login", + json={"nickname": nickname, "password": password}, + headers=csrf_headers(client), + ) + + +# ─── Регистрация ───────────────────────────────────────────────────────────── + +def test_register_opens_session(client: TestClient, engine): + r = _register(client) + assert r.status_code == 200, r.text + me = r.json() + assert me["nickname"] == "Игрок" + assert me["auth_provider"] == "local" + assert me["has_password"] is True + assert client.cookies.get("fs_session") + + assert client.get("/api/users/me").json()["id"] == me["id"] + with Session(engine) as s: + user = s.get(User, me["id"]) + assert user.password_hash and PASSWORD not in user.password_hash + + +def test_register_taken_nickname(client: TestClient): + assert _register(client).status_code == 200 + client.cookies.clear() + r = _register(client, password="another-pass") + assert r.status_code == 409 + assert r.json()["error"]["code"] == "NICKNAME_TAKEN" + + +def test_register_rejects_bad_passwords(client: TestClient): + for bad in ["short", " ", "я" * 37]: # короткий, пробелы, 74 байта UTF-8 + r = _register(client, password=bad) + assert r.status_code == 422, (bad, r.text) + assert _register(client, password="x" * 129).status_code == 422 # предел схемы + + +def test_register_rejects_bad_nickname(client: TestClient): + assert _register(client, nickname="x").status_code == 422 + + +# ─── Вход ──────────────────────────────────────────────────────────────────── + +def test_login_after_logout(client: TestClient): + uid = _register(client).json()["id"] + assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200 + client.cookies.clear() + + r = _login(client) + assert r.status_code == 200, r.text + assert r.json()["id"] == uid + + +def test_wrong_password_and_unknown_login_look_the_same(client: TestClient): + _register(client) + client.cookies.clear() + + wrong = _login(client, password="wrong-password") + unknown = _login(client, nickname="Никто") + assert wrong.status_code == unknown.status_code == 401 + assert wrong.json() == unknown.json() + assert wrong.json()["error"]["code"] == "INVALID_CREDENTIALS" + assert "fs_session" not in client.cookies + + +def test_admin_credentials_do_not_open_player_session(client: TestClient, make_admin): + make_admin("boss", "secret123") + r = _login(client, nickname="boss", password="secret123") + assert r.status_code == 401 + assert "fs_session" not in client.cookies + + +def test_player_password_does_not_open_admin_session(client: TestClient): + _register(client) + client.cookies.clear() + r = client.post("/api/admin/auth/login", json={"username": "Игрок", "password": PASSWORD}) + assert r.status_code == 401 + + +def test_account_without_password_cannot_log_in(client: TestClient, engine): + with Session(engine) as s: + s.add(User(nickname="Телеграмщик", role="player", auth_provider="telegram")) + s.commit() + r = _login(client, nickname="Телеграмщик", password="anything-at-all") + assert r.status_code == 401 + + +def test_disabled_account_cannot_log_in(client: TestClient, engine): + uid = _register(client).json()["id"] + client.cookies.clear() + with Session(engine) as s: + user = s.get(User, uid) + user.is_active = False + s.add(user) + s.commit() + + r = _login(client) + assert r.status_code == 403 + assert r.json()["error"]["code"] == "ACCOUNT_DISABLED" + + +def test_login_is_audited_without_secrets(client: TestClient, engine): + from app.models import AuditLog + + uid = _register(client).json()["id"] + with Session(engine) as s: + logs = s.exec(select(AuditLog).where(AuditLog.entity_id == uid)).all() + assert {(log.action, (log.payload or {}).get("provider")) for log in logs} >= { + ("create", "local"), + ("login", "local"), + } + assert all(PASSWORD not in str(log.payload) for log in logs) + + +# ─── Защита от перебора ────────────────────────────────────────────────────── + +def test_throttle_blocks_after_five_failures(client: TestClient, monkeypatch): + import app.core.ratelimit as ratelimit + + now = [1000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + + _register(client) + client.cookies.clear() + for _ in range(5): + assert _login(client, password="wrong-password").status_code == 401 + + blocked = _login(client) # даже верный пароль не проверяется + assert blocked.status_code == 429 + err = blocked.json()["error"] + assert err["code"] == "TOO_MANY_ATTEMPTS" + assert 0 < err["details"]["retry_after"] <= 15 * 60 + 1 + assert "fs_session" not in client.cookies + + now[0] += 15 * 60 # окно истекло + assert _login(client).status_code == 200 + + +def test_success_resets_pair_counter(client: TestClient): + _register(client) + client.cookies.clear() + for _ in range(4): + assert _login(client, password="wrong-password").status_code == 401 + assert _login(client).status_code == 200 + + client.cookies.clear() + for _ in range(4): + assert _login(client, password="wrong-password").status_code == 401 + assert _login(client).status_code == 200 + + +def test_throttle_per_ip_across_logins(client: TestClient): + """С одного адреса нельзя перебирать пароли по многим логинам: 20 неудач — блок.""" + for i in range(20): + assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401 + assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429 From c73b4519cbdaba35ef08e50b44b74f8cfd852b5e Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 14:02:19 +0300 Subject: [PATCH 02/13] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20Tel?= =?UTF-8?q?egram=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5,=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D0=B8=D0=B3=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 20 ++- backend/app/core/errors.py | 7 + backend/app/routers/admin.py | 57 +++++--- backend/app/routers/users.py | 52 ++++++++ backend/app/schemas/api.py | 12 ++ backend/app/services/admin_service.py | 11 ++ backend/app/services/user_service.py | 47 ++++++- backend/tests/test_password_auth.py | 181 ++++++++++++++++++++++++++ 8 files changed, 365 insertions(+), 22 deletions(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index 8dd5377..b40058b 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -9,7 +9,11 @@ from functools import lru_cache from sqlmodel import Session, select -from app.core.errors import InvalidCredentialsError, ValidationError +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 @@ -23,6 +27,8 @@ _BCRYPT_MAX_BYTES = 72 # одного аккаунта; на IP — против перебора по многим логинам с одного адреса. _PAIR_LIMIT = 5 _IP_LIMIT = 20 +# Неверный текущий пароль при смене — на аккаунт. +_CURRENT_PASSWORD_LIMIT = 5 def validate_new_password(password: str) -> None: @@ -70,3 +76,15 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None) 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) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index ad21e09..e502f40 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -151,6 +151,13 @@ class TooManyAttemptsError(AppError): ) +class WrongCurrentPasswordError(ForbiddenError): + code = "WRONG_CURRENT_PASSWORD" + + def __init__(self) -> None: + super().__init__("Текущий пароль неверен.") + + class TelegramAlreadyLinkedError(ConflictError): code = "TELEGRAM_ALREADY_LINKED" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 5d58805..0448c95 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -68,24 +68,26 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe: # ─── Пользователи ──────────────────────────────────────────────────────────── +def _admin_user_read(u: User) -> s.AdminUserRead: + return s.AdminUserRead( + id=u.id, # type: ignore[arg-type] + nickname=u.nickname, + role=u.role, + is_active=u.is_active, + auth_provider=u.auth_provider, + telegram_id=u.telegram_id, + created_at=iso_utc(u.created_at), + has_password=u.password_hash is not None, + ) + + @router.get("/users", response_model=list[s.AdminUserRead]) def list_users( query: str | None = Query(None), session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.AdminUserRead]: - return [ - s.AdminUserRead( - id=u.id, # type: ignore[arg-type] - nickname=u.nickname, - role=u.role, - is_active=u.is_active, - auth_provider=u.auth_provider, - telegram_id=u.telegram_id, - created_at=iso_utc(u.created_at), - ) - for u in admin_service.list_users(session, query) - ] + return [_admin_user_read(u) for u in admin_service.list_users(session, query)] @router.patch("/users/{user_id}", response_model=s.AdminUserRead) @@ -107,15 +109,30 @@ def update_user( ip=client_ip(request), ) session.commit() - return s.AdminUserRead( - id=u.id, # type: ignore[arg-type] - nickname=u.nickname, - role=u.role, - is_active=u.is_active, - auth_provider=u.auth_provider, - telegram_id=u.telegram_id, - created_at=iso_utc(u.created_at), + return _admin_user_read(u) + + +@router.put("/users/{user_id}/password", response_model=s.AdminUserRead) +def set_user_password( + user_id: int, + body: s.AdminPasswordSet, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.AdminUserRead: + """Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется.""" + u = admin_service.set_player_password(session, user_id, body.new_password) + audit_service.record( + session, + actor_id=admin.id, + action="update", + entity_type="user", + entity_id=user_id, + payload={"password": "set_by_admin"}, + ip=client_ip(request), ) + session.commit() + return _admin_user_read(u) # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 917a16f..648b056 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -6,6 +6,8 @@ from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user +from app.auth.password import check_current_password +from app.auth.telegram import TelegramProvider from app.core.security import client_ip from app.core.errors import NotFoundError from app.db.session import get_session @@ -116,6 +118,56 @@ def update_my_profile( return build_me(session, user) +@router.put("/me/password", response_model=s.MeRead) +def change_my_password( + body: s.PasswordChange, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + """Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).""" + had_password = user.password_hash is not None + if had_password: + check_current_password(user, body.current_password) + user_service.set_password(session, user, body.new_password) + audit_service.record( + session, + actor_id=user.id, + action="update", + entity_type="user", + entity_id=user.id, + payload={"password": "changed" if had_password else "set"}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return build_me(session, user) + + +@router.post("/me/telegram", response_model=s.MeRead) +def link_my_telegram( + body: s.TelegramAuthPayload, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + """Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе.""" + identity = TelegramProvider().authenticate(body.model_dump()) + user_service.link_telegram(session, user, identity) + audit_service.record( + session, + actor_id=user.id, + action="update", + entity_type="user", + entity_id=user.id, + payload={"telegram": "linked"}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + session.commit() + return build_me(session, user) + + @router.put("/me/avatar", response_model=s.MeRead) def upload_my_avatar( file: UploadFile = File(...), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 352a836..60026aa 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -33,6 +33,12 @@ class PasswordRegister(BaseModel): password: str = Field(max_length=_PASSWORD_MAX_CHARS) +class PasswordChange(BaseModel): + # current_password нужен, только если пароль уже задан; первая установка — без него. + current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS) + new_password: str = Field(max_length=_PASSWORD_MAX_CHARS) + + class TelegramAuthPayload(BaseModel): # Полезная нагрузка Telegram Login Widget (проверяется по HMAC). model_config = ConfigDict(extra="allow") @@ -512,6 +518,7 @@ class AdminUserRead(BaseModel): auth_provider: str telegram_id: int | None = None created_at: str + has_password: bool = False class AdminUserUpdate(BaseModel): @@ -519,6 +526,11 @@ class AdminUserUpdate(BaseModel): is_active: bool | None = None +class AdminPasswordSet(BaseModel): + # Новый пароль игроку от админа — способ восстановить забытый пароль. + new_password: str = Field(max_length=_PASSWORD_MAX_CHARS) + + class AdminGroupRead(BaseModel): id: int name: str diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 552f21d..d9b2334 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, return user +def set_player_password(session: Session, user_id: int, new_password: str) -> User: + """Новый пароль игроку (восстановление забытого). Пароль админа так не меняется — + он задаётся ADMIN_PASSWORD в .env.""" + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + if user.role != "player": + raise ValidationError("Пароль администратора здесь не меняется.") + return user_service.set_password(session, user, new_password) + + # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: # логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 545b242..b51bc1e 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -10,7 +10,13 @@ from sqlmodel import Session, select from app.auth.password import validate_new_password from app.auth.provider import ExternalIdentity from app.core.config import settings -from app.core.errors import NicknameTakenError, NotFoundError, ValidationError +from app.core.errors import ( + NicknameTakenError, + NotFoundError, + TelegramAlreadyLinkedError, + TelegramTakenError, + ValidationError, +) from app.core.security import hash_password from app.core.timeutil import utcnow from app.models import AuthIdentity, Faction, User @@ -154,6 +160,45 @@ def register_local(session: Session, nickname: str, password: str) -> User: return user +def set_password(session: Session, user: User, new_password: str) -> User: + """Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).""" + validate_new_password(new_password) + user.password_hash = hash_password(new_password) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User: + """Привязать Telegram к существующему аккаунту; ник не меняется. + + После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит + его по той же связке provider+external_id, что создаёт регистрация через Telegram.""" + already = session.exec( + select(AuthIdentity).where( + AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider + ) + ).first() + if user.telegram_id is not None or already is not None: + raise TelegramAlreadyLinkedError() + taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first() + if find_by_identity(session, identity) is not None or taken_by_id is not None: + raise TelegramTakenError() + user.telegram_id = identity.telegram_id + session.add(user) + session.add( + AuthIdentity( + user_id=user.id, # type: ignore[arg-type] + provider=identity.provider, + external_id=identity.external_id, + ) + ) + session.commit() + session.refresh(user) + return user + + def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not nickname_format_ok(new_nickname): diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index e00e31e..1896b32 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -6,6 +6,7 @@ from sqlmodel import Session, select from app.models import User from tests.conftest import csrf_headers +from tests.test_auth import _telegram_payload PASSWORD = "correct-horse" @@ -177,3 +178,183 @@ def test_throttle_per_ip_across_logins(client: TestClient): for i in range(20): assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401 assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429 + + +# ─── Установка и смена пароля ──────────────────────────────────────────────── + +def _set_password(client: TestClient, new: str, current: str | None = None): + body = {"new_password": new} + if current is not None: + body["current_password"] = current + return client.put("/api/users/me/password", json=body, headers=csrf_headers(client)) + + +def _telegram_login(client: TestClient, monkeypatch, **fields): + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + return client.post( + "/api/auth/telegram", + json=_telegram_payload("TEST_BOT_TOKEN", **fields), + headers=csrf_headers(client), + ) + + +def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch): + """Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику.""" + r = _telegram_login(client, monkeypatch) + assert r.status_code == 200, r.text + me = r.json() + assert me["has_password"] is False + + r2 = _set_password(client, PASSWORD) + assert r2.status_code == 200, r2.text + assert r2.json()["has_password"] is True + + client.cookies.clear() + r3 = _login(client, nickname=me["nickname"]) + assert r3.status_code == 200, r3.text + assert r3.json()["id"] == me["id"] + + +def test_change_password_requires_current(client: TestClient): + _register(client) + + missing = _set_password(client, "new-password-1") + assert missing.status_code == 403 + assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD" + assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403 + + assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200 + client.cookies.clear() + assert _login(client).status_code == 401 + assert _login(client, password="new-password-1").status_code == 200 + + +def test_change_password_validates_new(client: TestClient): + _register(client) + r = _set_password(client, "short", current=PASSWORD) + assert r.status_code == 422 + client.cookies.clear() + assert _login(client).status_code == 200 # старый пароль не тронут + + +def test_current_password_guessing_is_throttled(client: TestClient): + _register(client) + for _ in range(5): + assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403 + blocked = _set_password(client, "new-password-1", current=PASSWORD) + assert blocked.status_code == 429 + + +def test_set_password_requires_session(client: TestClient): + assert _set_password(client, PASSWORD).status_code == 401 + + +# ─── Привязка Telegram ─────────────────────────────────────────────────────── + +def _link_telegram(client: TestClient, monkeypatch, **fields): + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + return client.post( + "/api/users/me/telegram", + json=_telegram_payload("TEST_BOT_TOKEN", **fields), + headers=csrf_headers(client), + ) + + +def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch): + """Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт.""" + uid = _register(client).json()["id"] + + r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg + assert r.status_code == 200, r.text + assert r.json()["telegram_id"] == 777 + assert r.json()["nickname"] == "Игрок" # ник не меняется на тег + + client.cookies.clear() + r2 = _telegram_login(client, monkeypatch) + assert r2.status_code == 200, r2.text + assert r2.json()["id"] == uid + assert r2.json()["nickname"] == "Игрок" + + +def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch): + assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то + client.cookies.clear() + _register(client) + + r = _link_telegram(client, monkeypatch) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "TELEGRAM_TAKEN" + + +def test_link_telegram_twice(client: TestClient, monkeypatch): + _register(client) + assert _link_telegram(client, monkeypatch).status_code == 200 + r = _link_telegram(client, monkeypatch, id=778) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED" + + +def test_link_telegram_bad_signature(client: TestClient, monkeypatch): + from app.core.config import settings + + _register(client) + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + payload = _telegram_payload("TEST_BOT_TOKEN") + payload["hash"] = "deadbeef" + r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client)) + assert r.status_code == 401 + assert client.get("/api/users/me").json()["telegram_id"] is None + + +# ─── Пароль игроку из админки ──────────────────────────────────────────────── + +def _admin_login(client: TestClient, make_admin): + make_admin("boss", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "boss", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin): + player = _telegram_login(client, monkeypatch).json() + client.cookies.clear() + _admin_login(client, make_admin) + + r = client.put( + f"/api/admin/users/{player['id']}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert r.json()["has_password"] is True + + client.cookies.clear() + assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200 + + +def test_admin_cannot_set_admin_password(client: TestClient, make_admin): + admin_id = _admin_login(client, make_admin) + r = client.put( + f"/api/admin/users/{admin_id}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 422 + + +def test_player_cannot_set_passwords_via_admin(client: TestClient): + uid = _register(client).json()["id"] + r = client.put( + f"/api/admin/users/{uid}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 401 From 462079d8358f1d3e3dec281257a68ece11d3acf2 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 14:11:57 +0300 Subject: [PATCH 03/13] =?UTF-8?q?=D0=A4=D1=80=D0=BE=D0=BD=D1=82:=20=D0=B2?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=20=D0=BF=D0=BE=20=D0=BF=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8E,=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8F,=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница входа начинается с карточки «Вход» (ник и пароль), в ней же переключатель на регистрацию с повтором пароля. Telegram стоит ниже вторым способом. Под ним подсказка: аккаунт, созданный по паролю, сначала привязывают к Telegram в профиле, иначе вход через Telegram заведёт новый аккаунт. Поле логина без автозаглавной буквы и автоисправления, у полей правильный autocomplete для менеджеров паролей. SetPasswordDialog показывается в AppShell всем, у кого has_password=false: аккаунтам из Telegram и созданным до паролей. Закрыть окно нельзя, только задать пароль или выйти. Успешный ответ кладёт в кэш профиль с has_password=true, и окно исчезает само. Кнопка «Позже (dev)» есть лишь под import.meta.env.DEV: в прод-бандле её строки нет, это проверено по dist. В профиле появилась карточка «Вход в аккаунт»: смена пароля (текущий, если он задан, новый и повтор) и привязка Telegram тем же виджетом, что при входе. У смены ника подсказка, что ник — это и логин. В админке у игрока появилась кнопка «Задать пароль» с вводом в той же строке, как у переименования, а в строке игрока — пометка «без пароля». schema.d.ts перегенерирован с живого бэкенда. #24 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- frontend/src/api/schema.d.ts | 303 ++++++++++++++++++ frontend/src/components/AppShell.tsx | 4 + frontend/src/components/SetPasswordDialog.tsx | 95 ++++++ frontend/src/hooks/admin.ts | 15 + frontend/src/hooks/auth.ts | 46 +++ frontend/src/pages/AccountPage.tsx | 128 ++++++++ frontend/src/pages/LoginPage.tsx | 140 +++++++- .../src/pages/admin/AdminAccountsPage.tsx | 61 +++- 8 files changed, 775 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/SetPasswordDialog.tsx diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 371d564..d245781 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -21,6 +21,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Password Register + * @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле. + */ + post: operations["password_register_api_auth_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Password Login + * @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429. + */ + post: operations["password_login_api_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/telegram": { parameters: { query?: never; @@ -122,6 +162,46 @@ export interface paths { patch: operations["update_my_profile_api_users_me_profile_patch"]; trace?: never; }; + "/api/users/me/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Change My Password + * @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий). + */ + put: operations["change_my_password_api_users_me_password_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/telegram": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Link My Telegram + * @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе. + */ + post: operations["link_my_telegram_api_users_me_telegram_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/avatar": { parameters: { query?: never; @@ -844,6 +924,26 @@ export interface paths { patch: operations["update_user_api_admin_users__user_id__patch"]; trace?: never; }; + "/api/admin/users/{user_id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set User Password + * @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется. + */ + put: operations["set_user_password_api_admin_users__user_id__password_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/groups": { parameters: { query?: never; @@ -1224,6 +1324,11 @@ export interface components { /** Role */ role: string; }; + /** AdminPasswordSet */ + AdminPasswordSet: { + /** New Password */ + new_password: string; + }; /** AdminUserRead */ AdminUserRead: { /** Id */ @@ -1240,6 +1345,11 @@ export interface components { telegram_id?: number | null; /** Created At */ created_at: string; + /** + * Has Password + * @default false + */ + has_password: boolean; }; /** AdminUserUpdate */ AdminUserUpdate: { @@ -1887,6 +1997,11 @@ export interface components { * @default [] */ groups: components["schemas"]["GroupBrief"][]; + /** + * Has Password + * @default false + */ + has_password: boolean; }; /** MemberAdd */ MemberAdd: { @@ -1990,6 +2105,27 @@ export interface components { /** Comment */ comment?: string | null; }; + /** PasswordChange */ + PasswordChange: { + /** Current Password */ + current_password?: string | null; + /** New Password */ + new_password: string; + }; + /** PasswordLogin */ + PasswordLogin: { + /** Nickname */ + nickname: string; + /** Password */ + password: string; + }; + /** PasswordRegister */ + PasswordRegister: { + /** Nickname */ + nickname: string; + /** Password */ + password: string; + }; /** ProfileStats */ ProfileStats: { /** User Id */ @@ -2198,6 +2334,72 @@ export interface operations { }; }; }; + password_register_api_auth_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordRegister"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + password_login_api_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordLogin"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; telegram_login_api_auth_telegram_post: { parameters: { query?: never; @@ -2370,6 +2572,72 @@ export interface operations { }; }; }; + change_my_password_api_users_me_password_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChange"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + link_my_telegram_api_users_me_telegram_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramAuthPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; upload_my_avatar_api_users_me_avatar_put: { parameters: { query?: never; @@ -3789,6 +4057,41 @@ export interface operations { }; }; }; + set_user_password_api_admin_users__user_id__password_put: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPasswordSet"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_groups_api_admin_groups_get: { parameters: { query?: never; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0b2460a..5088d43 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents"; import { BottomBar } from "./BottomBar"; import { NotificationBell } from "./NotificationBell"; import { NotificationToaster } from "./NotificationToaster"; +import { SetPasswordDialog } from "./SetPasswordDialog"; import { SideMenu } from "./SideMenu"; const TITLES: Record = { @@ -51,6 +52,9 @@ export function AppShell() { setMenuOpen(false)} /> setMenuOpen(true)} /> + + {/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */} + {me && !me.has_password && } ); } diff --git a/frontend/src/components/SetPasswordDialog.tsx b/frontend/src/components/SetPasswordDialog.tsx new file mode 100644 index 0000000..3eb1ee0 --- /dev/null +++ b/frontend/src/components/SetPasswordDialog.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ApiError } from "../api/client"; +import { useLogout, useSetPassword } from "../hooks/auth"; + +/** + * Обязательная установка пароля — для аккаунтов из Telegram и созданных до появления + * паролей. Закрыть окно нельзя: только задать пароль или выйти. Кнопка «Позже» есть + * лишь в dev-сборке (import.meta.env.DEV) — чтобы тестовые stub-игроки не упирались в окно. + */ +export function SetPasswordDialog({ nickname }: { nickname: string }) { + const setPassword = useSetPassword(); + const logout = useLogout(); + const navigate = useNavigate(); + const [password, setPasswordValue] = useState(""); + const [repeat, setRepeat] = useState(""); + const [error, setError] = useState(null); + const [skipped, setSkipped] = useState(false); + + if (skipped) return null; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== repeat) { + setError("Пароли не совпадают."); + return; + } + setError(null); + try { + // Успех кладёт в кэш профиль с has_password=true — окно исчезает само. + await setPassword.mutateAsync({ new_password: password }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль"); + } + }; + + const doLogout = async () => { + await logout.mutateAsync().catch(() => {}); + navigate("/login", { replace: true }); + }; + + return ( +
+
+
Задайте пароль
+

+ Теперь на сайт входят по логину и паролю. Ваш логин — никнейм {nickname}. + Придумайте пароль, чтобы продолжить; вход через Telegram тоже останется. +

+ {/* Скрытый логин — чтобы менеджер паролей сохранил пару целиком. */} + +
+ + setPasswordValue(e.target.value)} + /> +
+
+ + setRepeat(e.target.value)} + /> +
+ {error &&

{error}

} +
+ {import.meta.env.DEV && ( + + )} + + +
+
+
+ ); +} diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 8828c15..4ab7ff8 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -203,6 +203,21 @@ export function useAdminUpdateUser() { }); } +// Новый пароль игроку — когда он забыл свой (почту приложение не хранит). +export function useAdminSetPassword() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { userId: number; password: string }) => + unwrap( + await api.PUT("/api/admin/users/{user_id}/password", { + params: { path: { user_id: args.userId } }, + body: { new_password: args.password }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), + }); +} + // ─── Ачивки (редактор; выдача игрокам — на будущее) ────────────────────────── interface AchievementInput { diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 9442c2d..b86b54f 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -45,6 +45,52 @@ export function useStubLogin() { }); } +// Вход по логину (нику) и паролю. Перебор сервер ограничивает: 429 TOO_MANY_ATTEMPTS. +export function usePasswordLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { nickname: string; password: string }) => + unwrap(await api.POST("/api/auth/login", { body })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +// Регистрация по логину (нику) и паролю; Telegram привязывается позже в профиле. +export function useRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { nickname: string; password: string }) => + unwrap(await api.POST("/api/auth/register", { body })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +// Задать пароль (первый раз — без текущего) или сменить его (нужен текущий). +export function useSetPassword() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { new_password: string; current_password?: string }): Promise => + unwrap(await api.PUT("/api/users/me/password", { body })), + onSuccess: (me) => qc.setQueryData(qk.me, me), + }); +} + +// Привязка Telegram к аккаунту: тот же payload виджета, что при входе; ник не меняется. +export function useLinkTelegram() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Record): Promise => + unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })), + onSuccess: (me) => qc.setQueryData(qk.me, me), + }); +} + // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. // При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED — // тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета). diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index e4e8aa9..123d5b0 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { Switch } from "../components/Switch"; +import { TelegramLoginButton } from "../components/TelegramLoginButton"; import { useToast } from "../context/ToastContext"; +import type { Me } from "../domain/types"; import { + useAuthConfig, useDeleteAvatar, + useLinkTelegram, useLogout, useMe, + useSetPassword, useUpdateBio, useUpdateFavoriteFaction, useUpdateHistoryPrefs, @@ -218,6 +223,9 @@ export function AccountPage() { Сохранить +
+ Никнейм — это и логин для входа: после смены входите под новым. +
@@ -256,9 +264,129 @@ export function AccountPage() { + + ); } + +// Вход в аккаунт: смена пароля и привязка Telegram (ник при привязке не меняется). +function LoginSettingsCard({ me }: { me: Me }) { + const setPassword = useSetPassword(); + const linkTelegram = useLinkTelegram(); + const { data: config } = useAuthConfig(); + const toast = useToast(); + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [repeat, setRepeat] = useState(""); + const [error, setError] = useState(null); + + const changePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (next !== repeat) { + setError("Пароли не совпадают."); + return; + } + setError(null); + try { + await setPassword.mutateAsync( + me.has_password ? { current_password: current, new_password: next } : { new_password: next }, + ); + setCurrent(""); + setNext(""); + setRepeat(""); + toast.show(me.has_password ? "Пароль изменён" : "Пароль задан"); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль"); + } + }; + + const onTelegram = async (payload: Record) => { + try { + await linkTelegram.mutateAsync(payload); + toast.show("Telegram привязан"); + } catch (err) { + toast.show(err instanceof ApiError ? err.message : "Не удалось привязать Telegram"); + } + }; + + const telegramReady = !!config?.telegram_bot_username && config.methods.includes("telegram"); + + return ( +
+

Вход в аккаунт

+
+ {/* Скрытый логин — чтобы менеджер паролей обновил сохранённую пару. */} + + {me.has_password && ( +
+ + setCurrent(e.target.value)} + /> +
+ )} +
+ + setNext(e.target.value)} + /> +
+
+ + setRepeat(e.target.value)} + /> +
+ {error &&

{error}

} + +
+ +
+ + {me.telegram_id != null ? ( +
Привязан — можно входить и через Telegram.
+ ) : telegramReady ? ( + <> +

+ Привяжите Telegram, чтобы входить и через него. Никнейм не изменится. +

+ onTelegram(u as unknown as Record)} + /> + + ) : ( +
Вход через Telegram не настроен.
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index f2cc532..6f821be 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -9,6 +9,8 @@ import { useCreateDevUser, useDevUsers, useMe, + usePasswordLogin, + useRegister, useStubLogin, useTelegramLogin, useTelegramRegister, @@ -28,6 +30,7 @@ export function LoginPage() { const [pendingTg, setPendingTg] = useState(null); const methods = config?.methods ?? []; + const hasPassword = methods.includes("password"); const hasTelegram = methods.includes("telegram"); // Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде // import.meta.env.DEV === false → блок вырезается из бандла). @@ -72,22 +75,29 @@ export function LoginPage() { onDone={() => navigate("/", { replace: true })} /> ) : ( - hasTelegram && ( -
-

Вход через Telegram

- {config?.telegram_bot_username ? ( - onTelegram(u as unknown as Record)} - /> - ) : ( -

- Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN - в .env (бот от @BotFather + /setdomain на ваш домен). + <> + {hasPassword && navigate("/", { replace: true })} />} + {hasTelegram && ( +

+

Вход через Telegram

+ {config?.telegram_bot_username ? ( + onTelegram(u as unknown as Record)} + /> + ) : ( +

+ Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN + в .env (бот от @BotFather + /setdomain на ваш домен). +

+ )} +

+ Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле — + иначе вход через Telegram создаст новый аккаунт.

- )} -
- ) +
+ )} + )} {showStub && !pendingTg && } @@ -98,6 +108,106 @@ export function LoginPage() { ); } +// Основной вход: логин (ник) и пароль, с переключением на регистрацию. +function PasswordAuthCard({ onDone }: { onDone: () => void }) { + const login = usePasswordLogin(); + const register = useRegister(); + const [mode, setMode] = useState<"login" | "register">("login"); + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [repeat, setRepeat] = useState(""); + const [error, setError] = useState(null); + const isRegister = mode === "register"; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const nick = nickname.trim(); + if (!nick || !password) { + setError("Введите никнейм и пароль."); + return; + } + if (isRegister && password !== repeat) { + setError("Пароли не совпадают."); + return; + } + setError(null); + try { + if (isRegister) await register.mutateAsync({ nickname: nick, password }); + else await login.mutateAsync({ nickname: nick, password }); + onDone(); + } catch (err) { + const fallback = isRegister ? "Не удалось зарегистрироваться" : "Не удалось войти"; + setError(err instanceof ApiError ? err.message : fallback); + } + }; + + const switchMode = () => { + setMode(isRegister ? "login" : "register"); + setRepeat(""); + setError(null); + }; + + return ( +
+

{isRegister ? "Регистрация" : "Вход"}

+
+ + setNickname(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> + {isRegister &&
Не короче 8 символов.
} +
+ {isRegister && ( +
+ + setRepeat(e.target.value)} + /> +
+ )} + {error &&

{error}

} + + +
+ ); +} + // Выбор ника при регистрации через Telegram, когда тег занят/некорректен. // Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново). function TelegramNicknamePicker({ diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 681589f..6997a06 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -1,10 +1,10 @@ -import { Pencil } from "lucide-react"; +import { KeyRound, Pencil } from "lucide-react"; import { useState } from "react"; import { ApiError } from "../../api/client"; import { Spinner } from "../../components/Spinner"; import { useToast } from "../../context/ToastContext"; -import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; +import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; // DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV, // поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; @@ -12,17 +12,40 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; export function AdminAccountsPage() { const { data: users, isLoading } = useAdminUsers(); const update = useAdminUpdateUser(); + const setPassword = useAdminSetPassword(); const toast = useToast(); const [editId, setEditId] = useState(null); const [editName, setEditName] = useState(""); + // Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование. + const [passwordId, setPasswordId] = useState(null); + const [newPassword, setNewPassword] = useState(""); if (isLoading) return ; const startEdit = (id: number, nickname: string) => { + setPasswordId(null); setEditId(id); setEditName(nickname); }; + const startPassword = (id: number) => { + setEditId(null); + setPasswordId(id); + setNewPassword(""); + }; + + const savePassword = async () => { + if (passwordId == null || !newPassword) return; + try { + await setPassword.mutateAsync({ userId: passwordId, password: newPassword }); + toast.show("Пароль задан — сообщите его игроку"); + setPasswordId(null); + setNewPassword(""); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Не удалось задать пароль"); + } + }; + const saveName = async () => { const name = editName.trim(); if (editId == null || !name) return; @@ -73,12 +96,39 @@ export function AdminAccountsPage() { Отмена + ) : passwordId === u.id ? ( + // Режим «задать пароль». +
+ setNewPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") savePassword(); + if (e.key === "Escape") setPasswordId(null); + }} + /> + + +
) : (
{u.nickname} {u.role === "admin" && админ}
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"} + {u.role !== "admin" && !u.has_password && " · без пароля"}
{u.role !== "admin" && ( @@ -90,6 +140,13 @@ export function AdminAccountsPage() { > + {import.meta.env.DEV && ( )} From a09766c0315786ce55c30152b53f326f845db3fc Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 14:12:50 +0300 Subject: [PATCH 04/13] =?UTF-8?q?README:=20=D0=B2=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=D1=83=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В таблицах методов входа (раздел «Аутентификация» и сводка окружений) пароль теперь основной способ во всех окружениях, Telegram — второй, stub остаётся только в dev. Описаны правила пароля, защита от перебора, обязательное окно для аккаунтов без пароля, привязка Telegram и восстановление пароля через админку. Поправлены места, где test-клон описывался как «только Telegram». #24 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a6f223c..74c4acd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Forbidden Stars — учёт партий Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: -профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий +профили игроков (вход по логину и паролю или через Telegram), группы, создание партий с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель. - **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite @@ -91,8 +91,8 @@ npm run gen:api # сгенерирует типы из живог npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека) ``` -Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику), -в проде — только Telegram (см. раздел «Аутентификация»). +Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub); +в проде stub нет (см. раздел «Аутентификация»). ## Production (Docker на Pi) @@ -108,7 +108,7 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми ## Test — локальный прод-клон в контейнере Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков -только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. +по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000). Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. @@ -123,8 +123,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с - Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). - Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi). -- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** — - только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`). +- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — + по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`. - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: @@ -136,9 +136,17 @@ docker compose -f docker-compose.test.yml down -v # остановить и с | | dev | test / prod | |---|---|---| -| Telegram Login Widget | ✓ | ✓ (единственный) | -| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) | +| Логин (= ник) и пароль | ✓ | ✓ (основной) | +| Telegram Login Widget | ✓ | ✓ | +| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) | +- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока + (смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt. + От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP, + дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей) + после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен + текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ + на вкладке аккаунтов — почту приложение не хранит. - **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development` @@ -165,7 +173,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с | Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | | Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | | База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | -| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram | +| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | - **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда From c0e162df720b25929c76219c5c9cbb2e2dbad90c Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 15:02:00 +0300 Subject: [PATCH 05/13] =?UTF-8?q?=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8:=20422?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D1=8D=D1=85=D0=B0=20=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если JSON-эндпойнт получал тело с Content-Type text/plain (так шлёт HTML-форма), сервер отвечал 500. _validation_handler клал exc.errors() в details как есть, а в input там лежало сырое тело типа bytes, которое JSONResponse не сериализует. Затронуты все эндпойнты с телом, в том числе /api/auth/telegram и /api/admin/auth/login. Запрос и раньше отклонялся, сессия не создавалась, но вместо понятной 422 в логе появлялся трейсбек. Тот же input возвращал клиенту тело запроса целиком. Неполная форма входа отправляла пароль обратно в ответе 422, откуда он мог осесть в логах прокси и инструментах отладки. Теперь в details идут только type, loc и msg. input и ctx (там бывают объекты исключений — та же проблема сериализации) в ответ не попадают. Конверт и код VALIDATION_ERROR прежние, фронт details валидации не читает. Тесты: text/plain на двух эндпойнтах даёт 422, пароль не возвращается, loc по-прежнему указывает на поле. На старом коде первые два падают. #52 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/main.py | 6 ++++- backend/tests/test_validation_errors.py | 31 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_validation_errors.py diff --git a/backend/app/main.py b/backend/app/main.py index ad9fca6..d58e61b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -177,13 +177,17 @@ def create_app() -> FastAPI: @app.exception_handler(RequestValidationError) async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse: + # Только type/loc/msg. В input лежит тело запроса: эхо паролей в ответ, а для тела + # не в JSON (text/plain от HTML-формы) — сырые bytes, которые JSON не сериализует, + # и ответ падал в 500. В ctx бывают объекты исключений — та же проблема. + details = [{"type": e["type"], "loc": e["loc"], "msg": e["msg"]} for e in exc.errors()] return JSONResponse( status_code=422, content={ "error": { "code": "VALIDATION_ERROR", "message": "Ошибка валидации запроса.", - "details": exc.errors(), + "details": details, } }, ) diff --git a/backend/tests/test_validation_errors.py b/backend/tests/test_validation_errors.py new file mode 100644 index 0000000..d7fc215 --- /dev/null +++ b/backend/tests/test_validation_errors.py @@ -0,0 +1,31 @@ +"""Ошибки валидации запроса: всегда 422 в едином конверте и без эха тела запроса.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_non_json_body_is_422_not_500(client: TestClient): + """HTML-форма шлёт text/plain: тело приходит сырыми bytes, ответ раньше падал в 500.""" + for path, raw in [ + ("/api/auth/telegram", '{"id": 1}'), + ("/api/admin/auth/login", '{"username": "a", "password": "b"}'), + ]: + r = client.post(path, content=raw, headers={"Content-Type": "text/plain"}) + assert r.status_code == 422, (path, r.text) + assert r.json()["error"]["code"] == "VALIDATION_ERROR" + + +def test_validation_error_does_not_echo_body(client: TestClient): + secret = "very-secret-password" + r = client.post("/api/admin/auth/login", json={"password": secret}) + assert r.status_code == 422, r.text + assert secret not in r.text + + details = r.json()["error"]["details"] + assert details and all(set(d) == {"type", "loc", "msg"} for d in details) + + +def test_validation_error_points_to_field(client: TestClient): + r = client.post("/api/admin/auth/login", json={"password": "x"}) + locs = [d["loc"] for d in r.json()["error"]["details"]] + assert ["body", "username"] in locs From 3587fc8af22938eb7ad7050dd7ac536af9743236 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 15:45:34 +0300 Subject: [PATCH 06/13] =?UTF-8?q?=D0=A2=D0=BE=D1=81=D1=82=D1=8B:=20=D1=83?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=B0=D0=BA=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=85=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Многие тосты повторяли то, что пользователь и так видит сразу после действия: новую фракцию или аватар в профиле, переход на страницу партии, исчезнувшую карточку приглашения, закрывшийся редактор. Такой шум приучает не читать всплывашки, в том числе ошибки. Убраны 19 таких тостов в профиле, группах, партиях и админке. Остались ошибки, предупреждения (лимит фото, повторы фракций, конфликт правок, «вы не в группе») и сообщения о действиях без видимого следа: приглашение отправлено, дополнения сохранены, вступление в группу, пароль задан. Сохранение текстового поля, где заметна только погасшая кнопка, тоже осталось. По просьбе владельца оставшиеся тосты показываются не нижней «таблеткой», а всплывашкой сверху, как входящие уведомления. ToastProvider держит одну стопку на всё приложение: show — обычное сообщение, error — ошибка с красной полосой (держится 5 с), notify — уведомление с переходом по клику. Одинаковое сообщение не копится, а заменяет прежнее; клик по карточке закрывает её. NotificationToaster больше не рисует свою стопку, а отдаёт новые уведомления в общую, поэтому они не наезжают на сообщения. Все вызовы с ApiError и отказы (лимит фото, партия изменилась на другом устройстве) переведены на toast.error. Нижний класс .toast удалён. #41 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- frontend/src/components/CreateGroupForm.tsx | 3 - frontend/src/components/GroupInvitations.tsx | 5 +- frontend/src/components/MatchMedia.tsx | 6 +- .../src/components/NotificationToaster.tsx | 46 ++++----- frontend/src/context/ToastContext.tsx | 94 ++++++++++++++++--- frontend/src/pages/AccountPage.tsx | 19 ++-- frontend/src/pages/CreateMatchPage.tsx | 3 +- frontend/src/pages/GroupPage.tsx | 2 +- frontend/src/pages/GroupSettingsPage.tsx | 6 +- frontend/src/pages/MatchDetailPage.tsx | 10 +- frontend/src/pages/PublicProfilePage.tsx | 2 +- .../src/pages/admin/AdminAccountsPage.tsx | 8 +- .../src/pages/admin/AdminAchievementsPage.tsx | 12 +-- .../src/pages/admin/AdminFactionsPage.tsx | 2 +- frontend/src/pages/admin/AdminMatchEdit.tsx | 3 - frontend/src/pages/admin/AdminRecordsPage.tsx | 3 +- .../pages/admin/DevDeleteAccountButton.tsx | 3 +- frontend/src/styles/global.css | 17 +--- 18 files changed, 129 insertions(+), 115 deletions(-) diff --git a/frontend/src/components/CreateGroupForm.tsx b/frontend/src/components/CreateGroupForm.tsx index 5037bc9..2d2fa3b 100644 --- a/frontend/src/components/CreateGroupForm.tsx +++ b/frontend/src/components/CreateGroupForm.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { ApiError } from "../api/client"; -import { useToast } from "../context/ToastContext"; import { useCreateGroup } from "../hooks/groups"; import { useExpansions } from "../hooks/reference"; import { Switch } from "./Switch"; @@ -9,7 +8,6 @@ import { Switch } from "./Switch"; export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) { const { data: expansions } = useExpansions(); const createGroup = useCreateGroup(); - const toast = useToast(); const [name, setName] = useState(""); const [selected, setSelected] = useState>(new Set()); const [error, setError] = useState(null); @@ -25,7 +23,6 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) { setError(null); try { await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] }); - toast.show("Группа создана"); onCreated?.(); } catch (e) { setError(e instanceof ApiError ? e.message : "Не удалось создать группу"); diff --git a/frontend/src/components/GroupInvitations.tsx b/frontend/src/components/GroupInvitations.tsx index 7b1fd7e..62877bd 100644 --- a/frontend/src/components/GroupInvitations.tsx +++ b/frontend/src/components/GroupInvitations.tsx @@ -15,16 +15,15 @@ export function GroupInvitations() { await accept.mutateAsync(id); toast.show("Вы вступили в группу"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; const onDecline = async (id: number) => { try { await decline.mutateAsync(id); - toast.show("Приглашение отклонено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; diff --git a/frontend/src/components/MatchMedia.tsx b/frontend/src/components/MatchMedia.tsx index 742a640..93568c3 100644 --- a/frontend/src/components/MatchMedia.tsx +++ b/frontend/src/components/MatchMedia.tsx @@ -35,14 +35,14 @@ export function MatchMedia({ let slots = max - attachments.length; for (const f of files) { if (slots <= 0) { - toast.show(`Максимум ${max} фото`); + toast.error(`Максимум ${max} фото`); break; } try { await onUpload(f); slots--; } catch (err) { - toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); + toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить"); break; } } @@ -53,7 +53,7 @@ export function MatchMedia({ try { await onDelete(id); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); + toast.error(e instanceof ApiError ? e.message : "Не удалось удалить"); } }; diff --git a/frontend/src/components/NotificationToaster.tsx b/frontend/src/components/NotificationToaster.tsx index 789a90c..7278f46 100644 --- a/frontend/src/components/NotificationToaster.tsx +++ b/frontend/src/components/NotificationToaster.tsx @@ -1,18 +1,17 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { useNotifications, type Notification } from "../hooks/notifications"; - -const TOAST_TTL_MS = 5000; -const MAX_VISIBLE = 4; +import { useToast } from "../context/ToastContext"; +import { useNotifications } from "../hooks/notifications"; /** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull). - * Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */ + * Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. + * Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */ export function NotificationToaster({ enabled }: { enabled: boolean }) { const { data } = useNotifications(enabled); const navigate = useNavigate(); + const toast = useToast(); const seen = useRef | null>(null); - const [toasts, setToasts] = useState([]); useEffect(() => { if (!data) return; @@ -22,29 +21,16 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) { return; } const fresh = items.filter((n) => !seen.current!.has(n.id)); - if (fresh.length === 0) return; fresh.forEach((n) => seen.current!.add(n.id)); - setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE)); - fresh.forEach((n) => { - setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS); - }); - }, [data]); + // Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху. + [...fresh].reverse().forEach((n) => + toast.notify({ + title: n.title, + body: n.body, + onClick: n.link ? () => navigate(n.link!) : undefined, + }), + ); + }, [data, toast, navigate]); - if (!enabled || toasts.length === 0) return null; - - const click = (n: Notification) => { - setToasts((prev) => prev.filter((t) => t.id !== n.id)); - if (n.link) navigate(n.link); - }; - - return ( -
- {toasts.map((n) => ( - - ))} -
- ); + return null; } diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 819c647..9ba3598 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -1,31 +1,97 @@ -import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import type { PropsWithChildren } from "react"; -interface ToastCtx { - show: (message: string) => void; +// Сообщения приложения и входящие уведомления — одна стопка всплывашек сверху экрана. +type ToastKind = "info" | "error" | "notification"; + +interface ToastItem { + id: number; + kind: ToastKind; + title: string; + body?: string | null; + onClick?: () => void; } -const Ctx = createContext({ show: () => {} }); +interface ToastCtx { + /** Нейтральное сообщение: действие прошло, но на экране это не видно. */ + show: (message: string) => void; + /** Ошибка или отказ — красная полоса, держится дольше. */ + error: (message: string) => void; + /** Входящее уведомление (заголовок, текст, переход по клику). */ + notify: (n: { title: string; body?: string | null; onClick?: () => void }) => void; +} + +const TTL_MS: Record = { info: 3000, error: 5000, notification: 5000 }; +const MAX_VISIBLE = 4; + +const Ctx = createContext({ show: () => {}, error: () => {}, notify: () => {} }); export function ToastProvider({ children }: PropsWithChildren) { - const [message, setMessage] = useState(null); - const timer = useRef | null>(null); + const [items, setItems] = useState([]); + const nextId = useRef(1); + const timers = useRef(new Map>()); - const show = useCallback((msg: string) => { - setMessage(msg); - if (timer.current) clearTimeout(timer.current); - timer.current = setTimeout(() => setMessage(null), 2800); + useEffect(() => { + const pending = timers.current; + return () => pending.forEach(clearTimeout); }, []); - // Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост: + const dismiss = useCallback((id: number) => { + clearTimeout(timers.current.get(id)); + timers.current.delete(id); + setItems((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const push = useCallback( + (item: Omit) => { + const id = nextId.current++; + setItems((prev) => { + // Тот же текст уже на экране (двойной клик, повтор ошибки) — заменяем, а не копим. + // Уведомления не схлопываем: одинаковый заголовок бывает у разных событий. + const rest = + item.kind === "notification" + ? prev + : prev.filter((t) => !(t.kind === item.kind && t.title === item.title)); + return [{ ...item, id }, ...rest].slice(0, MAX_VISIBLE); + }); + timers.current.set(id, setTimeout(() => dismiss(id), TTL_MS[item.kind])); + }, + [dismiss], + ); + + // Провайдер обёрнут вокруг всего приложения и перерисовывается на каждую всплывашку: // без useMemo новое значение контекста заставляло бы перерисоваться и всех - // потребителей, к самому тосту отношения не имеющих. - const value = useMemo(() => ({ show }), [show]); + // потребителей, к самим всплывашкам отношения не имеющих. + const value = useMemo( + () => ({ + show: (message) => push({ kind: "info", title: message }), + error: (message) => push({ kind: "error", title: message }), + notify: ({ title, body, onClick }) => push({ kind: "notification", title, body, onClick }), + }), + [push], + ); return ( {children} - {message &&
{message}
} + {/* Контейнер есть всегда: aria-live объявляет только то, что появилось внутри уже + существующей области. */} +
+ {items.map((t) => ( + + ))} +
); } diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 123d5b0..0cf112a 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -65,9 +65,8 @@ export function AccountPage() { const saveFavorite = async (factionId: number | null) => { try { await updateFavorite.mutateAsync(factionId); - toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -75,7 +74,7 @@ export function AccountPage() { try { await updateHistory.mutateAsync(prefs); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -85,9 +84,8 @@ export function AccountPage() { try { await updateNick.mutateAsync(nick); setNickname(""); - toast.show("Никнейм обновлён"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -96,7 +94,7 @@ export function AccountPage() { await updateBio.mutateAsync(bioValue.trim()); toast.show("Сохранено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -106,18 +104,16 @@ export function AccountPage() { if (!file) return; try { await uploadAvatar.mutateAsync(file); - toast.show("Аватар обновлён"); } catch (err) { - toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); + toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить"); } }; const removeAvatar = async () => { try { await deleteAvatar.mutateAsync(); - toast.show("Аватар удалён"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -307,9 +303,8 @@ function LoginSettingsCard({ me }: { me: Me }) { const onTelegram = async (payload: Record) => { try { await linkTelegram.mutateAsync(payload); - toast.show("Telegram привязан"); } catch (err) { - toast.show(err instanceof ApiError ? err.message : "Не удалось привязать Telegram"); + toast.error(err instanceof ApiError ? err.message : "Не удалось привязать Telegram"); } }; diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index d50afeb..39a1e54 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -131,7 +131,7 @@ export function CreateMatchPage() { } catch (e) { if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]); setSpin((s) => ({ ...s, [id]: null })); - toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); + toast.error(e instanceof ApiError ? e.message : "Ошибка рандома"); } }; @@ -176,7 +176,6 @@ export function CreateMatchPage() { was_random: r.was_random, })), }); - toast.show("Партия начата"); navigate(`/match/${match.id}`, { replace: true }); } catch (e) { setError(e instanceof ApiError ? e.message : "Не удалось начать партию"); diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index 5cf5f33..3d71967 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -53,7 +53,7 @@ export function GroupPage() { setNewNick(""); toast.show("Приглашение отправлено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index 2166844..878852a 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -59,7 +59,7 @@ export function GroupSettingsPage() { setName(null); toast.show("Название сохранено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -68,7 +68,7 @@ export function GroupSettingsPage() { await setExpansions.mutateAsync([...selected]); toast.show("Дополнения сохранены"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -76,7 +76,7 @@ export function GroupSettingsPage() { try { await removeMember.mutateAsync(userId); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 9988193..4a0fc31 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -165,10 +165,9 @@ export function MatchDetailPage() { expected_version: match.version, }, }); - toast.show("Партия завершена"); } catch (e) { if (isStale(e)) { - toast.show("Партия изменилась на другом устройстве — обновлено"); + toast.error("Партия изменилась на другом устройстве — обновлено"); refetch(); return; } @@ -234,11 +233,10 @@ export function MatchDetailPage() { expected_version: match.version, }, }); - toast.show("Партия обновлена"); cancelEditing(); } catch (e) { if (isStale(e)) { - toast.show("Партия изменилась на другом устройстве — обновлено"); + toast.error("Партия изменилась на другом устройстве — обновлено"); refetch(); cancelEditing(); return; @@ -257,11 +255,11 @@ export function MatchDetailPage() { navigate("/group", { replace: true }); } catch (e) { if (isStale(e)) { - toast.show("Партия изменилась на другом устройстве — обновлено"); + toast.error("Партия изменилась на другом устройстве — обновлено"); refetch(); return; } - toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить"); + toast.error(e instanceof ApiError ? e.message : "Не удалось выполнить"); } }; diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx index 438936f..7910ae0 100644 --- a/frontend/src/pages/PublicProfilePage.tsx +++ b/frontend/src/pages/PublicProfilePage.tsx @@ -44,7 +44,7 @@ export function PublicProfilePage() { await invite.mutateAsync({ groupId, nickname: data.nickname }); toast.show("Приглашение отправлено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось пригласить"); + toast.error(e instanceof ApiError ? e.message : "Не удалось пригласить"); } }; diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 6997a06..cc1b46f 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -42,7 +42,7 @@ export function AdminAccountsPage() { setPasswordId(null); setNewPassword(""); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось задать пароль"); + toast.error(e instanceof ApiError ? e.message : "Не удалось задать пароль"); } }; @@ -51,19 +51,17 @@ export function AdminAccountsPage() { if (editId == null || !name) return; try { await update.mutateAsync({ userId: editId, nickname: name }); - toast.show("Ник обновлён"); setEditId(null); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать"); + toast.error(e instanceof ApiError ? e.message : "Не удалось переименовать"); } }; const toggleActive = async (id: number, isActive: boolean) => { try { await update.mutateAsync({ userId: id, is_active: !isActive }); - toast.show("Сохранено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить"); + toast.error(e instanceof ApiError ? e.message : "Не удалось сохранить"); } }; diff --git a/frontend/src/pages/admin/AdminAchievementsPage.tsx b/frontend/src/pages/admin/AdminAchievementsPage.tsx index c7b3d2d..78e1a7a 100644 --- a/frontend/src/pages/admin/AdminAchievementsPage.tsx +++ b/frontend/src/pages/admin/AdminAchievementsPage.tsx @@ -35,9 +35,8 @@ export function AdminAchievementsPage() { setName(""); setDescription(""); setCondition(""); - toast.show("Ачивка создана"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -100,9 +99,8 @@ function AchievementCard({ ach }: { ach: AchievementRead }) { body: { name: name.trim(), description, condition }, }); setEditing(false); - toast.show("Сохранено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; @@ -112,18 +110,16 @@ function AchievementCard({ ach }: { ach: AchievementRead }) { if (!file) return; try { await uploadIcon.mutateAsync({ slug: ach.slug, file }); - toast.show("Иконка обновлена"); } catch (err) { - toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); + toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить"); } }; const remove = async () => { try { await del.mutateAsync(ach.slug); - toast.show("Удалено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; diff --git a/frontend/src/pages/admin/AdminFactionsPage.tsx b/frontend/src/pages/admin/AdminFactionsPage.tsx index b0dd5eb..82cca73 100644 --- a/frontend/src/pages/admin/AdminFactionsPage.tsx +++ b/frontend/src/pages/admin/AdminFactionsPage.tsx @@ -31,7 +31,7 @@ export function AdminFactionsPage() { await rename.mutateAsync({ factionId: id, name_ru: names[id] }); toast.show("Название обновлено во всей системе"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); + toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index d987a70..d71156d 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -6,7 +6,6 @@ import { MatchMedia } from "../../components/MatchMedia"; import { Spinner } from "../../components/Spinner"; import { Switch } from "../../components/Switch"; import { WIN_REASONS, type WinReason } from "../../domain/winReasons"; -import { useToast } from "../../context/ToastContext"; import { useAdminDeleteAttachment, useAdminFactions, @@ -33,7 +32,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: const update = useAdminUpdateMatch(); const uploadAtt = useAdminUploadAttachment(matchId); const deleteAtt = useAdminDeleteAttachment(matchId); - const toast = useToast(); const navigate = useNavigate(); const [rows, setRows] = useState([]); @@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: })), }, }); - toast.show("Партия обновлена"); onClose(); } catch (e) { setError(e instanceof ApiError ? e.message : "Не удалось сохранить"); diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx index 6f762d4..d47873e 100644 --- a/frontend/src/pages/admin/AdminRecordsPage.tsx +++ b/frontend/src/pages/admin/AdminRecordsPage.tsx @@ -149,9 +149,8 @@ export function AdminRecordsPage() { setDeleteTarget(null); try { await del.mutateAsync(deleteTarget.id); - toast.show("Удалено"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); + toast.error(e instanceof ApiError ? e.message : "Не удалось удалить"); } }} /> diff --git a/frontend/src/pages/admin/DevDeleteAccountButton.tsx b/frontend/src/pages/admin/DevDeleteAccountButton.tsx index 884b137..3b0b7fc 100644 --- a/frontend/src/pages/admin/DevDeleteAccountButton.tsx +++ b/frontend/src/pages/admin/DevDeleteAccountButton.tsx @@ -57,9 +57,8 @@ export function DevDeleteAccountButton({ setConfirmOpen(false); try { await del.mutateAsync(); - toast.show("Аккаунт удалён"); } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); + toast.error(e instanceof ApiError ? e.message : "Не удалось удалить"); } }} /> diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index e5cd70b..295df2d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -193,22 +193,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } .switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; } .switch input:disabled + .switch-track { opacity: 0.5; } -.toast { - position: fixed; - left: 50%; - bottom: calc(var(--bottombar-height) + 16px); - transform: translateX(-50%); - background: var(--surface-2); - border: 1px solid var(--border); - color: var(--text); - padding: 10px 16px; - border-radius: 999px; - z-index: 100; - max-width: 90%; - text-align: center; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); -} - /* ─── Уведомления ─────────────────────────────────────────────────────────── */ /* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */ @@ -471,5 +455,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } border-radius: var(--radius-sm); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); } +.toast-top-error { border-left-color: var(--danger); } .toast-top-title { font-weight: 700; } .toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); } From 3f8667b561b3bca795bab8fdc55f1e8f812d5d19 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:09:57 +0300 Subject: [PATCH 07/13] =?UTF-8?q?=D0=9E=D1=82=D0=B7=D1=8B=D0=B2=20JWT=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logout отзывает предъявленный токен по jti (in-memory denylist до exp); смена и сброс пароля инкрементят users.token_version (claim ver в JWT, сверка в auth/deps) — все прежние сессии отзываются. Своё устройство при смене пароля остаётся в сессии (перевыдача cookie). Миграция 0013. #57 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- .../versions/0013_user_token_version.py | 44 ++++++++++++++ backend/app/auth/deps.py | 9 +++ backend/app/auth/login.py | 2 +- backend/app/core/security.py | 47 +++++++++++++-- backend/app/core/token_revocation.py | 53 +++++++++++++++++ backend/app/models.py | 7 +++ backend/app/routers/admin.py | 5 +- backend/app/routers/auth.py | 5 +- backend/app/routers/users.py | 8 ++- backend/app/services/user_service.py | 7 ++- backend/tests/conftest.py | 2 + backend/tests/test_password_auth.py | 59 +++++++++++++++++++ 12 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/0013_user_token_version.py create mode 100644 backend/app/core/token_revocation.py diff --git a/backend/alembic/versions/0013_user_token_version.py b/backend/alembic/versions/0013_user_token_version.py new file mode 100644 index 0000000..b509484 --- /dev/null +++ b/backend/alembic/versions/0013_user_token_version.py @@ -0,0 +1,44 @@ +"""Пользователь: версия сессий (token_version) для отзыва JWT. + +Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite). + +Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57). + +Revision ID: 0013_user_token_version +Revises: 0012_match_finish_draft +Create Date: 2026-09-13 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0013_user_token_version" +down_revision: Union[str, None] = "0012_match_finish_draft" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + with op.batch_alter_table("users") as b: + if "token_version" not in cols: + b.add_column( + sa.Column( + "token_version", sa.Integer(), nullable=False, server_default="0" + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + with op.batch_alter_table("users") as b: + if "token_version" in cols: + b.drop_column("token_version") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index 541fbd2..ddeadac 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -21,9 +21,14 @@ def get_current_user( payload = security.decode_token(token, security.AUDIENCE_USER) except jwt.PyJWTError as exc: # noqa: F841 raise AuthError("Сессия недействительна.") + if security.is_session_revoked(payload): + raise AuthError("Сессия недействительна.") user = session.get(User, int(payload["sub"])) if user is None or not user.is_active: raise AuthError("Сессия недействительна.") + if int(payload.get("ver", 0)) != int(user.token_version or 0): + # Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57). + raise AuthError("Сессия недействительна.") return user @@ -37,7 +42,11 @@ def get_current_admin( payload = security.decode_token(token, security.AUDIENCE_ADMIN) except jwt.PyJWTError: raise AuthError("Сессия администратора недействительна.") + if security.is_session_revoked(payload): + raise AuthError("Сессия администратора недействительна.") user = session.get(User, int(payload["sub"])) if user is None or user.role != "admin" or not user.is_active: raise ForbiddenError("Доступ только для администратора.") + if int(payload.get("ver", 0)) != int(user.token_version or 0): + raise AuthError("Сессия администратора недействительна.") return user diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index a69fe9e..2541851 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -22,7 +22,7 @@ def establish_session( """Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" if not user.is_active: raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") - security.set_user_session(response, user.id, provider) # type: ignore[arg-type] + security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type] audit_service.record( session, actor_id=user.id, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 5b118bf..fd456da 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -9,6 +9,7 @@ import jwt from fastapi import Request, Response from app.core.config import settings +from app.core.token_revocation import revoked_tokens USER_COOKIE = "fs_session" ADMIN_COOKIE = "fs_admin" @@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool: # ─── JWT ───────────────────────────────────────────────────────────────────── -def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str: +def create_token( + subject: str | int, + audience: str, + ttl_minutes: int, + provider: str = "", + token_version: int = 0, +) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(subject), @@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()), "jti": secrets.token_hex(8), "provider": provider, + # Версия сессий владельца: при её росте (смена/сброс пароля) старые токены + # с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57). + "ver": token_version, } return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) @@ -95,9 +105,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]: return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie") -def set_user_session(response: Response, user_id: int, provider: str) -> None: +def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None: ttl = settings.jwt_user_ttl_minutes - token = create_token(user_id, AUDIENCE_USER, ttl, provider) + token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version) response.set_cookie( key=USER_COOKIE, value=token, @@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None: _set_csrf_cookie(response) -def set_admin_session(response: Response, admin_id: int) -> None: +def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None: ttl = settings.jwt_admin_ttl_minutes - token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local") + token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version) response.set_cookie( key=ADMIN_COOKIE, value=token, @@ -135,6 +145,33 @@ def clear_admin_session(response: Response) -> None: response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value) +def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None: + """Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout. + + Убивает именно этот токен (украденный/оставленный), не трогая другие устройства. + Некорректный/просроченный токен отзывать нечего — молча выходим.""" + token = request.cookies.get(cookie_name) + if not token: + return + try: + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.jwt_algorithm], + audience=audience, + ) + except jwt.PyJWTError: + return + exp = payload.get("exp") + if exp is not None: + revoked_tokens.revoke(payload.get("jti"), float(exp)) + + +def is_session_revoked(payload: dict) -> bool: + """Отозван ли этот токен точечно (через logout).""" + return revoked_tokens.is_revoked(payload.get("jti")) + + def client_ip(request: Request) -> str | None: """IP клиента для журнала аудита. diff --git a/backend/app/core/token_revocation.py b/backend/app/core/token_revocation.py new file mode 100644 index 0000000..ad4597d --- /dev/null +++ b/backend/app/core/token_revocation.py @@ -0,0 +1,53 @@ +"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена). + +Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn. +Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый +процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля +используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список. +""" +from __future__ import annotations + +import threading +import time + +# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память. +_PRUNE_ABOVE = 10_000 + + +class RevokedTokens: + """Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT).""" + + def __init__(self) -> None: + self._revoked: dict[str, float] = {} + self._lock = threading.Lock() + + def revoke(self, jti: str | None, expires_at: float) -> None: + if not jti: + return + now = time.time() + with self._lock: + if len(self._revoked) > _PRUNE_ABOVE: + for key, exp in list(self._revoked.items()): + if exp <= now: + del self._revoked[key] + self._revoked[jti] = expires_at + + def is_revoked(self, jti: str | None) -> bool: + if not jti: + return False + now = time.time() + with self._lock: + exp = self._revoked.get(jti) + if exp is None: + return False + if exp <= now: + del self._revoked[jti] # протухла — заодно вычищаем + return False + return True + + def clear(self) -> None: + with self._lock: + self._revoked.clear() + + +revoked_tokens = RevokedTokens() diff --git a/backend/app/models.py b/backend/app/models.py index 993d68e..4c5d45d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -93,6 +93,13 @@ class User(SQLModel, table=True): sa_column=Column(String(16), nullable=False, server_default="stub"), ) password_hash: str | None = Field(sa_column=Column(String(255), nullable=True)) + # Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя + # (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля + # и сбросе пароля админом — компрометация или утечка токена так прекращается (#57). + token_version: int = Field( + default=0, + sa_column=Column(Integer, nullable=False, server_default="0"), + ) active_group_id: int | None = Field( sa_column=Column( Integer, diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 0448c95..06aa7e3 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -41,7 +41,7 @@ def admin_login( session: Session = Depends(get_session), ) -> s.AdminMe: admin = admin_service.authenticate_admin(session, body.username, body.password) - security.set_admin_session(response, admin.id) # type: ignore[arg-type] + security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type] audit_service.record( session, actor_id=admin.id, @@ -56,7 +56,8 @@ def admin_login( @router.post("/auth/logout", response_model=s.OkResponse) -def admin_logout(response: Response) -> s.OkResponse: +def admin_logout(request: Request, response: Response) -> s.OkResponse: + security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN) security.clear_admin_session(response) return s.OkResponse() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5508402..acc9d87 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -116,6 +116,9 @@ def telegram_register( @router.post("/logout", response_model=s.OkResponse) -def logout(response: Response) -> s.OkResponse: +def logout(request: Request, response: Response) -> s.OkResponse: + # Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная + # cookie перестаёт работать сразу, а не живёт до конца TTL (#57). + security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER) security.clear_user_session(response) return s.OkResponse() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 648b056..b5298fb 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,14 +1,14 @@ """Роутер текущего пользователя.""" from __future__ import annotations -from fastapi import APIRouter, Depends, File, Query, Request, UploadFile +from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user from app.auth.password import check_current_password from app.auth.telegram import TelegramProvider -from app.core.security import client_ip +from app.core.security import client_ip, set_user_session from app.core.errors import NotFoundError from app.db.session import get_session from app.models import User @@ -122,6 +122,7 @@ def update_my_profile( def change_my_password( body: s.PasswordChange, request: Request, + response: Response, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MeRead: @@ -130,6 +131,9 @@ def change_my_password( if had_password: check_current_password(user, body.current_password) user_service.set_password(session, user, body.new_password) + # set_password инкрементит token_version → все ранее выданные токены отозваны (#57). + # Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные. + set_user_session(response, user.id, user.auth_provider, user.token_version) audit_service.record( session, actor_id=user.id, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index b51bc1e..8763606 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -161,9 +161,14 @@ def register_local(session: Session, nickname: str, password: str) -> User: def set_password(session: Session, user: User, new_password: str) -> User: - """Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).""" + """Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет). + + Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля + игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника). + Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver.""" validate_new_password(new_password) user.password_hash = hash_password(new_password) + user.token_version = (user.token_version or 0) + 1 session.add(user) session.commit() session.refresh(user) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8d51281..5f25d20 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -20,6 +20,7 @@ from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 import app.models # noqa: F401,E402 (регистрация моделей) from app.core.ratelimit import login_throttle # noqa: E402 from app.core.security import hash_password # noqa: E402 +from app.core.token_revocation import revoked_tokens # noqa: E402 from app.db.session import get_session # noqa: E402 from app.main import app # noqa: E402 from app.models import AuthIdentity, GroupMember, User # noqa: E402 @@ -50,6 +51,7 @@ def client(engine): app.dependency_overrides[get_session] = _get_session login_throttle.clear() # счётчики неудачных входов глобальны для процесса + revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index 1896b32..be34705 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -4,6 +4,7 @@ from __future__ import annotations from fastapi.testclient import TestClient from sqlmodel import Session, select +from app.main import app from app.models import User from tests.conftest import csrf_headers from tests.test_auth import _telegram_payload @@ -251,6 +252,64 @@ def test_set_password_requires_session(client: TestClient): assert _set_password(client, PASSWORD).status_code == 401 +# ─── Отзыв токена: logout и смена пароля (#57, F2) ──────────────────────────── + +def _me_with_token(cookie_name: str, token: str): + """Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie).""" + return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"}) + + +def test_logout_revokes_presented_token(client: TestClient): + _register(client) + tok = client.cookies.get("fs_session") + assert _me_with_token("fs_session", tok).status_code == 200 # пока жив + + assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200 + # тот же токен, предъявленный после выхода, больше не принимается + assert _me_with_token("fs_session", tok).status_code == 401 + + +def test_logout_does_not_revoke_other_devices(client: TestClient): + _register(client) # устройство A + tok_a = client.cookies.get("fs_session") + # устройство B: независимый вход тем же аккаунтом (свой jti) + b = TestClient(app) + assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200 + tok_b = b.cookies.get("fs_session") + assert tok_a and tok_b and tok_a != tok_b + + assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200 + assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел + assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут + + +def test_password_change_revokes_old_sessions_keeps_current(client: TestClient): + _register(client) + old = client.cookies.get("fs_session") + assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200 + # это устройство осталось в сессии (cookie перевыдан со свежим ver) + assert client.get("/api/users/me").status_code == 200 + # старый токен (другое устройство/утёкший) отозван инкрементом token_version + assert _me_with_token("fs_session", old).status_code == 401 + + +def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin): + player = _telegram_login(client, monkeypatch).json() + stolen = client.cookies.get("fs_session") # действующая сессия игрока + assert _me_with_token("fs_session", stolen).status_code == 200 + + client.cookies.clear() + _admin_login(client, make_admin) + r = client.put( + f"/api/admin/users/{player['id']}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + # сброс пароля админом обрывает прежние сессии игрока (в т.ч. злоумышленника) + assert _me_with_token("fs_session", stolen).status_code == 401 + + # ─── Привязка Telegram ─────────────────────────────────────────────────────── def _link_telegram(client: TestClient, monkeypatch, **fields): From 706eeb0af1ad0d92f134122a8a00626133469833 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:12:40 +0300 Subject: [PATCH 08/13] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20(throttle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin_login проходит через LoginThrottle (пара IP+логин, IP и сам аккаунт), как вход игрока; исчерпание лимита -> 429. Неудачные попытки пишутся в аудит (без пароля). Пароль админа — прямой путь к полному контролю, лимиты строже. #56 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/admin_login.py | 49 +++++++++++++++++++++++++++++ backend/app/routers/admin.py | 20 ++++++++++-- backend/tests/test_password_auth.py | 38 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 backend/app/auth/admin_login.py diff --git a/backend/app/auth/admin_login.py b/backend/app/auth/admin_login.py new file mode 100644 index 0000000..7e1d0d9 --- /dev/null +++ b/backend/app/auth/admin_login.py @@ -0,0 +1,49 @@ +"""Вход администратора под защитой от перебора (#56). + +Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин» +и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`). +Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный +барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого. +""" +from __future__ import annotations + +from sqlmodel import Session + +from app.core.errors import InvalidCredentialsError +from app.core.ratelimit import login_throttle +from app.models import User +from app.services import admin_service + +# Неудач за окно LoginThrottle (15 минут): +_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса +_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса +# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно +# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход +# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий). +_ACCOUNT_LIMIT = 50 + + +def _keys(ip: str, username: str) -> dict[str, int]: + pair_key = f"admin-login:{ip}:{username.casefold()}" + return { + pair_key: _PAIR_LIMIT, + f"admin-login-ip:{ip}": _IP_LIMIT, + f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT, + } + + +def login_admin(session: Session, username: str, password: str, ip: str | None) -> User: + """authenticate_admin под защитой от перебора. Сессию открывает вызывающий.""" + username = (username or "").strip() + ip = ip or "unknown" + limits = _keys(ip, username) + login_throttle.check(limits) + try: + admin = admin_service.authenticate_admin(session, username, password) + except InvalidCredentialsError: + login_throttle.fail(limits) + raise + # Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит. + for key in limits: + login_throttle.reset(key) + return admin diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 06aa7e3..a3e5314 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi from fastapi.responses import FileResponse from sqlmodel import Session +from app.auth.admin_login import login_admin from app.auth.deps import get_current_admin from app.core import security from app.core.security import client_ip -from app.core.errors import NotFoundError +from app.core.errors import InvalidCredentialsError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User @@ -40,7 +41,22 @@ def admin_login( response: Response, session: Session = Depends(get_session), ) -> s.AdminMe: - admin = admin_service.authenticate_admin(session, body.username, body.password) + try: + admin = login_admin(session, body.username, body.password, client_ip(request)) + except InvalidCredentialsError: + # Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к + # полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56). + audit_service.record( + session, + actor_id=None, + action="login_failed", + entity_type="admin", + payload={"username": (body.username or "")[:64]}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + session.commit() + raise security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type] audit_service.record( session, diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index be34705..b5ad43a 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -417,3 +417,41 @@ def test_player_cannot_set_passwords_via_admin(client: TestClient): headers=csrf_headers(client), ) assert r.status_code == 401 + + +# ─── Защита от перебора пароля администратора (#56, F1) ─────────────────────── + +def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch): + import app.core.ratelimit as ratelimit + + now = [2000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + make_admin("boss", "secret123") + + for _ in range(5): + r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"}) + assert r.status_code == 401 + + blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"}) + assert blocked.status_code == 429 # даже верный пароль не проверяется + assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS" + + now[0] += 15 * 60 # окно истекло + ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"}) + assert ok.status_code == 200 + + +def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine): + from app.models import AuditLog + + make_admin("boss", "secret123") + assert client.post( + "/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"} + ).status_code == 401 + + with Session(engine) as s: + logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all() + assert any( + log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs + ) + assert all("nope-secret-guess" not in str(log.payload) for log in logs) From 038a788f993caf8ec9a9d1231ffb5a3b8ab2fd50 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:15:04 +0300 Subject: [PATCH 09/13] =?UTF-8?q?Throttle:=20IP-=D0=BD=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=BB=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BD=D0=B0=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D1=82=20+=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D0=B1=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вход игрока и админа получают лимит login-user/admin-login-user, не зависящий от IP: ротация X-Forwarded-For (#58) больше не снимает защиту полностью. Успешный вход сбрасывает счётчики аккаунта. В docstring ratelimit — про сброс при рестарте и необходимость внешнего стора при нескольких воркерах. #60 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 11 ++++++++++- backend/app/core/ratelimit.py | 13 +++++++++++-- backend/tests/test_password_auth.py | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index b40058b..7c60e8f 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -27,6 +27,11 @@ _BCRYPT_MAX_BYTES = 72 # одного аккаунта; на IP — против перебора по многим логинам с одного адреса. _PAIR_LIMIT = 5 _IP_LIMIT = 20 +# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно +# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал +# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий); +# успешный вход его сбрасывает. +_ACCOUNT_LIMIT = 50 # Неверный текущий пароль при смене — на аккаунт. _CURRENT_PASSWORD_LIMIT = 5 @@ -67,14 +72,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None) 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} + 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 diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py index 0a6176a..941b5de 100644 --- a/backend/app/core/ratelimit.py +++ b/backend/app/core/ratelimit.py @@ -1,7 +1,16 @@ """Ограничение неудачных попыток ввода пароля (защита от перебора). -Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn. -Перезапуск их обнуляет; для окна в 15 минут это приемлемо. +Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn +(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это +приемлемо. Ограничения устойчивости (#60): + * рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после + перезапуска, но окно короткое, а рестарты редки; + * при уходе от одного воркера лимит делится между процессами (каждый считает своё) — + тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md + про SSE-шину), общий для всех воркеров. +Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт +(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов +(#58) не снимала защиту полностью. """ from __future__ import annotations diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index b5ad43a..a49905c 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -1,6 +1,7 @@ """Вход по логину (нику) и паролю: регистрация, вход, защита от перебора.""" from __future__ import annotations +import pytest from fastapi.testclient import TestClient from sqlmodel import Session, select @@ -181,6 +182,26 @@ def test_throttle_per_ip_across_logins(client: TestClient): assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429 +def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch): + """Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в + IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся.""" + import app.core.ratelimit as ratelimit + from app.auth.password import _ACCOUNT_LIMIT, login_player + from app.core.errors import InvalidCredentialsError, TooManyAttemptsError + + now = [3000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + _register(client, nickname="Жертва", password=PASSWORD) + + with Session(engine) as s: + for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес + with pytest.raises(InvalidCredentialsError): + login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}") + # ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт + with pytest.raises(TooManyAttemptsError): + login_player(s, "Жертва", "wrong-password", ip="203.0.113.7") + + # ─── Установка и смена пароля ──────────────────────────────────────────────── def _set_password(client: TestClient, new: str, current: str | None = None): From 112b583264959d77ceecc4b7330dde9c31ad86bc Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:16:49 +0300 Subject: [PATCH 10/13] =?UTF-8?q?=D0=A0=D0=B5=D0=B3=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F:=20throttle=20=D0=BF=D0=BE=20IP?= =?UTF-8?q?=20=D0=BE=D1=82=20=D1=81=D0=BF=D0=B0=D0=BC=D0=B0=20=D0=B0=D0=BA?= =?UTF-8?q?=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit throttle_register ограничивает частоту POST /api/auth/register с одного IP через тот же LoginThrottle (считаются все попытки). Enumeration ников через 409 не закрываем — ники и так публичны в топе (отмечено в отчёте). #62 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 14 ++++++++++++++ backend/app/routers/auth.py | 3 ++- backend/tests/test_password_auth.py | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index 7c60e8f..6e09643 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -34,6 +34,8 @@ _IP_LIMIT = 20 _ACCOUNT_LIMIT = 50 # Неверный текущий пароль при смене — на аккаунт. _CURRENT_PASSWORD_LIMIT = 5 +# Регистраций с одного IP за окно — против спама аккаунтов (#62). +_REGISTER_IP_LIMIT = 10 def validate_new_password(password: str) -> None: @@ -87,6 +89,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None) 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: """Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя: иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем.""" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index acc9d87..a9fd310 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session from app.auth.login import establish_session -from app.auth.password import login_player +from app.auth.password import login_player, throttle_register from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security @@ -39,6 +39,7 @@ def password_register( session: Session = Depends(get_session), ) -> s.MeRead: """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" + throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62) user = user_service.register_local(session, body.nickname, body.password) audit_service.record( session, diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index a49905c..65c78ce 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -65,6 +65,27 @@ def test_register_rejects_bad_nickname(client: TestClient): assert _register(client, nickname="x").status_code == 422 +def test_register_is_throttled_per_ip(client: TestClient, monkeypatch): + """Спам регистраций с одного IP упирается в лимит (#62).""" + import app.core.ratelimit as ratelimit + from app.auth.password import _REGISTER_IP_LIMIT + + now = [4000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + + for i in range(_REGISTER_IP_LIMIT): + client.cookies.clear() + assert _register(client, nickname=f"Ник{i}").status_code == 200 + client.cookies.clear() + blocked = _register(client, nickname="Лишний") + assert blocked.status_code == 429 + assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS" + + now[0] += 15 * 60 # окно истекло + client.cookies.clear() + assert _register(client, nickname="ПослеОкна").status_code == 200 + + # ─── Вход ──────────────────────────────────────────────────────────────────── def test_login_after_logout(client: TestClient): From 0799aee684d6ff9bb655a870eb1ca1df07cc0482 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:18:08 +0300 Subject: [PATCH 11/13] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=84=D0=B8=D0=B3:=20f?= =?UTF-8?q?ail-fast=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D1=85=20=D0=B2=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model_validator в Settings: при APP_ENV=production приложение не стартует, если SECRET_KEY дефолтный/короче 32 символов или (при включённом бутстрапе) ADMIN_PASSWORD дефолтный/пустой. dev/test не затронуты — там дефолты остаются нормой. #59 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/core/config.py | 36 +++++++++++++- backend/tests/test_config_security.py | 67 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_config_security.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e73c3aa..0085fbe 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,6 +4,7 @@ from __future__ import annotations from functools import lru_cache from pathlib import Path +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково @@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # (исключён из образа) — там настройки приходят переменными от docker compose. _ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") +# Небезопасные значения по умолчанию (годятся только для dev). В production приложение +# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59). +_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production" +_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password" +_MIN_SECRET_KEY_LENGTH = 32 + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -50,7 +57,7 @@ class Settings(BaseSettings): prod_achievements_dir: str = "/data/achievements" # JWT / cookie - secret_key: str = "change-me-dev-secret-not-for-production" + secret_key: str = _DEFAULT_SECRET_KEY jwt_algorithm: str = "HS256" jwt_user_ttl_minutes: int = 60 * 24 * 7 jwt_admin_ttl_minutes: int = 60 * 8 @@ -66,7 +73,7 @@ class Settings(BaseSettings): # Бутстрап администратора admin_bootstrap_enabled: bool = True admin_username: str = "admin" - admin_password: str = "change-me-admin-password" + admin_password: str = _DEFAULT_ADMIN_PASSWORD admin_nickname: str = "Администратор" # CORS (для раздельного dev-режима фронта). Строка из env, через запятую — @@ -115,6 +122,31 @@ class Settings(BaseSettings): def cookie_domain_value(self) -> str | None: return self.cookie_domain or None + @model_validator(mode="after") + def _forbid_default_secrets_in_prod(self) -> "Settings": + """Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59). + + Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы + с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и + известным паролем администратора. В dev/test проверка не мешает — там дефолты норма.""" + if self.app_env.lower() != "production": + return self + problems: list[str] = [] + if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH: + problems.append( + f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов" + ) + if self.admin_bootstrap_enabled: + password = (self.admin_password or "").strip() + if not password or password == _DEFAULT_ADMIN_PASSWORD: + problems.append("ADMIN_PASSWORD не задан или дефолтный") + if problems: + raise ValueError( + "Небезопасная конфигурация production — задайте секреты в .env: " + + "; ".join(problems) + ) + return self + @lru_cache def get_settings() -> Settings: diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py new file mode 100644 index 0000000..8c73066 --- /dev/null +++ b/backend/tests/test_config_security.py @@ -0,0 +1,67 @@ +"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4).""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.core import config + +_STRONG_SECRET = "k" * 40 +_STRONG_ADMIN_PW = "a-strong-admin-password" + + +def test_production_rejects_default_secret_key(): + with pytest.raises(ValidationError): + config.Settings( + app_env="production", + secret_key=config._DEFAULT_SECRET_KEY, + admin_password=_STRONG_ADMIN_PW, + ) + + +def test_production_rejects_short_secret_key(): + with pytest.raises(ValidationError): + config.Settings( + app_env="production", + secret_key="too-short", + admin_password=_STRONG_ADMIN_PW, + ) + + +def test_production_rejects_default_admin_password(): + with pytest.raises(ValidationError): + config.Settings( + app_env="production", + secret_key=_STRONG_SECRET, + admin_bootstrap_enabled=True, + admin_password=config._DEFAULT_ADMIN_PASSWORD, + ) + + +def test_production_accepts_strong_secrets(): + s = config.Settings( + app_env="production", + secret_key=_STRONG_SECRET, + admin_password=_STRONG_ADMIN_PW, + ) + assert s.is_production + + +def test_production_skips_admin_check_when_bootstrap_disabled(): + # Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт. + s = config.Settings( + app_env="production", + secret_key=_STRONG_SECRET, + admin_bootstrap_enabled=False, + admin_password=config._DEFAULT_ADMIN_PASSWORD, + ) + assert s.is_production + + +def test_development_allows_defaults(): + s = config.Settings( + app_env="development", + secret_key=config._DEFAULT_SECRET_KEY, + admin_password=config._DEFAULT_ADMIN_PASSWORD, + ) + assert s.is_development From ce7fbad58ed2d2534fac8267ea136dded71b366b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:19:42 +0300 Subject: [PATCH 12/13] =?UTF-8?q?API:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D1=85=D0=B5=D0=BC=D1=83=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B5=20+=20security-=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BD=D0=B0=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.py: openapi.json/docs/redoc отдаются только в dev/test (нужны для gen:api), в production отключены. Caddyfile (edge): HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, скрыт Server; CSP подготовлена, но выключена до проверки на test-клоне (строгая политика ломает SPA/Telegram-виджет/SSE). #61 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/main.py | 10 +++++++--- backend/tests/test_api_hardening.py | 22 ++++++++++++++++++++++ deploy/vps/Caddyfile | 21 ++++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_api_hardening.py diff --git a/backend/app/main.py b/backend/app/main.py index d58e61b..b91cb9c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI): def create_app() -> FastAPI: + # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для + # `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем — + # незачем облегчать разведку поверхности API анонимам (#61). + docs_enabled = not settings.is_production app = FastAPI( title="Forbidden Stars API", version="0.1.0", - openapi_url="/api/openapi.json", - docs_url="/api/docs", - redoc_url="/api/redoc", + openapi_url="/api/openapi.json" if docs_enabled else None, + docs_url="/api/docs" if docs_enabled else None, + redoc_url="/api/redoc" if docs_enabled else None, lifespan=_lifespan, ) diff --git a/backend/tests/test_api_hardening.py b/backend/tests/test_api_hardening.py new file mode 100644 index 0000000..4de3185 --- /dev/null +++ b/backend/tests/test_api_hardening.py @@ -0,0 +1,22 @@ +"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.core import config +from app.main import create_app + + +def test_openapi_open_in_development(client: TestClient): + # Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`. + assert client.get("/api/openapi.json").status_code == 200 + assert client.get("/api/docs").status_code == 200 + + +def test_openapi_closed_in_production(monkeypatch): + monkeypatch.setattr(config.settings, "app_env", "production") + prod_app = create_app() + c = TestClient(prod_app) + assert c.get("/api/openapi.json").status_code == 404 + assert c.get("/api/docs").status_code == 404 + assert c.get("/api/redoc").status_code == 404 diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index e469638..09cc82c 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -18,8 +18,27 @@ # иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит. # Файл заглушки — deploy/vps/maintenance.html. -# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима. +# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима. (edge) { + # Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff — + # запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить + # в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не + # светить используемый прокси. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + -Server + } + + # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая + # политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с + # telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать + # после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61). + # header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый # SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно. # Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно. From ec65f104ee47418143c31192544360bd20e1c867 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:21:27 +0300 Subject: [PATCH 13/13] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=BA=D1=81=D0=B8-?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8:=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20X-Forwarded-For?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entrypoint.sh: --forwarded-allow-ips сужен с "*" до loopback + приватных сетей compose (переопределяемо FORWARDED_ALLOW_IPS) — uvicorn сканирует XFF справа и берёт реальный адрес, подставленное клиентом левое значение игнорируется. Caddyfile: reverse_proxy перезаписывает X-Forwarded-For реальным пиром (header_up {remote_host}) вместо добавления. Итог — достоверный IP для throttle и аудита. Инфра-часть проверяется на test-клоне. #58 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/entrypoint.sh | 12 ++++++++++-- deploy/vps/Caddyfile | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 3425430..0407db9 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -9,8 +9,16 @@ python -m app.bootstrap echo "[entrypoint] Запуск сервера…" # --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS): -# чтобы приложение знало, что снаружи запрос пришёл по HTTPS. +# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент. +# +# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For, +# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только +# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback +# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес, +# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит. +# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается. # --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита # остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown. +FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \ - --proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10 + --proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10 diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index 09cc82c..c4f4f7f 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -5,8 +5,13 @@ # forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST # # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к -# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host — +# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host — # приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile. +# +# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не +# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для +# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это +# делает клиентский IP достоверным. # Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом). # # SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/ @@ -65,11 +70,14 @@ forbiddenstars.ru { handle @sse { reverse_proxy 127.0.0.1:9000 { flush_interval -1 + header_up X-Forwarded-For {remote_host} } } handle { encode zstd gzip - reverse_proxy 127.0.0.1:9000 + reverse_proxy 127.0.0.1:9000 { + header_up X-Forwarded-For {remote_host} + } } import edge } @@ -80,11 +88,14 @@ forbidden-stars.ru { handle @sse { reverse_proxy 127.0.0.1:9001 { flush_interval -1 + header_up X-Forwarded-For {remote_host} } } handle { encode zstd gzip - reverse_proxy 127.0.0.1:9001 + reverse_proxy 127.0.0.1:9001 { + header_up X-Forwarded-For {remote_host} + } } import edge }