Merge pull request 'Добавление аутентификации логин/пароль (#24)' (#53) from issue-24-login-password into dev
Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Forbidden Stars — учёт партий
|
# Forbidden Stars — учёт партий
|
||||||
|
|
||||||
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
||||||
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
|
профили игроков (вход по логину и паролю или через Telegram), группы, создание партий
|
||||||
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
||||||
|
|
||||||
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
||||||
@@ -91,8 +91,8 @@ npm run gen:api # сгенерирует типы из живог
|
|||||||
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
||||||
```
|
```
|
||||||
|
|
||||||
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
|
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
|
||||||
в проде — только Telegram (см. раздел «Аутентификация»).
|
в проде stub нет (см. раздел «Аутентификация»).
|
||||||
|
|
||||||
## Production (Docker на Pi)
|
## Production (Docker на Pi)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми
|
|||||||
## Test — локальный прод-клон в контейнере
|
## Test — локальный прод-клон в контейнере
|
||||||
|
|
||||||
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
||||||
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||||
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
||||||
|
|
||||||
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
||||||
@@ -123,8 +123,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
||||||
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
||||||
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
||||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** —
|
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** —
|
||||||
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`).
|
по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`.
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||||
@@ -136,9 +136,17 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
|
|
||||||
| | dev | test / prod |
|
| | dev | test / prod |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
||||||
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
| Telegram Login Widget | ✓ | ✓ |
|
||||||
|
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
|
||||||
|
|
||||||
|
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
|
||||||
|
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
|
||||||
|
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP,
|
||||||
|
дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей)
|
||||||
|
после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен
|
||||||
|
текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ
|
||||||
|
на вкладке аккаунтов — почту приложение не хранит.
|
||||||
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||||
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||||
@@ -165,7 +173,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||||
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||||
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram |
|
||||||
|
|
||||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Вход игрока по логину (нику) и паролю.
|
||||||
|
|
||||||
|
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику
|
||||||
|
без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.errors import (
|
||||||
|
InvalidCredentialsError,
|
||||||
|
ValidationError,
|
||||||
|
WrongCurrentPasswordError,
|
||||||
|
)
|
||||||
|
from app.core.ratelimit import login_throttle
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
PASSWORD_MIN_CHARS = 8
|
||||||
|
# bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает
|
||||||
|
# ValueError — ограничиваем явно, с понятным сообщением.
|
||||||
|
_BCRYPT_MAX_BYTES = 72
|
||||||
|
|
||||||
|
# Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора
|
||||||
|
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
||||||
|
_PAIR_LIMIT = 5
|
||||||
|
_IP_LIMIT = 20
|
||||||
|
# Неверный текущий пароль при смене — на аккаунт.
|
||||||
|
_CURRENT_PASSWORD_LIMIT = 5
|
||||||
|
|
||||||
|
|
||||||
|
def validate_new_password(password: str) -> None:
|
||||||
|
if len(password) < PASSWORD_MIN_CHARS:
|
||||||
|
raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.")
|
||||||
|
if not password.strip():
|
||||||
|
raise ValidationError("Пароль не может состоять из одних пробелов.")
|
||||||
|
if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES:
|
||||||
|
raise ValidationError(
|
||||||
|
"Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _dummy_hash() -> str:
|
||||||
|
return hash_password("dummy-password-for-timing")
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_player(session: Session, nickname: str, password: str) -> User:
|
||||||
|
"""Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie.
|
||||||
|
|
||||||
|
Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает
|
||||||
|
столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин."""
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.nickname == nickname, User.role == "player")
|
||||||
|
).first()
|
||||||
|
stored = user.password_hash if user is not None else None
|
||||||
|
valid = verify_password(password, stored or _dummy_hash())
|
||||||
|
if user is None or not stored or not valid:
|
||||||
|
raise InvalidCredentialsError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User:
|
||||||
|
"""authenticate_player под защитой от перебора. Сессию открывает вызывающий."""
|
||||||
|
nickname = (nickname or "").strip()
|
||||||
|
ip = ip or "unknown"
|
||||||
|
pair_key = f"login:{ip}:{nickname.casefold()}"
|
||||||
|
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT}
|
||||||
|
login_throttle.check(limits)
|
||||||
|
try:
|
||||||
|
user = authenticate_player(session, nickname, password)
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise
|
||||||
|
login_throttle.reset(pair_key)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def check_current_password(user: User, current_password: str | None) -> None:
|
||||||
|
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
|
||||||
|
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
|
||||||
|
key = f"current-password:{user.id}"
|
||||||
|
limits = {key: _CURRENT_PASSWORD_LIMIT}
|
||||||
|
login_throttle.check(limits)
|
||||||
|
if not current_password or not verify_password(current_password, user.password_hash or ""):
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise WrongCurrentPasswordError()
|
||||||
|
login_throttle.reset(key)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Доступные методы входа по окружению.
|
"""Доступные методы входа по окружению.
|
||||||
|
|
||||||
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
||||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
|||||||
|
|
||||||
|
|
||||||
def enabled_methods() -> list[str]:
|
def enabled_methods() -> list[str]:
|
||||||
methods = ["telegram"]
|
methods = ["password", "telegram"]
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
methods.append("stub")
|
methods.append("stub")
|
||||||
return methods
|
return methods
|
||||||
|
|||||||
@@ -136,5 +136,41 @@ class InvalidCredentialsError(AuthError):
|
|||||||
super().__init__("Неверный логин или пароль.")
|
super().__init__("Неверный логин или пароль.")
|
||||||
|
|
||||||
|
|
||||||
|
class TooManyAttemptsError(AppError):
|
||||||
|
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
|
||||||
|
ближайшая попытка снова будет принята."""
|
||||||
|
|
||||||
|
status_code = 429
|
||||||
|
code = "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
def __init__(self, retry_after: int) -> None:
|
||||||
|
minutes = max(1, -(-retry_after // 60))
|
||||||
|
super().__init__(
|
||||||
|
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
|
||||||
|
details={"retry_after": retry_after},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WrongCurrentPasswordError(ForbiddenError):
|
||||||
|
code = "WRONG_CURRENT_PASSWORD"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("Текущий пароль неверен.")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramAlreadyLinkedError(ConflictError):
|
||||||
|
code = "TELEGRAM_ALREADY_LINKED"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("К аккаунту уже привязан Telegram.")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramTakenError(ConflictError):
|
||||||
|
code = "TELEGRAM_TAKEN"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("Этот Telegram уже привязан к другому аккаунту.")
|
||||||
|
|
||||||
|
|
||||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||||
return exc.to_response()
|
return exc.to_response()
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||||
|
|
||||||
|
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
|
||||||
|
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from app.core.errors import TooManyAttemptsError
|
||||||
|
|
||||||
|
_WINDOW_SECONDS = 15 * 60
|
||||||
|
# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток
|
||||||
|
# с разных адресов не копил память бесконечно.
|
||||||
|
_PRUNE_ABOVE = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottle:
|
||||||
|
"""Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока).
|
||||||
|
|
||||||
|
Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком."""
|
||||||
|
|
||||||
|
def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None:
|
||||||
|
self.window = window_seconds
|
||||||
|
self._fails: dict[str, deque[float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _recent(self, key: str, now: float) -> deque[float]:
|
||||||
|
attempts = self._fails.get(key)
|
||||||
|
if attempts is None:
|
||||||
|
return deque()
|
||||||
|
while attempts and now - attempts[0] >= self.window:
|
||||||
|
attempts.popleft()
|
||||||
|
if not attempts:
|
||||||
|
del self._fails[key]
|
||||||
|
return attempts
|
||||||
|
|
||||||
|
def check(self, limits: dict[str, int]) -> None:
|
||||||
|
"""Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
for key, limit in limits.items():
|
||||||
|
attempts = self._recent(key, now)
|
||||||
|
if len(attempts) >= limit:
|
||||||
|
# Попытка снова примется, когда из окна выпадет неудача, после которой
|
||||||
|
# в нём остаётся limit-1 записей.
|
||||||
|
frees_at = attempts[len(attempts) - limit] + self.window
|
||||||
|
raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1)
|
||||||
|
|
||||||
|
def fail(self, keys: dict[str, int]) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
if len(self._fails) > _PRUNE_ABOVE:
|
||||||
|
for key in list(self._fails):
|
||||||
|
self._recent(key, now)
|
||||||
|
for key in keys:
|
||||||
|
self._fails.setdefault(key, deque()).append(now)
|
||||||
|
|
||||||
|
def reset(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._fails.pop(key, None)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._fails.clear()
|
||||||
|
|
||||||
|
|
||||||
|
login_throttle = LoginThrottle()
|
||||||
@@ -68,14 +68,8 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
|||||||
|
|
||||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
def _admin_user_read(u: User) -> s.AdminUserRead:
|
||||||
def list_users(
|
return s.AdminUserRead(
|
||||||
query: str | None = Query(None),
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
_admin: User = Depends(get_current_admin),
|
|
||||||
) -> list[s.AdminUserRead]:
|
|
||||||
return [
|
|
||||||
s.AdminUserRead(
|
|
||||||
id=u.id, # type: ignore[arg-type]
|
id=u.id, # type: ignore[arg-type]
|
||||||
nickname=u.nickname,
|
nickname=u.nickname,
|
||||||
role=u.role,
|
role=u.role,
|
||||||
@@ -83,9 +77,17 @@ def list_users(
|
|||||||
auth_provider=u.auth_provider,
|
auth_provider=u.auth_provider,
|
||||||
telegram_id=u.telegram_id,
|
telegram_id=u.telegram_id,
|
||||||
created_at=iso_utc(u.created_at),
|
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)
|
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||||
@@ -107,15 +109,30 @@ def update_user(
|
|||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.AdminUserRead(
|
return _admin_user_read(u)
|
||||||
id=u.id, # type: ignore[arg-type]
|
|
||||||
nickname=u.nickname,
|
|
||||||
role=u.role,
|
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
||||||
is_active=u.is_active,
|
def set_user_password(
|
||||||
auth_provider=u.auth_provider,
|
user_id: int,
|
||||||
telegram_id=u.telegram_id,
|
body: s.AdminPasswordSet,
|
||||||
created_at=iso_utc(u.created_at),
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.AdminUserRead:
|
||||||
|
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
||||||
|
u = admin_service.set_player_password(session, user_id, body.new_password)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=admin.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user_id,
|
||||||
|
payload={"password": "set_by_admin"},
|
||||||
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return _admin_user_read(u)
|
||||||
|
|
||||||
|
|
||||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
|
||||||
|
|
||||||
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.login import establish_session
|
from app.auth.login import establish_session
|
||||||
|
from app.auth.password import login_player
|
||||||
from app.auth.registry import enabled_methods
|
from app.auth.registry import enabled_methods
|
||||||
from app.auth.telegram import TelegramProvider
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import TelegramNicknameRequiredError
|
from app.core.errors import TelegramNicknameRequiredError
|
||||||
|
from app.core.security import client_ip
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.routers.users import build_me
|
from app.routers.users import build_me
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
from app.services import user_service
|
from app.services import audit_service, user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -29,6 +31,42 @@ def auth_config() -> s.AuthConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=s.MeRead)
|
||||||
|
def password_register(
|
||||||
|
body: s.PasswordRegister,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
|
||||||
|
user = user_service.register_local(session, body.nickname, body.password)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="create",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"provider": "local"},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
establish_session(session, response, request, user, "local")
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=s.MeRead)
|
||||||
|
def password_login(
|
||||||
|
body: s.PasswordLogin,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429."""
|
||||||
|
user = login_player(session, body.nickname, body.password, client_ip(request))
|
||||||
|
establish_session(session, response, request, user, "local")
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/telegram", response_model=s.MeRead)
|
@router.post("/telegram", response_model=s.MeRead)
|
||||||
def telegram_login(
|
def telegram_login(
|
||||||
body: s.TelegramAuthPayload,
|
body: s.TelegramAuthPayload,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from fastapi.responses import FileResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.password import check_current_password
|
||||||
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core.security import client_ip
|
from app.core.security import client_ip
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import NotFoundError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
@@ -36,6 +38,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
history_mode=user.history_mode,
|
history_mode=user.history_mode,
|
||||||
history_detail=user.history_detail,
|
history_detail=user.history_detail,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
|
has_password=user.password_hash is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +118,56 @@ def update_my_profile(
|
|||||||
return build_me(session, user)
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/password", response_model=s.MeRead)
|
||||||
|
def change_my_password(
|
||||||
|
body: s.PasswordChange,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Задать пароль (первый раз — без текущего) или сменить его (нужен текущий)."""
|
||||||
|
had_password = user.password_hash is not None
|
||||||
|
if had_password:
|
||||||
|
check_current_password(user, body.current_password)
|
||||||
|
user_service.set_password(session, user, body.new_password)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"password": "changed" if had_password else "set"},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/telegram", response_model=s.MeRead)
|
||||||
|
def link_my_telegram(
|
||||||
|
body: s.TelegramAuthPayload,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе."""
|
||||||
|
identity = TelegramProvider().authenticate(body.model_dump())
|
||||||
|
user_service.link_telegram(session, user, identity)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"telegram": "linked"},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me/avatar", response_model=s.MeRead)
|
@router.put("/me/avatar", response_model=s.MeRead)
|
||||||
def upload_my_avatar(
|
def upload_my_avatar(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
@@ -12,11 +12,33 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
|||||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
||||||
|
# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py.
|
||||||
|
_PASSWORD_MAX_CHARS = 128
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordLogin(BaseModel):
|
||||||
|
# Логин — это ник игрока.
|
||||||
|
nickname: str
|
||||||
|
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRegister(BaseModel):
|
||||||
|
nickname: str
|
||||||
|
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
# current_password нужен, только если пароль уже задан; первая установка — без него.
|
||||||
|
current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
class TelegramAuthPayload(BaseModel):
|
class TelegramAuthPayload(BaseModel):
|
||||||
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
@@ -99,6 +121,9 @@ class UserRead(BaseModel):
|
|||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
groups: list[GroupBrief] = []
|
groups: list[GroupBrief] = []
|
||||||
|
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
||||||
|
# фронт не пускает дальше окна установки пароля.
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
class NicknameUpdate(BaseModel):
|
class NicknameUpdate(BaseModel):
|
||||||
@@ -493,6 +518,7 @@ class AdminUserRead(BaseModel):
|
|||||||
auth_provider: str
|
auth_provider: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AdminUserUpdate(BaseModel):
|
class AdminUserUpdate(BaseModel):
|
||||||
@@ -500,6 +526,11 @@ class AdminUserUpdate(BaseModel):
|
|||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPasswordSet(BaseModel):
|
||||||
|
# Новый пароль игроку от админа — способ восстановить забытый пароль.
|
||||||
|
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
class AdminGroupRead(BaseModel):
|
class AdminGroupRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
||||||
|
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
||||||
|
он задаётся ADMIN_PASSWORD в .env."""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
if user.role != "player":
|
||||||
|
raise ValidationError("Пароль администратора здесь не меняется.")
|
||||||
|
return user_service.set_password(session, user, new_password)
|
||||||
|
|
||||||
|
|
||||||
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth.password import validate_new_password
|
||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
from app.core.errors import (
|
||||||
|
NicknameTakenError,
|
||||||
|
NotFoundError,
|
||||||
|
TelegramAlreadyLinkedError,
|
||||||
|
TelegramTakenError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from app.core.security import hash_password
|
||||||
from app.core.timeutil import utcnow
|
from app.core.timeutil import utcnow
|
||||||
from app.models import AuthIdentity, Faction, User
|
from app.models import AuthIdentity, Faction, User
|
||||||
|
|
||||||
@@ -132,6 +140,65 @@ def register_from_identity(
|
|||||||
return _create_from_identity(session, identity, nickname)
|
return _create_from_identity(session, identity, nickname)
|
||||||
|
|
||||||
|
|
||||||
|
def register_local(session: Session, nickname: str, password: str) -> User:
|
||||||
|
"""Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже."""
|
||||||
|
nickname = (nickname or "").strip()
|
||||||
|
if not nickname_format_ok(nickname):
|
||||||
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||||
|
if not nickname_available(session, nickname):
|
||||||
|
raise NicknameTakenError()
|
||||||
|
validate_new_password(password)
|
||||||
|
user = User(
|
||||||
|
nickname=nickname,
|
||||||
|
role="player",
|
||||||
|
auth_provider="local",
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(session: Session, user: User, new_password: str) -> User:
|
||||||
|
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
|
||||||
|
validate_new_password(new_password)
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User:
|
||||||
|
"""Привязать Telegram к существующему аккаунту; ник не меняется.
|
||||||
|
|
||||||
|
После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит
|
||||||
|
его по той же связке provider+external_id, что создаёт регистрация через Telegram."""
|
||||||
|
already = session.exec(
|
||||||
|
select(AuthIdentity).where(
|
||||||
|
AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if user.telegram_id is not None or already is not None:
|
||||||
|
raise TelegramAlreadyLinkedError()
|
||||||
|
taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first()
|
||||||
|
if find_by_identity(session, identity) is not None or taken_by_id is not None:
|
||||||
|
raise TelegramTakenError()
|
||||||
|
user.telegram_id = identity.telegram_id
|
||||||
|
session.add(user)
|
||||||
|
session.add(
|
||||||
|
AuthIdentity(
|
||||||
|
user_id=user.id, # type: ignore[arg-type]
|
||||||
|
provider=identity.provider,
|
||||||
|
external_id=identity.external_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||||
new_nickname = (new_nickname or "").strip()
|
new_nickname = (new_nickname or "").strip()
|
||||||
if not nickname_format_ok(new_nickname):
|
if not nickname_format_ok(new_nickname):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402
|
|||||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||||
|
|
||||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||||
|
from app.core.ratelimit import login_throttle # noqa: E402
|
||||||
from app.core.security import hash_password # noqa: E402
|
from app.core.security import hash_password # noqa: E402
|
||||||
from app.db.session import get_session # noqa: E402
|
from app.db.session import get_session # noqa: E402
|
||||||
from app.main import app # noqa: E402
|
from app.main import app # noqa: E402
|
||||||
@@ -48,6 +49,7 @@ def client(engine):
|
|||||||
yield s
|
yield s
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = _get_session
|
app.dependency_overrides[get_session] = _get_session
|
||||||
|
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
|
|||||||
from tests.conftest import csrf_headers
|
from tests.conftest import csrf_headers
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_dev_has_both_methods(client: TestClient):
|
def test_auth_config_dev_has_all_methods(client: TestClient):
|
||||||
cfg = client.get("/api/auth/config").json()
|
cfg = client.get("/api/auth/config").json()
|
||||||
|
assert "password" in cfg["methods"]
|
||||||
assert "telegram" in cfg["methods"]
|
assert "telegram" in cfg["methods"]
|
||||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||||
|
|
||||||
@@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch):
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
monkeypatch.setattr(settings, "app_env", "development")
|
||||||
assert set(enabled_methods()) == {"telegram", "stub"}
|
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
||||||
monkeypatch.setattr(settings, "app_env", "test")
|
monkeypatch.setattr(settings, "app_env", "test")
|
||||||
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub
|
||||||
monkeypatch.setattr(settings, "app_env", "production")
|
monkeypatch.setattr(settings, "app_env", "production")
|
||||||
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
||||||
|
|
||||||
|
|
||||||
def test_env_flags_and_db_path(monkeypatch):
|
def test_env_flags_and_db_path(monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
from tests.conftest import csrf_headers
|
||||||
|
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_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 _set_password(client: TestClient, new: str, current: str | None = None):
|
||||||
|
body = {"new_password": new}
|
||||||
|
if current is not None:
|
||||||
|
body["current_password"] = current
|
||||||
|
return client.put("/api/users/me/password", json=body, headers=csrf_headers(client))
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_login(client: TestClient, monkeypatch, **fields):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
return client.post(
|
||||||
|
"/api/auth/telegram",
|
||||||
|
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch):
|
||||||
|
"""Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику."""
|
||||||
|
r = _telegram_login(client, monkeypatch)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
me = r.json()
|
||||||
|
assert me["has_password"] is False
|
||||||
|
|
||||||
|
r2 = _set_password(client, PASSWORD)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["has_password"] is True
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r3 = _login(client, nickname=me["nickname"])
|
||||||
|
assert r3.status_code == 200, r3.text
|
||||||
|
assert r3.json()["id"] == me["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_requires_current(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
|
||||||
|
missing = _set_password(client, "new-password-1")
|
||||||
|
assert missing.status_code == 403
|
||||||
|
assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD"
|
||||||
|
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
|
||||||
|
|
||||||
|
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _login(client).status_code == 401
|
||||||
|
assert _login(client, password="new-password-1").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_validates_new(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
r = _set_password(client, "short", current=PASSWORD)
|
||||||
|
assert r.status_code == 422
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _login(client).status_code == 200 # старый пароль не тронут
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_password_guessing_is_throttled(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
for _ in range(5):
|
||||||
|
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
|
||||||
|
blocked = _set_password(client, "new-password-1", current=PASSWORD)
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_password_requires_session(client: TestClient):
|
||||||
|
assert _set_password(client, PASSWORD).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Привязка Telegram ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
return client.post(
|
||||||
|
"/api/users/me/telegram",
|
||||||
|
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch):
|
||||||
|
"""Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт."""
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
|
||||||
|
r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["telegram_id"] == 777
|
||||||
|
assert r.json()["nickname"] == "Игрок" # ник не меняется на тег
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r2 = _telegram_login(client, monkeypatch)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["id"] == uid
|
||||||
|
assert r2.json()["nickname"] == "Игрок"
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch):
|
||||||
|
assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то
|
||||||
|
client.cookies.clear()
|
||||||
|
_register(client)
|
||||||
|
|
||||||
|
r = _link_telegram(client, monkeypatch)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "TELEGRAM_TAKEN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_twice(client: TestClient, monkeypatch):
|
||||||
|
_register(client)
|
||||||
|
assert _link_telegram(client, monkeypatch).status_code == 200
|
||||||
|
r = _link_telegram(client, monkeypatch, id=778)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_bad_signature(client: TestClient, monkeypatch):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_register(client)
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
payload = _telegram_payload("TEST_BOT_TOKEN")
|
||||||
|
payload["hash"] = "deadbeef"
|
||||||
|
r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert client.get("/api/users/me").json()["telegram_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пароль игроку из админки ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _admin_login(client: TestClient, make_admin):
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "boss", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin):
|
||||||
|
player = _telegram_login(client, monkeypatch).json()
|
||||||
|
client.cookies.clear()
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{player['id']}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["has_password"] is True
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_set_admin_password(client: TestClient, make_admin):
|
||||||
|
admin_id = _admin_login(client, make_admin)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{admin_id}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_cannot_set_passwords_via_admin(client: TestClient):
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{uid}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
Vendored
+303
@@ -21,6 +21,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/auth/register": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Password Register
|
||||||
|
* @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.
|
||||||
|
*/
|
||||||
|
post: operations["password_register_api_auth_register_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/auth/login": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Password Login
|
||||||
|
* @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.
|
||||||
|
*/
|
||||||
|
post: operations["password_login_api_auth_login_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/auth/telegram": {
|
"/api/auth/telegram": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -122,6 +162,46 @@ export interface paths {
|
|||||||
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/me/password": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Change My Password
|
||||||
|
* @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
|
||||||
|
*/
|
||||||
|
put: operations["change_my_password_api_users_me_password_put"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/users/me/telegram": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Link My Telegram
|
||||||
|
* @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе.
|
||||||
|
*/
|
||||||
|
post: operations["link_my_telegram_api_users_me_telegram_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users/me/avatar": {
|
"/api/users/me/avatar": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -844,6 +924,26 @@ export interface paths {
|
|||||||
patch: operations["update_user_api_admin_users__user_id__patch"];
|
patch: operations["update_user_api_admin_users__user_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/users/{user_id}/password": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Set User Password
|
||||||
|
* @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется.
|
||||||
|
*/
|
||||||
|
put: operations["set_user_password_api_admin_users__user_id__password_put"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/groups": {
|
"/api/admin/groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1224,6 +1324,11 @@ export interface components {
|
|||||||
/** Role */
|
/** Role */
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
|
/** AdminPasswordSet */
|
||||||
|
AdminPasswordSet: {
|
||||||
|
/** New Password */
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
/** AdminUserRead */
|
/** AdminUserRead */
|
||||||
AdminUserRead: {
|
AdminUserRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1240,6 +1345,11 @@ export interface components {
|
|||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
/** Created At */
|
/** Created At */
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Has Password
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_password: boolean;
|
||||||
};
|
};
|
||||||
/** AdminUserUpdate */
|
/** AdminUserUpdate */
|
||||||
AdminUserUpdate: {
|
AdminUserUpdate: {
|
||||||
@@ -1887,6 +1997,11 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
groups: components["schemas"]["GroupBrief"][];
|
groups: components["schemas"]["GroupBrief"][];
|
||||||
|
/**
|
||||||
|
* Has Password
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_password: boolean;
|
||||||
};
|
};
|
||||||
/** MemberAdd */
|
/** MemberAdd */
|
||||||
MemberAdd: {
|
MemberAdd: {
|
||||||
@@ -1990,6 +2105,27 @@ export interface components {
|
|||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
};
|
};
|
||||||
|
/** PasswordChange */
|
||||||
|
PasswordChange: {
|
||||||
|
/** Current Password */
|
||||||
|
current_password?: string | null;
|
||||||
|
/** New Password */
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
|
/** PasswordLogin */
|
||||||
|
PasswordLogin: {
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Password */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
/** PasswordRegister */
|
||||||
|
PasswordRegister: {
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Password */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
/** ProfileStats */
|
/** ProfileStats */
|
||||||
ProfileStats: {
|
ProfileStats: {
|
||||||
/** User Id */
|
/** User Id */
|
||||||
@@ -2198,6 +2334,72 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
password_register_api_auth_register_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordRegister"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
password_login_api_auth_login_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordLogin"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
telegram_login_api_auth_telegram_post: {
|
telegram_login_api_auth_telegram_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2370,6 +2572,72 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
change_my_password_api_users_me_password_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordChange"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
link_my_telegram_api_users_me_telegram_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TelegramAuthPayload"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
upload_my_avatar_api_users_me_avatar_put: {
|
upload_my_avatar_api_users_me_avatar_put: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3789,6 +4057,41 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
set_user_password_api_admin_users__user_id__password_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminPasswordSet"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminUserRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_groups_api_admin_groups_get: {
|
list_groups_api_admin_groups_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
|||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
import { NotificationToaster } from "./NotificationToaster";
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
|
import { SetPasswordDialog } from "./SetPasswordDialog";
|
||||||
import { SideMenu } from "./SideMenu";
|
import { SideMenu } from "./SideMenu";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
@@ -51,6 +52,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||||
|
|
||||||
|
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
||||||
|
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -203,6 +203,21 @@ export function useAdminUpdateUser() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Новый пароль игроку — когда он забыл свой (почту приложение не хранит).
|
||||||
|
export function useAdminSetPassword() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args: { userId: number; password: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PUT("/api/admin/users/{user_id}/password", {
|
||||||
|
params: { path: { user_id: args.userId } },
|
||||||
|
body: { new_password: args.password },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
|
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
|
||||||
|
|
||||||
interface AchievementInput {
|
interface AchievementInput {
|
||||||
|
|||||||
@@ -45,6 +45,52 @@ export function useStubLogin() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вход по логину (нику) и паролю. Перебор сервер ограничивает: 429 TOO_MANY_ATTEMPTS.
|
||||||
|
export function usePasswordLogin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { nickname: string; password: string }) =>
|
||||||
|
unwrap(await api.POST("/api/auth/login", { body })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрация по логину (нику) и паролю; Telegram привязывается позже в профиле.
|
||||||
|
export function useRegister() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { nickname: string; password: string }) =>
|
||||||
|
unwrap(await api.POST("/api/auth/register", { body })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
|
||||||
|
export function useSetPassword() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { new_password: string; current_password?: string }): Promise<Me> =>
|
||||||
|
unwrap(await api.PUT("/api/users/me/password", { body })),
|
||||||
|
onSuccess: (me) => qc.setQueryData(qk.me, me),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка Telegram к аккаунту: тот же payload виджета, что при входе; ник не меняется.
|
||||||
|
export function useLinkTelegram() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: Record<string, unknown>): Promise<Me> =>
|
||||||
|
unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })),
|
||||||
|
onSuccess: (me) => qc.setQueryData(qk.me, me),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||||||
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
||||||
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect";
|
|||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { Switch } from "../components/Switch";
|
import { Switch } from "../components/Switch";
|
||||||
|
import { TelegramLoginButton } from "../components/TelegramLoginButton";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import type { Me } from "../domain/types";
|
||||||
import {
|
import {
|
||||||
|
useAuthConfig,
|
||||||
useDeleteAvatar,
|
useDeleteAvatar,
|
||||||
|
useLinkTelegram,
|
||||||
useLogout,
|
useLogout,
|
||||||
useMe,
|
useMe,
|
||||||
|
useSetPassword,
|
||||||
useUpdateBio,
|
useUpdateBio,
|
||||||
useUpdateFavoriteFaction,
|
useUpdateFavoriteFaction,
|
||||||
useUpdateHistoryPrefs,
|
useUpdateHistoryPrefs,
|
||||||
@@ -218,6 +223,9 @@ export function AccountPage() {
|
|||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 6 }}>
|
||||||
|
Никнейм — это и логин для входа: после смены входите под новым.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,9 +264,129 @@ export function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LoginSettingsCard me={me} />
|
||||||
|
|
||||||
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вход в аккаунт: смена пароля и привязка Telegram (ник при привязке не меняется).
|
||||||
|
function LoginSettingsCard({ me }: { me: Me }) {
|
||||||
|
const setPassword = useSetPassword();
|
||||||
|
const linkTelegram = useLinkTelegram();
|
||||||
|
const { data: config } = useAuthConfig();
|
||||||
|
const toast = useToast();
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const changePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (next !== repeat) {
|
||||||
|
setError("Пароли не совпадают.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setPassword.mutateAsync(
|
||||||
|
me.has_password ? { current_password: current, new_password: next } : { new_password: next },
|
||||||
|
);
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setRepeat("");
|
||||||
|
toast.show(me.has_password ? "Пароль изменён" : "Пароль задан");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTelegram = async (payload: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await linkTelegram.mutateAsync(payload);
|
||||||
|
toast.show("Telegram привязан");
|
||||||
|
} catch (err) {
|
||||||
|
toast.show(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
useCreateDevUser,
|
useCreateDevUser,
|
||||||
useDevUsers,
|
useDevUsers,
|
||||||
useMe,
|
useMe,
|
||||||
|
usePasswordLogin,
|
||||||
|
useRegister,
|
||||||
useStubLogin,
|
useStubLogin,
|
||||||
useTelegramLogin,
|
useTelegramLogin,
|
||||||
useTelegramRegister,
|
useTelegramRegister,
|
||||||
@@ -28,6 +30,7 @@ export function LoginPage() {
|
|||||||
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
||||||
|
|
||||||
const methods = config?.methods ?? [];
|
const methods = config?.methods ?? [];
|
||||||
|
const hasPassword = methods.includes("password");
|
||||||
const hasTelegram = methods.includes("telegram");
|
const hasTelegram = methods.includes("telegram");
|
||||||
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
||||||
// import.meta.env.DEV === false → блок вырезается из бандла).
|
// import.meta.env.DEV === false → блок вырезается из бандла).
|
||||||
@@ -72,7 +75,9 @@ export function LoginPage() {
|
|||||||
onDone={() => navigate("/", { replace: true })}
|
onDone={() => navigate("/", { replace: true })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
hasTelegram && (
|
<>
|
||||||
|
{hasPassword && <PasswordAuthCard onDone={() => navigate("/", { replace: true })} />}
|
||||||
|
{hasTelegram && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Вход через Telegram</h3>
|
<h3>Вход через Telegram</h3>
|
||||||
{config?.telegram_bot_username ? (
|
{config?.telegram_bot_username ? (
|
||||||
@@ -86,8 +91,13 @@ export function LoginPage() {
|
|||||||
в .env (бот от @BotFather + /setdomain на ваш домен).
|
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||||
|
Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле —
|
||||||
|
иначе вход через Telegram создаст новый аккаунт.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
||||||
@@ -98,6 +108,106 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Основной вход: логин (ник) и пароль, с переключением на регистрацию.
|
||||||
|
function PasswordAuthCard({ onDone }: { onDone: () => void }) {
|
||||||
|
const login = usePasswordLogin();
|
||||||
|
const register = useRegister();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [nickname, setNickname] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isRegister = mode === "register";
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const nick = nickname.trim();
|
||||||
|
if (!nick || !password) {
|
||||||
|
setError("Введите никнейм и пароль.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRegister && password !== repeat) {
|
||||||
|
setError("Пароли не совпадают.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isRegister) await register.mutateAsync({ nickname: nick, password });
|
||||||
|
else await login.mutateAsync({ nickname: nick, password });
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
const fallback = isRegister ? "Не удалось зарегистрироваться" : "Не удалось войти";
|
||||||
|
setError(err instanceof ApiError ? err.message : fallback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchMode = () => {
|
||||||
|
setMode(isRegister ? "login" : "register");
|
||||||
|
setRepeat("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="card" onSubmit={submit}>
|
||||||
|
<h3>{isRegister ? "Регистрация" : "Вход"}</h3>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="auth-nickname">
|
||||||
|
Никнейм
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auth-nickname"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="auth-password">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auth-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={isRegister ? "new-password" : "current-password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{isRegister && <div className="muted small">Не короче 8 символов.</div>}
|
||||||
|
</div>
|
||||||
|
{isRegister && (
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="auth-repeat">
|
||||||
|
Повторите пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auth-repeat"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(e) => setRepeat(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={login.isPending || register.isPending}
|
||||||
|
>
|
||||||
|
{isRegister ? "Зарегистрироваться" : "Войти"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-block small" style={{ marginTop: 8 }} onClick={switchMode}>
|
||||||
|
{isRegister ? "Уже есть аккаунт? Войти" : "Нет аккаунта? Зарегистрироваться"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
||||||
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
||||||
function TelegramNicknamePicker({
|
function TelegramNicknamePicker({
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Pencil } from "lucide-react";
|
import { KeyRound, Pencil } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||||
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||||
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||||
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||||
@@ -12,17 +12,40 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
|||||||
export function AdminAccountsPage() {
|
export function AdminAccountsPage() {
|
||||||
const { data: users, isLoading } = useAdminUsers();
|
const { data: users, isLoading } = useAdminUsers();
|
||||||
const update = useAdminUpdateUser();
|
const update = useAdminUpdateUser();
|
||||||
|
const setPassword = useAdminSetPassword();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
// Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование.
|
||||||
|
const [passwordId, setPasswordId] = useState<number | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
const startEdit = (id: number, nickname: string) => {
|
const startEdit = (id: number, nickname: string) => {
|
||||||
|
setPasswordId(null);
|
||||||
setEditId(id);
|
setEditId(id);
|
||||||
setEditName(nickname);
|
setEditName(nickname);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startPassword = (id: number) => {
|
||||||
|
setEditId(null);
|
||||||
|
setPasswordId(id);
|
||||||
|
setNewPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
if (passwordId == null || !newPassword) return;
|
||||||
|
try {
|
||||||
|
await setPassword.mutateAsync({ userId: passwordId, password: newPassword });
|
||||||
|
toast.show("Пароль задан — сообщите его игроку");
|
||||||
|
setPasswordId(null);
|
||||||
|
setNewPassword("");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось задать пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
const name = editName.trim();
|
const name = editName.trim();
|
||||||
if (editId == null || !name) return;
|
if (editId == null || !name) return;
|
||||||
@@ -73,12 +96,39 @@ export function AdminAccountsPage() {
|
|||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : passwordId === u.id ? (
|
||||||
|
// Режим «задать пароль».
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={`Новый пароль для ${u.nickname}`}
|
||||||
|
value={newPassword}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") savePassword();
|
||||||
|
if (e.key === "Escape") setPasswordId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary small"
|
||||||
|
disabled={setPassword.isPending || !newPassword}
|
||||||
|
onClick={savePassword}
|
||||||
|
>
|
||||||
|
Задать
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost small" onClick={() => setPasswordId(null)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
||||||
|
{u.role !== "admin" && !u.has_password && " · без пароля"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{u.role !== "admin" && (
|
{u.role !== "admin" && (
|
||||||
@@ -90,6 +140,13 @@ export function AdminAccountsPage() {
|
|||||||
>
|
>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
title="Задать пароль"
|
||||||
|
onClick={() => startPassword(u.id)}
|
||||||
|
>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
</button>
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user