v1.37 — аутентификация через логин и пароль

This commit is contained in:
2026-09-13 18:48:46 +03:00
52 changed files with 2350 additions and 194 deletions
+17 -9
View File
@@ -1,7 +1,7 @@
# Forbidden Stars — учёт партий # Forbidden Stars — учёт партий
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий профили игроков (вход по логину и паролю или через Telegram), группы, создание партий
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель. с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite - **Бэкенд / ядро + 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 (оба стека) npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
``` ```
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику), Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
в проде — только Telegram (см. раздел «Аутентификация»). в проде stub нет (см. раздел «Аутентификация»).
## Production (Docker на Pi) ## Production (Docker на Pi)
@@ -108,7 +108,7 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми
## Test — локальный прод-клон в контейнере ## Test — локальный прод-клон в контейнере
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000). Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
@@ -123,8 +123,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри - Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi). - Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** — - Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** —
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`). по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`.
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
@@ -136,9 +136,17 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
| | dev | test / prod | | | 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-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** - **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development` Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
@@ -165,7 +173,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | | Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | | Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | | База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram | | Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram |
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что - **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
@@ -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")
+49
View File
@@ -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
+9
View File
@@ -21,9 +21,14 @@ def get_current_user(
payload = security.decode_token(token, security.AUDIENCE_USER) payload = security.decode_token(token, security.AUDIENCE_USER)
except jwt.PyJWTError as exc: # noqa: F841 except jwt.PyJWTError as exc: # noqa: F841
raise AuthError("Сессия недействительна.") raise AuthError("Сессия недействительна.")
if security.is_session_revoked(payload):
raise AuthError("Сессия недействительна.")
user = session.get(User, int(payload["sub"])) user = session.get(User, int(payload["sub"]))
if user is None or not user.is_active: if user is None or not user.is_active:
raise AuthError("Сессия недействительна.") raise AuthError("Сессия недействительна.")
if int(payload.get("ver", 0)) != int(user.token_version or 0):
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
raise AuthError("Сессия недействительна.")
return user return user
@@ -37,7 +42,11 @@ def get_current_admin(
payload = security.decode_token(token, security.AUDIENCE_ADMIN) payload = security.decode_token(token, security.AUDIENCE_ADMIN)
except jwt.PyJWTError: except jwt.PyJWTError:
raise AuthError("Сессия администратора недействительна.") raise AuthError("Сессия администратора недействительна.")
if security.is_session_revoked(payload):
raise AuthError("Сессия администратора недействительна.")
user = session.get(User, int(payload["sub"])) user = session.get(User, int(payload["sub"]))
if user is None or user.role != "admin" or not user.is_active: if user is None or user.role != "admin" or not user.is_active:
raise ForbiddenError("Доступ только для администратора.") raise ForbiddenError("Доступ только для администратора.")
if int(payload.get("ver", 0)) != int(user.token_version or 0):
raise AuthError("Сессия администратора недействительна.")
return user return user
+1 -1
View File
@@ -22,7 +22,7 @@ def establish_session(
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" """Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
if not user.is_active: if not user.is_active:
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") 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( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
+113
View File
@@ -0,0 +1,113 @@
"""Вход игрока по логину (нику) и паролю.
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику
без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать.
"""
from __future__ import annotations
from functools import lru_cache
from sqlmodel import Session, select
from app.core.errors import (
InvalidCredentialsError,
ValidationError,
WrongCurrentPasswordError,
)
from app.core.ratelimit import login_throttle
from app.core.security import hash_password, verify_password
from app.models import User
PASSWORD_MIN_CHARS = 8
# bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает
# ValueError — ограничиваем явно, с понятным сообщением.
_BCRYPT_MAX_BYTES = 72
# Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
_PAIR_LIMIT = 5
_IP_LIMIT = 20
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
# успешный вход его сбрасывает.
_ACCOUNT_LIMIT = 50
# Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
_REGISTER_IP_LIMIT = 10
def validate_new_password(password: str) -> None:
if len(password) < PASSWORD_MIN_CHARS:
raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.")
if not password.strip():
raise ValidationError("Пароль не может состоять из одних пробелов.")
if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES:
raise ValidationError(
"Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)."
)
@lru_cache
def _dummy_hash() -> str:
return hash_password("dummy-password-for-timing")
def authenticate_player(session: Session, nickname: str, password: str) -> User:
"""Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie.
Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает
столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин."""
user = session.exec(
select(User).where(User.nickname == nickname, User.role == "player")
).first()
stored = user.password_hash if user is not None else None
valid = verify_password(password, stored or _dummy_hash())
if user is None or not stored or not valid:
raise InvalidCredentialsError()
return user
def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User:
"""authenticate_player под защитой от перебора. Сессию открывает вызывающий."""
nickname = (nickname or "").strip()
ip = ip or "unknown"
pair_key = f"login:{ip}:{nickname.casefold()}"
account_key = f"login-user:{nickname.casefold()}"
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
login_throttle.check(limits)
try:
user = authenticate_player(session, nickname, password)
except InvalidCredentialsError:
login_throttle.fail(limits)
raise
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
# оставляем — он общий для всех логинов с адреса.
login_throttle.reset(pair_key)
login_throttle.reset(account_key)
return user
def 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:
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
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)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Доступные методы входа по окружению. """Доступные методы входа по окружению.
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG). Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода. Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,7 +9,7 @@ from app.core.config import settings
def enabled_methods() -> list[str]: def enabled_methods() -> list[str]:
methods = ["telegram"] methods = ["password", "telegram"]
if settings.is_development: if settings.is_development:
methods.append("stub") methods.append("stub")
return methods return methods
+34 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# (исключён из образа) — там настройки приходят переменными от docker compose. # (исключён из образа) — там настройки приходят переменными от docker compose.
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") _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): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
prod_achievements_dir: str = "/data/achievements" prod_achievements_dir: str = "/data/achievements"
# JWT / cookie # JWT / cookie
secret_key: str = "change-me-dev-secret-not-for-production" secret_key: str = _DEFAULT_SECRET_KEY
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_user_ttl_minutes: int = 60 * 24 * 7 jwt_user_ttl_minutes: int = 60 * 24 * 7
jwt_admin_ttl_minutes: int = 60 * 8 jwt_admin_ttl_minutes: int = 60 * 8
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
# Бутстрап администратора # Бутстрап администратора
admin_bootstrap_enabled: bool = True admin_bootstrap_enabled: bool = True
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "change-me-admin-password" admin_password: str = _DEFAULT_ADMIN_PASSWORD
admin_nickname: str = "Администратор" admin_nickname: str = "Администратор"
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую — # CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
def cookie_domain_value(self) -> str | None: def cookie_domain_value(self) -> str | None:
return self.cookie_domain or 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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+36
View File
@@ -136,5 +136,41 @@ class InvalidCredentialsError(AuthError):
super().__init__("Неверный логин или пароль.") 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 WrongCurrentPasswordError(ForbiddenError):
code = "WRONG_CURRENT_PASSWORD"
def __init__(self) -> None:
super().__init__("Текущий пароль неверен.")
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: async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
return exc.to_response() return exc.to_response()
+79
View File
@@ -0,0 +1,79 @@
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
Счётчики живут в памяти процесса — как и 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
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()
+42 -5
View File
@@ -9,6 +9,7 @@ import jwt
from fastapi import Request, Response from fastapi import Request, Response
from app.core.config import settings from app.core.config import settings
from app.core.token_revocation import revoked_tokens
USER_COOKIE = "fs_session" USER_COOKIE = "fs_session"
ADMIN_COOKIE = "fs_admin" ADMIN_COOKIE = "fs_admin"
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
# ─── JWT ───────────────────────────────────────────────────────────────────── # ─── 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) now = datetime.now(timezone.utc)
payload = { payload = {
"sub": str(subject), "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()), "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
"jti": secrets.token_hex(8), "jti": secrets.token_hex(8),
"provider": provider, "provider": provider,
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
"ver": token_version,
} }
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) 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") 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 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( response.set_cookie(
key=USER_COOKIE, key=USER_COOKIE,
value=token, value=token,
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
_set_csrf_cookie(response) _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 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( response.set_cookie(
key=ADMIN_COOKIE, key=ADMIN_COOKIE,
value=token, 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) 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: def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита. """IP клиента для журнала аудита.
+53
View File
@@ -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()
+12 -4
View File
@@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI):
def create_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( app = FastAPI(
title="Forbidden Stars API", title="Forbidden Stars API",
version="0.1.0", version="0.1.0",
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json" if docs_enabled else None,
docs_url="/api/docs", docs_url="/api/docs" if docs_enabled else None,
redoc_url="/api/redoc", redoc_url="/api/redoc" if docs_enabled else None,
lifespan=_lifespan, lifespan=_lifespan,
) )
@@ -177,13 +181,17 @@ def create_app() -> FastAPI:
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse: 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( return JSONResponse(
status_code=422, status_code=422,
content={ content={
"error": { "error": {
"code": "VALIDATION_ERROR", "code": "VALIDATION_ERROR",
"message": "Ошибка валидации запроса.", "message": "Ошибка валидации запроса.",
"details": exc.errors(), "details": details,
} }
}, },
) )
+7
View File
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
sa_column=Column(String(16), nullable=False, server_default="stub"), sa_column=Column(String(16), nullable=False, server_default="stub"),
) )
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True)) 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( active_group_id: int | None = Field(
sa_column=Column( sa_column=Column(
Integer, Integer,
+58 -24
View File
@@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.admin_login import login_admin
from app.auth.deps import get_current_admin from app.auth.deps import get_current_admin
from app.core import security from app.core import security
from app.core.security import client_ip 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.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
@@ -40,8 +41,23 @@ def admin_login(
response: Response, response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> s.AdminMe: ) -> s.AdminMe:
admin = admin_service.authenticate_admin(session, body.username, body.password) try:
security.set_admin_session(response, admin.id) # type: ignore[arg-type] 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( audit_service.record(
session, session,
actor_id=admin.id, actor_id=admin.id,
@@ -56,7 +72,8 @@ def admin_login(
@router.post("/auth/logout", response_model=s.OkResponse) @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) security.clear_admin_session(response)
return s.OkResponse() return s.OkResponse()
@@ -68,24 +85,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]) @router.get("/users", response_model=list[s.AdminUserRead])
def list_users( def list_users(
query: str | None = Query(None), query: str | None = Query(None),
session: Session = Depends(get_session), session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> list[s.AdminUserRead]: ) -> list[s.AdminUserRead]:
return [ return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
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)
]
@router.patch("/users/{user_id}", response_model=s.AdminUserRead) @router.patch("/users/{user_id}", response_model=s.AdminUserRead)
@@ -107,15 +126,30 @@ def update_user(
ip=client_ip(request), ip=client_ip(request),
) )
session.commit() session.commit()
return s.AdminUserRead( return _admin_user_read(u)
id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
role=u.role, @router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
is_active=u.is_active, def set_user_password(
auth_provider=u.auth_provider, user_id: int,
telegram_id=u.telegram_id, body: s.AdminPasswordSet,
created_at=iso_utc(u.created_at), 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 возможность, вынесена в # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
+46 -4
View File
@@ -1,6 +1,6 @@
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход. """Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev. Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
""" """
from __future__ import annotations from __future__ import annotations
@@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session from sqlmodel import Session
from app.auth.login import establish_session from app.auth.login import establish_session
from app.auth.password import login_player, throttle_register
from app.auth.registry import enabled_methods from app.auth.registry import enabled_methods
from app.auth.telegram import TelegramProvider from app.auth.telegram import TelegramProvider
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.errors import TelegramNicknameRequiredError from app.core.errors import TelegramNicknameRequiredError
from app.core.security import client_ip
from app.db.session import get_session from app.db.session import get_session
from app.routers.users import build_me from app.routers.users import build_me
from app.schemas import api as s 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"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -29,6 +31,43 @@ 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 привязывается позже в профиле."""
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
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) @router.post("/telegram", response_model=s.MeRead)
def telegram_login( def telegram_login(
body: s.TelegramAuthPayload, body: s.TelegramAuthPayload,
@@ -78,6 +117,9 @@ def telegram_register(
@router.post("/logout", response_model=s.OkResponse) @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) security.clear_user_session(response)
return s.OkResponse() return s.OkResponse()
+59 -2
View File
@@ -1,12 +1,14 @@
"""Роутер текущего пользователя.""" """Роутер текущего пользователя."""
from __future__ import annotations 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 fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.security import client_ip from app.auth.password import check_current_password
from app.auth.telegram import TelegramProvider
from app.core.security import client_ip, set_user_session
from app.core.errors import NotFoundError from app.core.errors import NotFoundError
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
@@ -36,6 +38,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
history_mode=user.history_mode, history_mode=user.history_mode,
history_detail=user.history_detail, history_detail=user.history_detail,
groups=groups, groups=groups,
has_password=user.password_hash is not None,
) )
@@ -115,6 +118,60 @@ def update_my_profile(
return build_me(session, user) return build_me(session, user)
@router.put("/me/password", response_model=s.MeRead)
def change_my_password(
body: s.PasswordChange,
request: Request,
response: Response,
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)
# 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,
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) @router.put("/me/avatar", response_model=s.MeRead)
def upload_my_avatar( def upload_my_avatar(
file: UploadFile = File(...), file: UploadFile = File(...),
+32 -1
View File
@@ -12,11 +12,33 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"]
# ─── Auth ──────────────────────────────────────────────────────────────────── # ─── Auth ────────────────────────────────────────────────────────────────────
class AuthConfig(BaseModel): class AuthConfig(BaseModel):
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве. # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = [] methods: list[str] = []
telegram_bot_username: str | None = None 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 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): class TelegramAuthPayload(BaseModel):
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC). # Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@@ -99,6 +121,9 @@ class UserRead(BaseModel):
class MeRead(UserRead): class MeRead(UserRead):
groups: list[GroupBrief] = [] groups: list[GroupBrief] = []
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
# фронт не пускает дальше окна установки пароля.
has_password: bool = False
class NicknameUpdate(BaseModel): class NicknameUpdate(BaseModel):
@@ -493,6 +518,7 @@ class AdminUserRead(BaseModel):
auth_provider: str auth_provider: str
telegram_id: int | None = None telegram_id: int | None = None
created_at: str created_at: str
has_password: bool = False
class AdminUserUpdate(BaseModel): class AdminUserUpdate(BaseModel):
@@ -500,6 +526,11 @@ class AdminUserUpdate(BaseModel):
is_active: bool | None = None is_active: bool | None = None
class AdminPasswordSet(BaseModel):
# Новый пароль игроку от админа — способ восстановить забытый пароль.
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class AdminGroupRead(BaseModel): class AdminGroupRead(BaseModel):
id: int id: int
name: str name: str
+11
View File
@@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
return user 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 нет намеренно: # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). # логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
+73 -1
View File
@@ -7,9 +7,17 @@ from pathlib import Path
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.password import validate_new_password
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core.config import settings 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.core.timeutil import utcnow
from app.models import AuthIdentity, Faction, User from app.models import AuthIdentity, Faction, User
@@ -132,6 +140,70 @@ def register_from_identity(
return _create_from_identity(session, identity, nickname) 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 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)
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: def update_nickname(session: Session, user: User, new_nickname: str) -> User:
new_nickname = (new_nickname or "").strip() new_nickname = (new_nickname or "").strip()
if not nickname_format_ok(new_nickname): if not nickname_format_ok(new_nickname):
+10 -2
View File
@@ -9,8 +9,16 @@ python -m app.bootstrap
echo "[entrypoint] Запуск сервера…" echo "[entrypoint] Запуск сервера…"
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS): # --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 сами не закрываются, и без лимита # --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown. # остановка ждала бы их до 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 \ 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
+4
View File
@@ -18,7 +18,9 @@ from sqlalchemy.pool import StaticPool # noqa: E402
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
import app.models # noqa: F401,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.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.db.session import get_session # noqa: E402
from app.main import app # noqa: E402 from app.main import app # noqa: E402
from app.models import AuthIdentity, GroupMember, User # noqa: E402 from app.models import AuthIdentity, GroupMember, User # noqa: E402
@@ -48,6 +50,8 @@ def client(engine):
yield s yield s
app.dependency_overrides[get_session] = _get_session app.dependency_overrides[get_session] = _get_session
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
+22
View File
@@ -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
+5 -4
View File
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
from tests.conftest import csrf_headers 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() cfg = client.get("/api/auth/config").json()
assert "password" in cfg["methods"]
assert "telegram" in cfg["methods"] assert "telegram" in cfg["methods"]
assert "stub" in cfg["methods"] # dev → доступен вход по нику assert "stub" in cfg["methods"] # dev → доступен вход по нику
@@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch):
from app.core.config import settings from app.core.config import settings
monkeypatch.setattr(settings, "app_env", "development") 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") 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") 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): def test_env_flags_and_db_path(monkeypatch):
+67
View File
@@ -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
+499
View File
@@ -0,0 +1,499 @@
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
from __future__ import annotations
import pytest
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
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_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):
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
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):
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
# ─── Отзыв токена: 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 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
# ─── Защита от перебора пароля администратора (#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)
+31
View File
@@ -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
+34 -4
View File
@@ -5,8 +5,13 @@
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST # forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
# #
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к # 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. # приложение это учитывает (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 + промежуточные одним файлом). # Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
# #
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/ # SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
@@ -18,8 +23,27 @@
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит. # иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
# Файл заглушки — deploy/vps/maintenance.html. # Файл заглушки — deploy/vps/maintenance.html.
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима. # Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
(edge) { (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) НЕ кэшируем. Иначе браузер отдаёт старый # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно. # SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно. # Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
@@ -46,11 +70,14 @@ forbiddenstars.ru {
handle @sse { handle @sse {
reverse_proxy 127.0.0.1:9000 { reverse_proxy 127.0.0.1:9000 {
flush_interval -1 flush_interval -1
header_up X-Forwarded-For {remote_host}
} }
} }
handle { handle {
encode zstd gzip 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 import edge
} }
@@ -61,11 +88,14 @@ forbidden-stars.ru {
handle @sse { handle @sse {
reverse_proxy 127.0.0.1:9001 { reverse_proxy 127.0.0.1:9001 {
flush_interval -1 flush_interval -1
header_up X-Forwarded-For {remote_host}
} }
} }
handle { handle {
encode zstd gzip 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 import edge
} }
+303
View File
@@ -21,6 +21,46 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/auth/telegram": {
parameters: { parameters: {
query?: never; query?: never;
@@ -122,6 +162,46 @@ export interface paths {
patch: operations["update_my_profile_api_users_me_profile_patch"]; patch: operations["update_my_profile_api_users_me_profile_patch"];
trace?: never; 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": { "/api/users/me/avatar": {
parameters: { parameters: {
query?: never; query?: never;
@@ -844,6 +924,26 @@ export interface paths {
patch: operations["update_user_api_admin_users__user_id__patch"]; patch: operations["update_user_api_admin_users__user_id__patch"];
trace?: never; 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": { "/api/admin/groups": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1224,6 +1324,11 @@ export interface components {
/** Role */ /** Role */
role: string; role: string;
}; };
/** AdminPasswordSet */
AdminPasswordSet: {
/** New Password */
new_password: string;
};
/** AdminUserRead */ /** AdminUserRead */
AdminUserRead: { AdminUserRead: {
/** Id */ /** Id */
@@ -1240,6 +1345,11 @@ export interface components {
telegram_id?: number | null; telegram_id?: number | null;
/** Created At */ /** Created At */
created_at: string; created_at: string;
/**
* Has Password
* @default false
*/
has_password: boolean;
}; };
/** AdminUserUpdate */ /** AdminUserUpdate */
AdminUserUpdate: { AdminUserUpdate: {
@@ -1887,6 +1997,11 @@ export interface components {
* @default [] * @default []
*/ */
groups: components["schemas"]["GroupBrief"][]; groups: components["schemas"]["GroupBrief"][];
/**
* Has Password
* @default false
*/
has_password: boolean;
}; };
/** MemberAdd */ /** MemberAdd */
MemberAdd: { MemberAdd: {
@@ -1990,6 +2105,27 @@ export interface components {
/** Comment */ /** Comment */
comment?: string | null; 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 */
ProfileStats: { ProfileStats: {
/** User Id */ /** 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: { telegram_login_api_auth_telegram_post: {
parameters: { parameters: {
query?: never; 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: { upload_my_avatar_api_users_me_avatar_put: {
parameters: { parameters: {
query?: never; 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: { list_groups_api_admin_groups_get: {
parameters: { parameters: {
query?: never; query?: never;
+4
View File
@@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
import { BottomBar } from "./BottomBar"; import { BottomBar } from "./BottomBar";
import { NotificationBell } from "./NotificationBell"; import { NotificationBell } from "./NotificationBell";
import { NotificationToaster } from "./NotificationToaster"; import { NotificationToaster } from "./NotificationToaster";
import { SetPasswordDialog } from "./SetPasswordDialog";
import { SideMenu } from "./SideMenu"; import { SideMenu } from "./SideMenu";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
@@ -51,6 +52,9 @@ export function AppShell() {
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} /> <SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
<BottomBar onMenu={() => setMenuOpen(true)} /> <BottomBar onMenu={() => setMenuOpen(true)} />
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
</div> </div>
); );
} }
@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import { useCreateGroup } from "../hooks/groups"; import { useCreateGroup } from "../hooks/groups";
import { useExpansions } from "../hooks/reference"; import { useExpansions } from "../hooks/reference";
import { Switch } from "./Switch"; import { Switch } from "./Switch";
@@ -9,7 +8,6 @@ import { Switch } from "./Switch";
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) { export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
const { data: expansions } = useExpansions(); const { data: expansions } = useExpansions();
const createGroup = useCreateGroup(); const createGroup = useCreateGroup();
const toast = useToast();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -25,7 +23,6 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
setError(null); setError(null);
try { try {
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] }); await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
toast.show("Группа создана");
onCreated?.(); onCreated?.();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось создать группу"); setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
+2 -3
View File
@@ -15,16 +15,15 @@ export function GroupInvitations() {
await accept.mutateAsync(id); await accept.mutateAsync(id);
toast.show("Вы вступили в группу"); toast.show("Вы вступили в группу");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
const onDecline = async (id: number) => { const onDecline = async (id: number) => {
try { try {
await decline.mutateAsync(id); await decline.mutateAsync(id);
toast.show("Приглашение отклонено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
+3 -3
View File
@@ -35,14 +35,14 @@ export function MatchMedia({
let slots = max - attachments.length; let slots = max - attachments.length;
for (const f of files) { for (const f of files) {
if (slots <= 0) { if (slots <= 0) {
toast.show(`Максимум ${max} фото`); toast.error(`Максимум ${max} фото`);
break; break;
} }
try { try {
await onUpload(f); await onUpload(f);
slots--; slots--;
} catch (err) { } catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
break; break;
} }
} }
@@ -53,7 +53,7 @@ export function MatchMedia({
try { try {
await onDelete(id); await onDelete(id);
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
} }
}; };
+16 -30
View File
@@ -1,18 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useNotifications, type Notification } from "../hooks/notifications"; import { useToast } from "../context/ToastContext";
import { useNotifications } from "../hooks/notifications";
const TOAST_TTL_MS = 5000;
const MAX_VISIBLE = 4;
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull). /** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */ * Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id.
* Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */
export function NotificationToaster({ enabled }: { enabled: boolean }) { export function NotificationToaster({ enabled }: { enabled: boolean }) {
const { data } = useNotifications(enabled); const { data } = useNotifications(enabled);
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast();
const seen = useRef<Set<number> | null>(null); const seen = useRef<Set<number> | null>(null);
const [toasts, setToasts] = useState<Notification[]>([]);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
@@ -22,29 +21,16 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) {
return; return;
} }
const fresh = items.filter((n) => !seen.current!.has(n.id)); const fresh = items.filter((n) => !seen.current!.has(n.id));
if (fresh.length === 0) return;
fresh.forEach((n) => seen.current!.add(n.id)); fresh.forEach((n) => seen.current!.add(n.id));
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE)); // Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху.
fresh.forEach((n) => { [...fresh].reverse().forEach((n) =>
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS); toast.notify({
}); title: n.title,
}, [data]); body: n.body,
onClick: n.link ? () => navigate(n.link!) : undefined,
}),
);
}, [data, toast, navigate]);
if (!enabled || toasts.length === 0) return null; return null;
const click = (n: Notification) => {
setToasts((prev) => prev.filter((t) => t.id !== n.id));
if (n.link) navigate(n.link);
};
return (
<div className="toast-top-wrap">
{toasts.map((n) => (
<button key={n.id} className="toast-top" onClick={() => click(n)}>
<div className="toast-top-title">{n.title}</div>
{n.body && <div className="toast-top-body">{n.body}</div>}
</button>
))}
</div>
);
} }
@@ -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<string | null>(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 (
<div className="modal-overlay">
<form className="modal" onSubmit={submit}>
<div className="modal-title">Задайте пароль</div>
<p className="modal-msg">
Теперь на сайт входят по логину и паролю. Ваш логин — никнейм <b>{nickname}</b>.
Придумайте пароль, чтобы продолжить; вход через Telegram тоже останется.
</p>
{/* Скрытый логин — чтобы менеджер паролей сохранил пару целиком. */}
<input type="text" name="username" autoComplete="username" value={nickname} readOnly hidden />
<div className="field">
<label className="label" htmlFor="set-password">
Пароль (не короче 8 символов)
</label>
<input
id="set-password"
type="password"
autoComplete="new-password"
autoFocus
value={password}
onChange={(e) => setPasswordValue(e.target.value)}
/>
</div>
<div className="field">
<label className="label" htmlFor="set-password-repeat">
Повторите пароль
</label>
<input
id="set-password-repeat"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
/>
</div>
{error && <p className="error-text">{error}</p>}
<div className="modal-actions">
{import.meta.env.DEV && (
<button type="button" className="btn btn-ghost" onClick={() => setSkipped(true)}>
Позже (dev)
</button>
)}
<button type="button" className="btn btn-ghost" onClick={doLogout}>
Выйти
</button>
<button type="submit" className="btn btn-primary" disabled={setPassword.isPending || !password}>
Сохранить
</button>
</div>
</form>
</div>
);
}
+80 -14
View File
@@ -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"; 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<ToastCtx>({ 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<ToastKind, number> = { info: 3000, error: 5000, notification: 5000 };
const MAX_VISIBLE = 4;
const Ctx = createContext<ToastCtx>({ show: () => {}, error: () => {}, notify: () => {} });
export function ToastProvider({ children }: PropsWithChildren) { export function ToastProvider({ children }: PropsWithChildren) {
const [message, setMessage] = useState<string | null>(null); const [items, setItems] = useState<ToastItem[]>([]);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null); const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const show = useCallback((msg: string) => { useEffect(() => {
setMessage(msg); const pending = timers.current;
if (timer.current) clearTimeout(timer.current); return () => pending.forEach(clearTimeout);
timer.current = setTimeout(() => setMessage(null), 2800);
}, []); }, []);
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост: 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<ToastItem, "id">) => {
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 новое значение контекста заставляло бы перерисоваться и всех // без useMemo новое значение контекста заставляло бы перерисоваться и всех
// потребителей, к самому тосту отношения не имеющих. // потребителей, к самим всплывашкам отношения не имеющих.
const value = useMemo(() => ({ show }), [show]); const value = useMemo<ToastCtx>(
() => ({
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 ( return (
<Ctx.Provider value={value}> <Ctx.Provider value={value}>
{children} {children}
{message && <div className="toast">{message}</div>} {/* Контейнер есть всегда: aria-live объявляет только то, что появилось внутри уже
существующей области. */}
<div className="toast-top-wrap" aria-live="polite">
{items.map((t) => (
<button
key={t.id}
type="button"
className={"toast-top" + (t.kind === "error" ? " toast-top-error" : "")}
onClick={() => {
dismiss(t.id);
t.onClick?.();
}}
>
<div className="toast-top-title">{t.title}</div>
{t.body && <div className="toast-top-body">{t.body}</div>}
</button>
))}
</div>
</Ctx.Provider> </Ctx.Provider>
); );
} }
+15
View File
@@ -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 { interface AchievementInput {
+46
View File
@@ -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<Me> =>
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<string, unknown>): Promise<Me> =>
unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })),
onSuccess: (me) => qc.setQueryData(qk.me, me),
});
}
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED — // При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета). // тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
+133 -10
View File
@@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch"; import { Switch } from "../components/Switch";
import { TelegramLoginButton } from "../components/TelegramLoginButton";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import type { Me } from "../domain/types";
import { import {
useAuthConfig,
useDeleteAvatar, useDeleteAvatar,
useLinkTelegram,
useLogout, useLogout,
useMe, useMe,
useSetPassword,
useUpdateBio, useUpdateBio,
useUpdateFavoriteFaction, useUpdateFavoriteFaction,
useUpdateHistoryPrefs, useUpdateHistoryPrefs,
@@ -60,9 +65,8 @@ export function AccountPage() {
const saveFavorite = async (factionId: number | null) => { const saveFavorite = async (factionId: number | null) => {
try { try {
await updateFavorite.mutateAsync(factionId); await updateFavorite.mutateAsync(factionId);
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -70,7 +74,7 @@ export function AccountPage() {
try { try {
await updateHistory.mutateAsync(prefs); await updateHistory.mutateAsync(prefs);
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -80,9 +84,8 @@ export function AccountPage() {
try { try {
await updateNick.mutateAsync(nick); await updateNick.mutateAsync(nick);
setNickname(""); setNickname("");
toast.show("Никнейм обновлён");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -91,7 +94,7 @@ export function AccountPage() {
await updateBio.mutateAsync(bioValue.trim()); await updateBio.mutateAsync(bioValue.trim());
toast.show("Сохранено"); toast.show("Сохранено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -101,18 +104,16 @@ export function AccountPage() {
if (!file) return; if (!file) return;
try { try {
await uploadAvatar.mutateAsync(file); await uploadAvatar.mutateAsync(file);
toast.show("Аватар обновлён");
} catch (err) { } catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
} }
}; };
const removeAvatar = async () => { const removeAvatar = async () => {
try { try {
await deleteAvatar.mutateAsync(); await deleteAvatar.mutateAsync();
toast.show("Аватар удалён");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -218,6 +219,9 @@ export function AccountPage() {
Сохранить Сохранить
</button> </button>
</div> </div>
<div className="muted small" style={{ marginTop: 6 }}>
Никнейм — это и логин для входа: после смены входите под новым.
</div>
</div> </div>
</div> </div>
@@ -256,9 +260,128 @@ export function AccountPage() {
</div> </div>
</div> </div>
<LoginSettingsCard me={me} />
<button className="btn btn-danger btn-block" onClick={doLogout}> <button className="btn btn-danger btn-block" onClick={doLogout}>
Выйти Выйти
</button> </button>
</div> </div>
); );
} }
// Вход в аккаунт: смена пароля и привязка 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<string | null>(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<string, unknown>) => {
try {
await linkTelegram.mutateAsync(payload);
} catch (err) {
toast.error(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
}
};
const telegramReady = !!config?.telegram_bot_username && config.methods.includes("telegram");
return (
<div className="card">
<h3>Вход в аккаунт</h3>
<form onSubmit={changePassword}>
{/* Скрытый логин — чтобы менеджер паролей обновил сохранённую пару. */}
<input type="text" name="username" autoComplete="username" value={me.nickname} readOnly hidden />
{me.has_password && (
<div className="field">
<label className="label" htmlFor="current-password">
Текущий пароль
</label>
<input
id="current-password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</div>
)}
<div className="field">
<label className="label" htmlFor="new-password">
Новый пароль (не короче 8 символов)
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</div>
<div className="field">
<label className="label" htmlFor="new-password-repeat">
Повторите новый пароль
</label>
<input
id="new-password-repeat"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
/>
</div>
{error && <p className="error-text">{error}</p>}
<button
type="submit"
className="btn btn-primary"
disabled={setPassword.isPending || !next || (me.has_password && !current)}
>
{me.has_password ? "Сменить пароль" : "Задать пароль"}
</button>
</form>
<div className="field" style={{ marginTop: 16, marginBottom: 0 }}>
<label className="label">Telegram</label>
{me.telegram_id != null ? (
<div>Привязан — можно входить и через Telegram.</div>
) : telegramReady ? (
<>
<p className="muted small" style={{ marginTop: 0 }}>
Привяжите Telegram, чтобы входить и через него. Никнейм не изменится.
</p>
<TelegramLoginButton
botUsername={config?.telegram_bot_username ?? ""}
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
/>
</>
) : (
<div className="muted small">Вход через Telegram не настроен.</div>
)}
</div>
</div>
);
}
+1 -2
View File
@@ -131,7 +131,7 @@ export function CreateMatchPage() {
} catch (e) { } catch (e) {
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]); if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
setSpin((s) => ({ ...s, [id]: null })); 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, was_random: r.was_random,
})), })),
}); });
toast.show("Партия начата");
navigate(`/match/${match.id}`, { replace: true }); navigate(`/match/${match.id}`, { replace: true });
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось начать партию"); setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
+1 -1
View File
@@ -53,7 +53,7 @@ export function GroupPage() {
setNewNick(""); setNewNick("");
toast.show("Приглашение отправлено"); toast.show("Приглашение отправлено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
+3 -3
View File
@@ -59,7 +59,7 @@ export function GroupSettingsPage() {
setName(null); setName(null);
toast.show("Название сохранено"); toast.show("Название сохранено");
} catch (e) { } 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]); await setExpansions.mutateAsync([...selected]);
toast.show("Дополнения сохранены"); toast.show("Дополнения сохранены");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -76,7 +76,7 @@ export function GroupSettingsPage() {
try { try {
await removeMember.mutateAsync(userId); await removeMember.mutateAsync(userId);
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
+125 -15
View File
@@ -9,6 +9,8 @@ import {
useCreateDevUser, useCreateDevUser,
useDevUsers, useDevUsers,
useMe, useMe,
usePasswordLogin,
useRegister,
useStubLogin, useStubLogin,
useTelegramLogin, useTelegramLogin,
useTelegramRegister, useTelegramRegister,
@@ -28,6 +30,7 @@ export function LoginPage() {
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null); const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
const methods = config?.methods ?? []; const methods = config?.methods ?? [];
const hasPassword = methods.includes("password");
const hasTelegram = methods.includes("telegram"); const hasTelegram = methods.includes("telegram");
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде // Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
// import.meta.env.DEV === false → блок вырезается из бандла). // import.meta.env.DEV === false → блок вырезается из бандла).
@@ -72,22 +75,29 @@ export function LoginPage() {
onDone={() => navigate("/", { replace: true })} onDone={() => navigate("/", { replace: true })}
/> />
) : ( ) : (
hasTelegram && ( <>
<div className="card"> {hasPassword && <PasswordAuthCard onDone={() => navigate("/", { replace: true })} />}
<h3>Вход через Telegram</h3> {hasTelegram && (
{config?.telegram_bot_username ? ( <div className="card">
<TelegramLoginButton <h3>Вход через Telegram</h3>
botUsername={config.telegram_bot_username} {config?.telegram_bot_username ? (
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)} <TelegramLoginButton
/> botUsername={config.telegram_bot_username}
) : ( onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
<p className="muted small"> />
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN ) : (
в .env (бот от @BotFather + /setdomain на ваш домен). <p className="muted small">
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
в .env (бот от @BotFather + /setdomain на ваш домен).
</p>
)}
<p className="muted small" style={{ marginBottom: 0 }}>
Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле —
иначе вход через Telegram создаст новый аккаунт.
</p> </p>
)} </div>
</div> )}
) </>
)} )}
{showStub && !pendingTg && <DevLogin onError={setError} />} {showStub && !pendingTg && <DevLogin onError={setError} />}
@@ -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<string | null>(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 (
<form className="card" onSubmit={submit}>
<h3>{isRegister ? "Регистрация" : "Вход"}</h3>
<div className="field">
<label className="label" htmlFor="auth-nickname">
Никнейм
</label>
<input
id="auth-nickname"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
</div>
<div className="field">
<label className="label" htmlFor="auth-password">
Пароль
</label>
<input
id="auth-password"
name="password"
type="password"
autoComplete={isRegister ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{isRegister && <div className="muted small">Не короче 8 символов.</div>}
</div>
{isRegister && (
<div className="field">
<label className="label" htmlFor="auth-repeat">
Повторите пароль
</label>
<input
id="auth-repeat"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
/>
</div>
)}
{error && <p className="error-text">{error}</p>}
<button
type="submit"
className="btn btn-primary btn-block"
disabled={login.isPending || register.isPending}
>
{isRegister ? "Зарегистрироваться" : "Войти"}
</button>
<button type="button" className="btn btn-ghost btn-block small" style={{ marginTop: 8 }} onClick={switchMode}>
{isRegister ? "Уже есть аккаунт? Войти" : "Нет аккаунта? Зарегистрироваться"}
</button>
</form>
);
}
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен. // Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново). // Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
function TelegramNicknamePicker({ function TelegramNicknamePicker({
+4 -6
View File
@@ -165,10 +165,9 @@ export function MatchDetailPage() {
expected_version: match.version, expected_version: match.version,
}, },
}); });
toast.show("Партия завершена");
} catch (e) { } catch (e) {
if (isStale(e)) { if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено"); toast.error("Партия изменилась на другом устройстве — обновлено");
refetch(); refetch();
return; return;
} }
@@ -234,11 +233,10 @@ export function MatchDetailPage() {
expected_version: match.version, expected_version: match.version,
}, },
}); });
toast.show("Партия обновлена");
cancelEditing(); cancelEditing();
} catch (e) { } catch (e) {
if (isStale(e)) { if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено"); toast.error("Партия изменилась на другом устройстве — обновлено");
refetch(); refetch();
cancelEditing(); cancelEditing();
return; return;
@@ -257,11 +255,11 @@ export function MatchDetailPage() {
navigate("/group", { replace: true }); navigate("/group", { replace: true });
} catch (e) { } catch (e) {
if (isStale(e)) { if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено"); toast.error("Партия изменилась на другом устройстве — обновлено");
refetch(); refetch();
return; return;
} }
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить"); toast.error(e instanceof ApiError ? e.message : "Не удалось выполнить");
} }
}; };
+1 -1
View File
@@ -44,7 +44,7 @@ export function PublicProfilePage() {
await invite.mutateAsync({ groupId, nickname: data.nickname }); await invite.mutateAsync({ groupId, nickname: data.nickname });
toast.show("Приглашение отправлено"); toast.show("Приглашение отправлено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось пригласить"); toast.error(e instanceof ApiError ? e.message : "Не удалось пригласить");
} }
}; };
+61 -6
View File
@@ -1,10 +1,10 @@
import { Pencil } from "lucide-react"; import { KeyRound, Pencil } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext"; 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.meta.env.DEV,
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). // поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
@@ -12,35 +12,56 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
export function AdminAccountsPage() { export function AdminAccountsPage() {
const { data: users, isLoading } = useAdminUsers(); const { data: users, isLoading } = useAdminUsers();
const update = useAdminUpdateUser(); const update = useAdminUpdateUser();
const setPassword = useAdminSetPassword();
const toast = useToast(); const toast = useToast();
const [editId, setEditId] = useState<number | null>(null); const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
// Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование.
const [passwordId, setPasswordId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState("");
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
const startEdit = (id: number, nickname: string) => { const startEdit = (id: number, nickname: string) => {
setPasswordId(null);
setEditId(id); setEditId(id);
setEditName(nickname); 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.error(e instanceof ApiError ? e.message : "Не удалось задать пароль");
}
};
const saveName = async () => { const saveName = async () => {
const name = editName.trim(); const name = editName.trim();
if (editId == null || !name) return; if (editId == null || !name) return;
try { try {
await update.mutateAsync({ userId: editId, nickname: name }); await update.mutateAsync({ userId: editId, nickname: name });
toast.show("Ник обновлён");
setEditId(null); setEditId(null);
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать"); toast.error(e instanceof ApiError ? e.message : "Не удалось переименовать");
} }
}; };
const toggleActive = async (id: number, isActive: boolean) => { const toggleActive = async (id: number, isActive: boolean) => {
try { try {
await update.mutateAsync({ userId: id, is_active: !isActive }); await update.mutateAsync({ userId: id, is_active: !isActive });
toast.show("Сохранено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить"); toast.error(e instanceof ApiError ? e.message : "Не удалось сохранить");
} }
}; };
@@ -73,12 +94,39 @@ export function AdminAccountsPage() {
Отмена Отмена
</button> </button>
</div> </div>
) : passwordId === u.id ? (
// Режим «задать пароль».
<div className="row">
<input
type="password"
autoComplete="new-password"
placeholder={`Новый пароль для ${u.nickname}`}
value={newPassword}
autoFocus
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") savePassword();
if (e.key === "Escape") setPasswordId(null);
}}
/>
<button
className="btn btn-primary small"
disabled={setPassword.isPending || !newPassword}
onClick={savePassword}
>
Задать
</button>
<button className="btn btn-ghost small" onClick={() => setPasswordId(null)}>
Отмена
</button>
</div>
) : ( ) : (
<div className="row-between"> <div className="row-between">
<div> <div>
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>} <b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
<div className="muted small"> <div className="muted small">
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"} #{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
{u.role !== "admin" && !u.has_password && " · без пароля"}
</div> </div>
</div> </div>
{u.role !== "admin" && ( {u.role !== "admin" && (
@@ -90,6 +138,13 @@ export function AdminAccountsPage() {
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
<button
className="btn btn-ghost small"
title="Задать пароль"
onClick={() => startPassword(u.id)}
>
<KeyRound size={16} />
</button>
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} /> <DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
)} )}
@@ -35,9 +35,8 @@ export function AdminAchievementsPage() {
setName(""); setName("");
setDescription(""); setDescription("");
setCondition(""); setCondition("");
toast.show("Ачивка создана");
} catch (e) { } 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 }, body: { name: name.trim(), description, condition },
}); });
setEditing(false); setEditing(false);
toast.show("Сохранено");
} catch (e) { } 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; if (!file) return;
try { try {
await uploadIcon.mutateAsync({ slug: ach.slug, file }); await uploadIcon.mutateAsync({ slug: ach.slug, file });
toast.show("Иконка обновлена");
} catch (err) { } catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
} }
}; };
const remove = async () => { const remove = async () => {
try { try {
await del.mutateAsync(ach.slug); await del.mutateAsync(ach.slug);
toast.show("Удалено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -31,7 +31,7 @@ export function AdminFactionsPage() {
await rename.mutateAsync({ factionId: id, name_ru: names[id] }); await rename.mutateAsync({ factionId: id, name_ru: names[id] });
toast.show("Название обновлено во всей системе"); toast.show("Название обновлено во всей системе");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка"); toast.error(e instanceof ApiError ? e.message : "Ошибка");
} }
}; };
@@ -6,7 +6,6 @@ import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch"; import { Switch } from "../../components/Switch";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons"; import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext";
import { import {
useAdminDeleteAttachment, useAdminDeleteAttachment,
useAdminFactions, useAdminFactions,
@@ -33,7 +32,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
const update = useAdminUpdateMatch(); const update = useAdminUpdateMatch();
const uploadAtt = useAdminUploadAttachment(matchId); const uploadAtt = useAdminUploadAttachment(matchId);
const deleteAtt = useAdminDeleteAttachment(matchId); const deleteAtt = useAdminDeleteAttachment(matchId);
const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
@@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
})), })),
}, },
}); });
toast.show("Партия обновлена");
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось сохранить"); setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
@@ -149,9 +149,8 @@ export function AdminRecordsPage() {
setDeleteTarget(null); setDeleteTarget(null);
try { try {
await del.mutateAsync(deleteTarget.id); await del.mutateAsync(deleteTarget.id);
toast.show("Удалено");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
} }
}} }}
/> />
@@ -57,9 +57,8 @@ export function DevDeleteAccountButton({
setConfirmOpen(false); setConfirmOpen(false);
try { try {
await del.mutateAsync(); await del.mutateAsync();
toast.show("Аккаунт удалён");
} catch (e) { } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
} }
}} }}
/> />
+1 -16
View File
@@ -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:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
.switch input:disabled + .switch-track { opacity: 0.5; } .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/заголовок, по центру название, справа колокольчик */ /* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
@@ -471,5 +455,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); 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-title { font-weight: 700; }
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); } .toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }