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