v1.37 — аутентификация через логин и пароль
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Forbidden Stars — учёт партий
|
||||
|
||||
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
||||
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
|
||||
профили игроков (вход по логину и паролю или через Telegram), группы, создание партий
|
||||
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
||||
|
||||
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
||||
@@ -91,8 +91,8 @@ npm run gen:api # сгенерирует типы из живог
|
||||
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
||||
```
|
||||
|
||||
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
|
||||
в проде — только Telegram (см. раздел «Аутентификация»).
|
||||
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
|
||||
в проде stub нет (см. раздел «Аутентификация»).
|
||||
|
||||
## Production (Docker на Pi)
|
||||
|
||||
@@ -108,7 +108,7 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми
|
||||
## Test — локальный прод-клон в контейнере
|
||||
|
||||
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
||||
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||
по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
||||
|
||||
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
||||
@@ -123,8 +123,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
||||
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
||||
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** —
|
||||
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`).
|
||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** —
|
||||
по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`.
|
||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||
@@ -136,9 +136,17 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
|
||||
| | dev | test / prod |
|
||||
|---|---|---|
|
||||
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
||||
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
||||
| Telegram Login Widget | ✓ | ✓ |
|
||||
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
|
||||
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
|
||||
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
|
||||
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP,
|
||||
дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей)
|
||||
после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен
|
||||
текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ
|
||||
на вкладке аккаунтов — почту приложение не хранит.
|
||||
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||
@@ -165,7 +173,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
||||
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram |
|
||||
|
||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -21,9 +21,14 @@ def get_current_user(
|
||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||
except jwt.PyJWTError as exc: # noqa: F841
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||
raise AuthError("Сессия недействительна.")
|
||||
return user
|
||||
|
||||
|
||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||
except jwt.PyJWTError:
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or user.role != "admin" or not user.is_active:
|
||||
raise ForbiddenError("Доступ только для администратора.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
return user
|
||||
|
||||
@@ -22,7 +22,7 @@ def establish_session(
|
||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||
if not user.is_active:
|
||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
|
||||
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Доступные методы входа по окружению.
|
||||
|
||||
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
||||
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
||||
|
||||
|
||||
def enabled_methods() -> list[str]:
|
||||
methods = ["telegram"]
|
||||
methods = ["password", "telegram"]
|
||||
if settings.is_development:
|
||||
methods.append("stub")
|
||||
return methods
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||
|
||||
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
||||
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
||||
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||
_MIN_SECRET_KEY_LENGTH = 32
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
|
||||
prod_achievements_dir: str = "/data/achievements"
|
||||
|
||||
# JWT / cookie
|
||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||
secret_key: str = _DEFAULT_SECRET_KEY
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||
jwt_admin_ttl_minutes: int = 60 * 8
|
||||
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
|
||||
# Бутстрап администратора
|
||||
admin_bootstrap_enabled: bool = True
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-admin-password"
|
||||
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||
admin_nickname: str = "Администратор"
|
||||
|
||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
|
||||
def cookie_domain_value(self) -> str | None:
|
||||
return self.cookie_domain or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
||||
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
||||
|
||||
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
||||
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
||||
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
|
||||
if self.app_env.lower() != "production":
|
||||
return self
|
||||
problems: list[str] = []
|
||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||
problems.append(
|
||||
f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов"
|
||||
)
|
||||
if self.admin_bootstrap_enabled:
|
||||
password = (self.admin_password or "").strip()
|
||||
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||
if problems:
|
||||
raise ValueError(
|
||||
"Небезопасная конфигурация production — задайте секреты в .env: "
|
||||
+ "; ".join(problems)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -136,5 +136,41 @@ class InvalidCredentialsError(AuthError):
|
||||
super().__init__("Неверный логин или пароль.")
|
||||
|
||||
|
||||
class TooManyAttemptsError(AppError):
|
||||
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
|
||||
ближайшая попытка снова будет принята."""
|
||||
|
||||
status_code = 429
|
||||
code = "TOO_MANY_ATTEMPTS"
|
||||
|
||||
def __init__(self, retry_after: int) -> None:
|
||||
minutes = max(1, -(-retry_after // 60))
|
||||
super().__init__(
|
||||
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
|
||||
details={"retry_after": retry_after},
|
||||
)
|
||||
|
||||
|
||||
class 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:
|
||||
return exc.to_response()
|
||||
|
||||
@@ -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()
|
||||
@@ -9,6 +9,7 @@ import jwt
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.token_revocation import revoked_tokens
|
||||
|
||||
USER_COOKIE = "fs_session"
|
||||
ADMIN_COOKIE = "fs_admin"
|
||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
|
||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
||||
def create_token(
|
||||
subject: str | int,
|
||||
audience: str,
|
||||
ttl_minutes: int,
|
||||
provider: str = "",
|
||||
token_version: int = 0,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
@@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider:
|
||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||
"jti": secrets.token_hex(8),
|
||||
"provider": provider,
|
||||
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||
"ver": token_version,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
@@ -95,9 +105,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
||||
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
||||
|
||||
|
||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None:
|
||||
ttl = settings.jwt_user_ttl_minutes
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
||||
response.set_cookie(
|
||||
key=USER_COOKIE,
|
||||
value=token,
|
||||
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
|
||||
ttl = settings.jwt_admin_ttl_minutes
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
value=token,
|
||||
@@ -135,6 +145,33 @@ def clear_admin_session(response: Response) -> None:
|
||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
||||
|
||||
|
||||
def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None:
|
||||
"""Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout.
|
||||
|
||||
Убивает именно этот токен (украденный/оставленный), не трогая другие устройства.
|
||||
Некорректный/просроченный токен отзывать нечего — молча выходим."""
|
||||
token = request.cookies.get(cookie_name)
|
||||
if not token:
|
||||
return
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
audience=audience,
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return
|
||||
exp = payload.get("exp")
|
||||
if exp is not None:
|
||||
revoked_tokens.revoke(payload.get("jti"), float(exp))
|
||||
|
||||
|
||||
def is_session_revoked(payload: dict) -> bool:
|
||||
"""Отозван ли этот токен точечно (через logout)."""
|
||||
return revoked_tokens.is_revoked(payload.get("jti"))
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
"""IP клиента для журнала аудита.
|
||||
|
||||
|
||||
@@ -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
@@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI):
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
||||
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
||||
# незачем облегчать разведку поверхности API анонимам (#61).
|
||||
docs_enabled = not settings.is_production
|
||||
app = FastAPI(
|
||||
title="Forbidden Stars API",
|
||||
version="0.1.0",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json" if docs_enabled else None,
|
||||
docs_url="/api/docs" if docs_enabled else None,
|
||||
redoc_url="/api/redoc" if docs_enabled else None,
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
@@ -177,13 +181,17 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
# Только type/loc/msg. В input лежит тело запроса: эхо паролей в ответ, а для тела
|
||||
# не в JSON (text/plain от HTML-формы) — сырые bytes, которые JSON не сериализует,
|
||||
# и ответ падал в 500. В ctx бывают объекты исключений — та же проблема.
|
||||
details = [{"type": e["type"], "loc": e["loc"], "msg": e["msg"]} for e in exc.errors()]
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Ошибка валидации запроса.",
|
||||
"details": exc.errors(),
|
||||
"details": details,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
|
||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||
)
|
||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
||||
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
|
||||
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
|
||||
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
|
||||
token_version: int = Field(
|
||||
default=0,
|
||||
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
active_group_id: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
|
||||
@@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.admin_login import login_admin
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core import security
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import NotFoundError
|
||||
from app.core.errors import InvalidCredentialsError, NotFoundError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
@@ -40,8 +41,23 @@ def admin_login(
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.AdminMe:
|
||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
||||
try:
|
||||
admin = login_admin(session, body.username, body.password, client_ip(request))
|
||||
except InvalidCredentialsError:
|
||||
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
|
||||
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=None,
|
||||
action="login_failed",
|
||||
entity_type="admin",
|
||||
payload={"username": (body.username or "")[:64]},
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
raise
|
||||
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
@@ -56,7 +72,8 @@ def admin_login(
|
||||
|
||||
|
||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||
def admin_logout(response: Response) -> s.OkResponse:
|
||||
def admin_logout(request: Request, response: Response) -> s.OkResponse:
|
||||
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
|
||||
security.clear_admin_session(response)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -68,14 +85,8 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||
def list_users(
|
||||
query: str | None = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminUserRead]:
|
||||
return [
|
||||
s.AdminUserRead(
|
||||
def _admin_user_read(u: User) -> s.AdminUserRead:
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
@@ -83,9 +94,17 @@ def list_users(
|
||||
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,
|
||||
)
|
||||
for u in admin_service.list_users(session, query)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||
def list_users(
|
||||
query: str | None = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminUserRead]:
|
||||
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||
@@ -107,15 +126,30 @@ def update_user(
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
||||
def set_user_password(
|
||||
user_id: int,
|
||||
body: s.AdminPasswordSet,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AdminUserRead:
|
||||
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
||||
u = admin_service.set_player_password(session, user_id, body.new_password)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload={"password": "set_by_admin"},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
||||
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
|
||||
|
||||
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.login import establish_session
|
||||
from app.auth.password import login_player, throttle_register
|
||||
from app.auth.registry import enabled_methods
|
||||
from app.auth.telegram import TelegramProvider
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.errors import TelegramNicknameRequiredError
|
||||
from app.core.security import client_ip
|
||||
from app.db.session import get_session
|
||||
from app.routers.users import build_me
|
||||
from app.schemas import api as s
|
||||
from app.services import user_service
|
||||
from app.services import audit_service, user_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -29,6 +31,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)
|
||||
def telegram_login(
|
||||
body: s.TelegramAuthPayload,
|
||||
@@ -78,6 +117,9 @@ def telegram_register(
|
||||
|
||||
|
||||
@router.post("/logout", response_model=s.OkResponse)
|
||||
def logout(response: Response) -> s.OkResponse:
|
||||
def logout(request: Request, response: Response) -> s.OkResponse:
|
||||
# Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная
|
||||
# cookie перестаёт работать сразу, а не живёт до конца TTL (#57).
|
||||
security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER)
|
||||
security.clear_user_session(response)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Роутер текущего пользователя."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.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.db.session import get_session
|
||||
from app.models import User
|
||||
@@ -36,6 +38,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
||||
history_mode=user.history_mode,
|
||||
history_detail=user.history_detail,
|
||||
groups=groups,
|
||||
has_password=user.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,6 +118,60 @@ def update_my_profile(
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.put("/me/password", response_model=s.MeRead)
|
||||
def change_my_password(
|
||||
body: s.PasswordChange,
|
||||
request: Request,
|
||||
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)
|
||||
def upload_my_avatar(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@@ -12,11 +12,33 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||
methods: list[str] = []
|
||||
telegram_bot_username: str | None = None
|
||||
|
||||
|
||||
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
||||
# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py.
|
||||
_PASSWORD_MAX_CHARS = 128
|
||||
|
||||
|
||||
class PasswordLogin(BaseModel):
|
||||
# Логин — это ник игрока.
|
||||
nickname: str
|
||||
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||
|
||||
|
||||
class PasswordRegister(BaseModel):
|
||||
nickname: str
|
||||
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
# current_password нужен, только если пароль уже задан; первая установка — без него.
|
||||
current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS)
|
||||
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||
|
||||
|
||||
class TelegramAuthPayload(BaseModel):
|
||||
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -99,6 +121,9 @@ class UserRead(BaseModel):
|
||||
|
||||
class MeRead(UserRead):
|
||||
groups: list[GroupBrief] = []
|
||||
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
||||
# фронт не пускает дальше окна установки пароля.
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class NicknameUpdate(BaseModel):
|
||||
@@ -493,6 +518,7 @@ class AdminUserRead(BaseModel):
|
||||
auth_provider: str
|
||||
telegram_id: int | None = None
|
||||
created_at: str
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
@@ -500,6 +526,11 @@ class AdminUserUpdate(BaseModel):
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminPasswordSet(BaseModel):
|
||||
# Новый пароль игроку от админа — способ восстановить забытый пароль.
|
||||
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||
|
||||
|
||||
class AdminGroupRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
||||
return user
|
||||
|
||||
|
||||
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
||||
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
||||
он задаётся ADMIN_PASSWORD в .env."""
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
if user.role != "player":
|
||||
raise ValidationError("Пароль администратора здесь не меняется.")
|
||||
return user_service.set_password(session, user, new_password)
|
||||
|
||||
|
||||
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||
|
||||
|
||||
@@ -7,9 +7,17 @@ from pathlib import Path
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.password import validate_new_password
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||
from app.core.errors import (
|
||||
NicknameTakenError,
|
||||
NotFoundError,
|
||||
TelegramAlreadyLinkedError,
|
||||
TelegramTakenError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.core.timeutil import utcnow
|
||||
from app.models import AuthIdentity, Faction, User
|
||||
|
||||
@@ -132,6 +140,70 @@ def register_from_identity(
|
||||
return _create_from_identity(session, identity, nickname)
|
||||
|
||||
|
||||
def register_local(session: Session, nickname: str, password: str) -> User:
|
||||
"""Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже."""
|
||||
nickname = (nickname or "").strip()
|
||||
if not nickname_format_ok(nickname):
|
||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||
if not nickname_available(session, nickname):
|
||||
raise NicknameTakenError()
|
||||
validate_new_password(password)
|
||||
user = User(
|
||||
nickname=nickname,
|
||||
role="player",
|
||||
auth_provider="local",
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def 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:
|
||||
new_nickname = (new_nickname or "").strip()
|
||||
if not nickname_format_ok(new_nickname):
|
||||
|
||||
+10
-2
@@ -9,8 +9,16 @@ python -m app.bootstrap
|
||||
|
||||
echo "[entrypoint] Запуск сервера…"
|
||||
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
|
||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент.
|
||||
#
|
||||
# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For,
|
||||
# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только
|
||||
# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback
|
||||
# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес,
|
||||
# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит.
|
||||
# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается.
|
||||
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
||||
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
||||
FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
||||
--proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10
|
||||
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10
|
||||
|
||||
@@ -18,7 +18,9 @@ from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||
from app.core.ratelimit import login_throttle # noqa: E402
|
||||
from app.core.security import hash_password # noqa: E402
|
||||
from app.core.token_revocation import revoked_tokens # noqa: E402
|
||||
from app.db.session import get_session # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||
@@ -48,6 +50,8 @@ def client(engine):
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session
|
||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -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
|
||||
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
|
||||
def test_auth_config_dev_has_both_methods(client: TestClient):
|
||||
def test_auth_config_dev_has_all_methods(client: TestClient):
|
||||
cfg = client.get("/api/auth/config").json()
|
||||
assert "password" in cfg["methods"]
|
||||
assert "telegram" in cfg["methods"]
|
||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||
|
||||
@@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert set(enabled_methods()) == {"telegram", "stub"}
|
||||
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
||||
assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
||||
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
||||
|
||||
|
||||
def test_env_flags_and_db_path(monkeypatch):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -5,8 +5,13 @@
|
||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||
#
|
||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||
# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
||||
#
|
||||
# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не
|
||||
# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для
|
||||
# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это
|
||||
# делает клиентский IP достоверным.
|
||||
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
||||
#
|
||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||
@@ -18,8 +23,27 @@
|
||||
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||
# Файл заглушки — deploy/vps/maintenance.html.
|
||||
|
||||
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
|
||||
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
|
||||
(edge) {
|
||||
# Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff —
|
||||
# запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить
|
||||
# в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не
|
||||
# светить используемый прокси.
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
-Server
|
||||
}
|
||||
|
||||
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
|
||||
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
|
||||
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
|
||||
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
|
||||
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||
|
||||
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||
@@ -46,11 +70,14 @@ forbiddenstars.ru {
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9000 {
|
||||
flush_interval -1
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9000
|
||||
reverse_proxy 127.0.0.1:9000 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
import edge
|
||||
}
|
||||
@@ -61,11 +88,14 @@ forbidden-stars.ru {
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9001 {
|
||||
flush_interval -1
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
reverse_proxy 127.0.0.1:9001 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
import edge
|
||||
}
|
||||
|
||||
Vendored
+303
@@ -21,6 +21,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/register": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Password Register
|
||||
* @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.
|
||||
*/
|
||||
post: operations["password_register_api_auth_register_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Password Login
|
||||
* @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.
|
||||
*/
|
||||
post: operations["password_login_api_auth_login_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/telegram": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -122,6 +162,46 @@ export interface paths {
|
||||
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Change My Password
|
||||
* @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
|
||||
*/
|
||||
put: operations["change_my_password_api_users_me_password_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/telegram": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Link My Telegram
|
||||
* @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе.
|
||||
*/
|
||||
post: operations["link_my_telegram_api_users_me_telegram_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/avatar": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -844,6 +924,26 @@ export interface paths {
|
||||
patch: operations["update_user_api_admin_users__user_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/users/{user_id}/password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Set User Password
|
||||
* @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется.
|
||||
*/
|
||||
put: operations["set_user_password_api_admin_users__user_id__password_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/groups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1224,6 +1324,11 @@ export interface components {
|
||||
/** Role */
|
||||
role: string;
|
||||
};
|
||||
/** AdminPasswordSet */
|
||||
AdminPasswordSet: {
|
||||
/** New Password */
|
||||
new_password: string;
|
||||
};
|
||||
/** AdminUserRead */
|
||||
AdminUserRead: {
|
||||
/** Id */
|
||||
@@ -1240,6 +1345,11 @@ export interface components {
|
||||
telegram_id?: number | null;
|
||||
/** Created At */
|
||||
created_at: string;
|
||||
/**
|
||||
* Has Password
|
||||
* @default false
|
||||
*/
|
||||
has_password: boolean;
|
||||
};
|
||||
/** AdminUserUpdate */
|
||||
AdminUserUpdate: {
|
||||
@@ -1887,6 +1997,11 @@ export interface components {
|
||||
* @default []
|
||||
*/
|
||||
groups: components["schemas"]["GroupBrief"][];
|
||||
/**
|
||||
* Has Password
|
||||
* @default false
|
||||
*/
|
||||
has_password: boolean;
|
||||
};
|
||||
/** MemberAdd */
|
||||
MemberAdd: {
|
||||
@@ -1990,6 +2105,27 @@ export interface components {
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
};
|
||||
/** PasswordChange */
|
||||
PasswordChange: {
|
||||
/** Current Password */
|
||||
current_password?: string | null;
|
||||
/** New Password */
|
||||
new_password: string;
|
||||
};
|
||||
/** PasswordLogin */
|
||||
PasswordLogin: {
|
||||
/** Nickname */
|
||||
nickname: string;
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** PasswordRegister */
|
||||
PasswordRegister: {
|
||||
/** Nickname */
|
||||
nickname: string;
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** ProfileStats */
|
||||
ProfileStats: {
|
||||
/** User Id */
|
||||
@@ -2198,6 +2334,72 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
password_register_api_auth_register_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasswordRegister"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
password_login_api_auth_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasswordLogin"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
telegram_login_api_auth_telegram_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2370,6 +2572,72 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
change_my_password_api_users_me_password_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasswordChange"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
link_my_telegram_api_users_me_telegram_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TelegramAuthPayload"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upload_my_avatar_api_users_me_avatar_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3789,6 +4057,41 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
set_user_password_api_admin_users__user_id__password_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminPasswordSet"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminUserRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_groups_api_admin_groups_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
||||
import { BottomBar } from "./BottomBar";
|
||||
import { NotificationBell } from "./NotificationBell";
|
||||
import { NotificationToaster } from "./NotificationToaster";
|
||||
import { SetPasswordDialog } from "./SetPasswordDialog";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
@@ -51,6 +52,9 @@ export function AppShell() {
|
||||
|
||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||
|
||||
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
||||
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useCreateGroup } from "../hooks/groups";
|
||||
import { useExpansions } from "../hooks/reference";
|
||||
import { Switch } from "./Switch";
|
||||
@@ -9,7 +8,6 @@ import { Switch } from "./Switch";
|
||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||
const { data: expansions } = useExpansions();
|
||||
const createGroup = useCreateGroup();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -25,7 +23,6 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||
setError(null);
|
||||
try {
|
||||
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
||||
toast.show("Группа создана");
|
||||
onCreated?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
||||
|
||||
@@ -15,16 +15,15 @@ export function GroupInvitations() {
|
||||
await accept.mutateAsync(id);
|
||||
toast.show("Вы вступили в группу");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const onDecline = async (id: number) => {
|
||||
try {
|
||||
await decline.mutateAsync(id);
|
||||
toast.show("Приглашение отклонено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ export function MatchMedia({
|
||||
let slots = max - attachments.length;
|
||||
for (const f of files) {
|
||||
if (slots <= 0) {
|
||||
toast.show(`Максимум ${max} фото`);
|
||||
toast.error(`Максимум ${max} фото`);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await onUpload(f);
|
||||
slots--;
|
||||
} catch (err) {
|
||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function MatchMedia({
|
||||
try {
|
||||
await onDelete(id);
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useNotifications, type Notification } from "../hooks/notifications";
|
||||
|
||||
const TOAST_TTL_MS = 5000;
|
||||
const MAX_VISIBLE = 4;
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useNotifications } from "../hooks/notifications";
|
||||
|
||||
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
||||
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
|
||||
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id.
|
||||
* Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */
|
||||
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
||||
const { data } = useNotifications(enabled);
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const seen = useRef<Set<number> | null>(null);
|
||||
const [toasts, setToasts] = useState<Notification[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
@@ -22,29 +21,16 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
||||
return;
|
||||
}
|
||||
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
||||
if (fresh.length === 0) return;
|
||||
fresh.forEach((n) => seen.current!.add(n.id));
|
||||
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
|
||||
fresh.forEach((n) => {
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
if (!enabled || toasts.length === 0) return null;
|
||||
|
||||
const click = (n: Notification) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== n.id));
|
||||
if (n.link) navigate(n.link);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
// Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху.
|
||||
[...fresh].reverse().forEach((n) =>
|
||||
toast.notify({
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
onClick: n.link ? () => navigate(n.link!) : undefined,
|
||||
}),
|
||||
);
|
||||
}, [data, toast, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,97 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
interface ToastCtx {
|
||||
show: (message: string) => void;
|
||||
// Сообщения приложения и входящие уведомления — одна стопка всплывашек сверху экрана.
|
||||
type ToastKind = "info" | "error" | "notification";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<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) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const nextId = useRef(1);
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const show = useCallback((msg: string) => {
|
||||
setMessage(msg);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
||||
useEffect(() => {
|
||||
const pending = timers.current;
|
||||
return () => pending.forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
|
||||
const dismiss = useCallback((id: number) => {
|
||||
clearTimeout(timers.current.get(id));
|
||||
timers.current.delete(id);
|
||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(item: Omit<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 новое значение контекста заставляло бы перерисоваться и всех
|
||||
// потребителей, к самому тосту отношения не имеющих.
|
||||
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 (
|
||||
<Ctx.Provider value={value}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
||||
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
||||
|
||||
@@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { TelegramLoginButton } from "../components/TelegramLoginButton";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import type { Me } from "../domain/types";
|
||||
import {
|
||||
useAuthConfig,
|
||||
useDeleteAvatar,
|
||||
useLinkTelegram,
|
||||
useLogout,
|
||||
useMe,
|
||||
useSetPassword,
|
||||
useUpdateBio,
|
||||
useUpdateFavoriteFaction,
|
||||
useUpdateHistoryPrefs,
|
||||
@@ -60,9 +65,8 @@ export function AccountPage() {
|
||||
const saveFavorite = async (factionId: number | null) => {
|
||||
try {
|
||||
await updateFavorite.mutateAsync(factionId);
|
||||
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +74,7 @@ export function AccountPage() {
|
||||
try {
|
||||
await updateHistory.mutateAsync(prefs);
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,9 +84,8 @@ export function AccountPage() {
|
||||
try {
|
||||
await updateNick.mutateAsync(nick);
|
||||
setNickname("");
|
||||
toast.show("Никнейм обновлён");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,7 +94,7 @@ export function AccountPage() {
|
||||
await updateBio.mutateAsync(bioValue.trim());
|
||||
toast.show("Сохранено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,18 +104,16 @@ export function AccountPage() {
|
||||
if (!file) return;
|
||||
try {
|
||||
await uploadAvatar.mutateAsync(file);
|
||||
toast.show("Аватар обновлён");
|
||||
} catch (err) {
|
||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
}
|
||||
};
|
||||
|
||||
const removeAvatar = async () => {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync();
|
||||
toast.show("Аватар удалён");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,6 +219,9 @@ export function AccountPage() {
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted small" style={{ marginTop: 6 }}>
|
||||
Никнейм — это и логин для входа: после смены входите под новым.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,9 +260,128 @@ export function AccountPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginSettingsCard me={me} />
|
||||
|
||||
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function CreateMatchPage() {
|
||||
} catch (e) {
|
||||
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
||||
setSpin((s) => ({ ...s, [id]: null }));
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,7 +176,6 @@ export function CreateMatchPage() {
|
||||
was_random: r.was_random,
|
||||
})),
|
||||
});
|
||||
toast.show("Партия начата");
|
||||
navigate(`/match/${match.id}`, { replace: true });
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
||||
|
||||
@@ -53,7 +53,7 @@ export function GroupPage() {
|
||||
setNewNick("");
|
||||
toast.show("Приглашение отправлено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function GroupSettingsPage() {
|
||||
setName(null);
|
||||
toast.show("Название сохранено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export function GroupSettingsPage() {
|
||||
await setExpansions.mutateAsync([...selected]);
|
||||
toast.show("Дополнения сохранены");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export function GroupSettingsPage() {
|
||||
try {
|
||||
await removeMember.mutateAsync(userId);
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useCreateDevUser,
|
||||
useDevUsers,
|
||||
useMe,
|
||||
usePasswordLogin,
|
||||
useRegister,
|
||||
useStubLogin,
|
||||
useTelegramLogin,
|
||||
useTelegramRegister,
|
||||
@@ -28,6 +30,7 @@ export function LoginPage() {
|
||||
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
||||
|
||||
const methods = config?.methods ?? [];
|
||||
const hasPassword = methods.includes("password");
|
||||
const hasTelegram = methods.includes("telegram");
|
||||
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
||||
// import.meta.env.DEV === false → блок вырезается из бандла).
|
||||
@@ -72,7 +75,9 @@ export function LoginPage() {
|
||||
onDone={() => navigate("/", { replace: true })}
|
||||
/>
|
||||
) : (
|
||||
hasTelegram && (
|
||||
<>
|
||||
{hasPassword && <PasswordAuthCard onDone={() => navigate("/", { replace: true })} />}
|
||||
{hasTelegram && (
|
||||
<div className="card">
|
||||
<h3>Вход через Telegram</h3>
|
||||
{config?.telegram_bot_username ? (
|
||||
@@ -86,8 +91,13 @@ export function LoginPage() {
|
||||
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||
</p>
|
||||
)}
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле —
|
||||
иначе вход через Telegram создаст новый аккаунт.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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, когда тег занят/некорректен.
|
||||
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
||||
function TelegramNicknamePicker({
|
||||
|
||||
@@ -165,10 +165,9 @@ export function MatchDetailPage() {
|
||||
expected_version: match.version,
|
||||
},
|
||||
});
|
||||
toast.show("Партия завершена");
|
||||
} catch (e) {
|
||||
if (isStale(e)) {
|
||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
@@ -234,11 +233,10 @@ export function MatchDetailPage() {
|
||||
expected_version: match.version,
|
||||
},
|
||||
});
|
||||
toast.show("Партия обновлена");
|
||||
cancelEditing();
|
||||
} catch (e) {
|
||||
if (isStale(e)) {
|
||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||
refetch();
|
||||
cancelEditing();
|
||||
return;
|
||||
@@ -257,11 +255,11 @@ export function MatchDetailPage() {
|
||||
navigate("/group", { replace: true });
|
||||
} catch (e) {
|
||||
if (isStale(e)) {
|
||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function PublicProfilePage() {
|
||||
await invite.mutateAsync({ groupId, nickname: data.nickname });
|
||||
toast.show("Приглашение отправлено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось пригласить");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось пригласить");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Pencil } from "lucide-react";
|
||||
import { KeyRound, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { useToast } from "../../context/ToastContext";
|
||||
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||
@@ -12,35 +12,56 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||
export function AdminAccountsPage() {
|
||||
const { data: users, isLoading } = useAdminUsers();
|
||||
const update = useAdminUpdateUser();
|
||||
const setPassword = useAdminSetPassword();
|
||||
const toast = useToast();
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
// Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование.
|
||||
const [passwordId, setPasswordId] = useState<number | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
const startEdit = (id: number, nickname: string) => {
|
||||
setPasswordId(null);
|
||||
setEditId(id);
|
||||
setEditName(nickname);
|
||||
};
|
||||
|
||||
const startPassword = (id: number) => {
|
||||
setEditId(null);
|
||||
setPasswordId(id);
|
||||
setNewPassword("");
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
if (passwordId == null || !newPassword) return;
|
||||
try {
|
||||
await setPassword.mutateAsync({ userId: passwordId, password: newPassword });
|
||||
toast.show("Пароль задан — сообщите его игроку");
|
||||
setPasswordId(null);
|
||||
setNewPassword("");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось задать пароль");
|
||||
}
|
||||
};
|
||||
|
||||
const saveName = async () => {
|
||||
const name = editName.trim();
|
||||
if (editId == null || !name) return;
|
||||
try {
|
||||
await update.mutateAsync({ userId: editId, nickname: name });
|
||||
toast.show("Ник обновлён");
|
||||
setEditId(null);
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (id: number, isActive: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ userId: id, is_active: !isActive });
|
||||
toast.show("Сохранено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,12 +94,39 @@ export function AdminAccountsPage() {
|
||||
Отмена
|
||||
</button>
|
||||
</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>
|
||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
||||
<div className="muted small">
|
||||
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
||||
{u.role !== "admin" && !u.has_password && " · без пароля"}
|
||||
</div>
|
||||
</div>
|
||||
{u.role !== "admin" && (
|
||||
@@ -90,6 +138,13 @@ export function AdminAccountsPage() {
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost small"
|
||||
title="Задать пароль"
|
||||
onClick={() => startPassword(u.id)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</button>
|
||||
{import.meta.env.DEV && (
|
||||
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||
)}
|
||||
|
||||
@@ -35,9 +35,8 @@ export function AdminAchievementsPage() {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setCondition("");
|
||||
toast.show("Ачивка создана");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,9 +99,8 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
||||
body: { name: name.trim(), description, condition },
|
||||
});
|
||||
setEditing(false);
|
||||
toast.show("Сохранено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,18 +110,16 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
||||
if (!file) return;
|
||||
try {
|
||||
await uploadIcon.mutateAsync({ slug: ach.slug, file });
|
||||
toast.show("Иконка обновлена");
|
||||
} catch (err) {
|
||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
try {
|
||||
await del.mutateAsync(ach.slug);
|
||||
toast.show("Удалено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AdminFactionsPage() {
|
||||
await rename.mutateAsync({ factionId: id, name_ru: names[id] });
|
||||
toast.show("Название обновлено во всей системе");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { MatchMedia } from "../../components/MatchMedia";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { Switch } from "../../components/Switch";
|
||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||
import { useToast } from "../../context/ToastContext";
|
||||
import {
|
||||
useAdminDeleteAttachment,
|
||||
useAdminFactions,
|
||||
@@ -33,7 +32,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const update = useAdminUpdateMatch();
|
||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
@@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
})),
|
||||
},
|
||||
});
|
||||
toast.show("Партия обновлена");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||
|
||||
@@ -149,9 +149,8 @@ export function AdminRecordsPage() {
|
||||
setDeleteTarget(null);
|
||||
try {
|
||||
await del.mutateAsync(deleteTarget.id);
|
||||
toast.show("Удалено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -57,9 +57,8 @@ export function DevDeleteAccountButton({
|
||||
setConfirmOpen(false);
|
||||
try {
|
||||
await del.mutateAsync();
|
||||
toast.show("Аккаунт удалён");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -193,22 +193,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
.switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
|
||||
.switch input:disabled + .switch-track { opacity: 0.5; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--bottombar-height) + 16px);
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
z-index: 100;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
||||
@@ -471,5 +455,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.toast-top-error { border-left-color: var(--danger); }
|
||||
.toast-top-title { font-weight: 700; }
|
||||
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
Reference in New Issue
Block a user