diff --git a/README.md b/README.md index a6f223c..74c4acd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Forbidden Stars — учёт партий Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: -профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий +профили игроков (вход по логину и паролю или через Telegram), группы, создание партий с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель. - **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite @@ -91,8 +91,8 @@ npm run gen:api # сгенерирует типы из живог npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека) ``` -Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику), -в проде — только Telegram (см. раздел «Аутентификация»). +Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub); +в проде stub нет (см. раздел «Аутентификация»). ## Production (Docker на Pi) @@ -108,7 +108,7 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми ## Test — локальный прод-клон в контейнере Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков -только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. +по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000). Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. @@ -123,8 +123,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с - Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). - Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi). -- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** — - только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`). +- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — + по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`. - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: @@ -136,9 +136,17 @@ docker compose -f docker-compose.test.yml down -v # остановить и с | | dev | test / prod | |---|---|---| -| Telegram Login Widget | ✓ | ✓ (единственный) | -| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) | +| Логин (= ник) и пароль | ✓ | ✓ (основной) | +| Telegram Login Widget | ✓ | ✓ | +| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) | +- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока + (смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt. + От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP, + дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей) + после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен + текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ + на вкладке аккаунтов — почту приложение не хранит. - **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development` @@ -165,7 +173,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с | Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | | Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | | База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | -| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram | +| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | - **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py new file mode 100644 index 0000000..b40058b --- /dev/null +++ b/backend/app/auth/password.py @@ -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) diff --git a/backend/app/auth/registry.py b/backend/app/auth/registry.py index 952782a..2af0588 100644 --- a/backend/app/auth/registry.py +++ b/backend/app/auth/registry.py @@ -1,6 +1,6 @@ """Доступные методы входа по окружению. -Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG). +Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development. Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода. """ from __future__ import annotations @@ -9,7 +9,7 @@ from app.core.config import settings def enabled_methods() -> list[str]: - methods = ["telegram"] + methods = ["password", "telegram"] if settings.is_development: methods.append("stub") return methods diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index b82911a..e502f40 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -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() diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py new file mode 100644 index 0000000..0a6176a --- /dev/null +++ b/backend/app/core/ratelimit.py @@ -0,0 +1,70 @@ +"""Ограничение неудачных попыток ввода пароля (защита от перебора). + +Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn. +Перезапуск их обнуляет; для окна в 15 минут это приемлемо. +""" +from __future__ import annotations + +import threading +import time +from collections import deque + +from app.core.errors import TooManyAttemptsError + +_WINDOW_SECONDS = 15 * 60 +# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток +# с разных адресов не копил память бесконечно. +_PRUNE_ABOVE = 10_000 + + +class LoginThrottle: + """Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока). + + Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком.""" + + def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None: + self.window = window_seconds + self._fails: dict[str, deque[float]] = {} + self._lock = threading.Lock() + + def _recent(self, key: str, now: float) -> deque[float]: + attempts = self._fails.get(key) + if attempts is None: + return deque() + while attempts and now - attempts[0] >= self.window: + attempts.popleft() + if not attempts: + del self._fails[key] + return attempts + + def check(self, limits: dict[str, int]) -> None: + """Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан.""" + now = time.monotonic() + with self._lock: + for key, limit in limits.items(): + attempts = self._recent(key, now) + if len(attempts) >= limit: + # Попытка снова примется, когда из окна выпадет неудача, после которой + # в нём остаётся limit-1 записей. + frees_at = attempts[len(attempts) - limit] + self.window + raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1) + + def fail(self, keys: dict[str, int]) -> None: + now = time.monotonic() + with self._lock: + if len(self._fails) > _PRUNE_ABOVE: + for key in list(self._fails): + self._recent(key, now) + for key in keys: + self._fails.setdefault(key, deque()).append(now) + + def reset(self, key: str) -> None: + with self._lock: + self._fails.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._fails.clear() + + +login_throttle = LoginThrottle() diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 5d58805..0448c95 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -68,24 +68,26 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe: # ─── Пользователи ──────────────────────────────────────────────────────────── +def _admin_user_read(u: User) -> s.AdminUserRead: + return s.AdminUserRead( + id=u.id, # type: ignore[arg-type] + nickname=u.nickname, + role=u.role, + is_active=u.is_active, + auth_provider=u.auth_provider, + telegram_id=u.telegram_id, + created_at=iso_utc(u.created_at), + has_password=u.password_hash is not None, + ) + + @router.get("/users", response_model=list[s.AdminUserRead]) def list_users( query: str | None = Query(None), session: Session = Depends(get_session), _admin: User = Depends(get_current_admin), ) -> list[s.AdminUserRead]: - return [ - s.AdminUserRead( - id=u.id, # type: ignore[arg-type] - nickname=u.nickname, - role=u.role, - is_active=u.is_active, - auth_provider=u.auth_provider, - telegram_id=u.telegram_id, - created_at=iso_utc(u.created_at), - ) - for u in admin_service.list_users(session, query) - ] + return [_admin_user_read(u) for u in admin_service.list_users(session, query)] @router.patch("/users/{user_id}", response_model=s.AdminUserRead) @@ -107,15 +109,30 @@ def update_user( ip=client_ip(request), ) session.commit() - return s.AdminUserRead( - id=u.id, # type: ignore[arg-type] - nickname=u.nickname, - role=u.role, - is_active=u.is_active, - auth_provider=u.auth_provider, - telegram_id=u.telegram_id, - created_at=iso_utc(u.created_at), + return _admin_user_read(u) + + +@router.put("/users/{user_id}/password", response_model=s.AdminUserRead) +def set_user_password( + user_id: int, + body: s.AdminPasswordSet, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.AdminUserRead: + """Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется.""" + u = admin_service.set_player_password(session, user_id, body.new_password) + audit_service.record( + session, + actor_id=admin.id, + action="update", + entity_type="user", + entity_id=user_id, + payload={"password": "set_by_admin"}, + ip=client_ip(request), ) + session.commit() + return _admin_user_read(u) # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index d3c0ba2..5508402 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ -"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход. +"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход. -Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev. +Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev. """ from __future__ import annotations @@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session from app.auth.login import establish_session +from app.auth.password import login_player from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security from app.core.config import settings from app.core.errors import TelegramNicknameRequiredError +from app.core.security import client_ip from app.db.session import get_session from app.routers.users import build_me from app.schemas import api as s -from app.services import user_service +from app.services import audit_service, user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -29,6 +31,42 @@ def auth_config() -> s.AuthConfig: ) +@router.post("/register", response_model=s.MeRead) +def password_register( + body: s.PasswordRegister, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" + user = user_service.register_local(session, body.nickname, body.password) + audit_service.record( + session, + actor_id=user.id, + action="create", + entity_type="user", + entity_id=user.id, + payload={"provider": "local"}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + establish_session(session, response, request, user, "local") + return build_me(session, user) + + +@router.post("/login", response_model=s.MeRead) +def password_login( + body: s.PasswordLogin, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.""" + user = login_player(session, body.nickname, body.password, client_ip(request)) + establish_session(session, response, request, user, "local") + return build_me(session, user) + + @router.post("/telegram", response_model=s.MeRead) def telegram_login( body: s.TelegramAuthPayload, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index cb0e36a..648b056 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -6,6 +6,8 @@ from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user +from app.auth.password import check_current_password +from app.auth.telegram import TelegramProvider from app.core.security import client_ip from app.core.errors import NotFoundError from app.db.session import get_session @@ -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(...), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 4a3288e..60026aa 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 552f21d..d9b2334 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, return user +def set_player_password(session: Session, user_id: int, new_password: str) -> User: + """Новый пароль игроку (восстановление забытого). Пароль админа так не меняется — + он задаётся ADMIN_PASSWORD в .env.""" + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + if user.role != "player": + raise ValidationError("Пароль администратора здесь не меняется.") + return user_service.set_password(session, user, new_password) + + # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: # логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 987eeda..b51bc1e 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.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): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 27cc48f..8d51281 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,6 +18,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402 from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 import app.models # noqa: F401,E402 (регистрация моделей) +from app.core.ratelimit import login_throttle # noqa: E402 from app.core.security import hash_password # noqa: E402 from app.db.session import get_session # noqa: E402 from app.main import app # noqa: E402 @@ -48,6 +49,7 @@ def client(engine): yield s app.dependency_overrides[get_session] = _get_session + login_throttle.clear() # счётчики неудачных входов глобальны для процесса with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 184be38..7079c1b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -10,8 +10,9 @@ from fastapi.testclient import TestClient from tests.conftest import csrf_headers -def test_auth_config_dev_has_both_methods(client: TestClient): +def test_auth_config_dev_has_all_methods(client: TestClient): cfg = client.get("/api/auth/config").json() + assert "password" in cfg["methods"] assert "telegram" in cfg["methods"] assert "stub" in cfg["methods"] # dev → доступен вход по нику @@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch): from app.core.config import settings monkeypatch.setattr(settings, "app_env", "development") - assert set(enabled_methods()) == {"telegram", "stub"} + assert set(enabled_methods()) == {"password", "telegram", "stub"} monkeypatch.setattr(settings, "app_env", "test") - assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram + assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub monkeypatch.setattr(settings, "app_env", "production") - assert enabled_methods() == ["telegram"] # prod → только Telegram + assert enabled_methods() == ["password", "telegram"] # prod → без stub def test_env_flags_and_db_path(monkeypatch): diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py new file mode 100644 index 0000000..1896b32 --- /dev/null +++ b/backend/tests/test_password_auth.py @@ -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 diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 371d564..d245781 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -21,6 +21,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Password Register + * @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле. + */ + post: operations["password_register_api_auth_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Password Login + * @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429. + */ + post: operations["password_login_api_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/telegram": { parameters: { query?: never; @@ -122,6 +162,46 @@ export interface paths { patch: operations["update_my_profile_api_users_me_profile_patch"]; trace?: never; }; + "/api/users/me/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Change My Password + * @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий). + */ + put: operations["change_my_password_api_users_me_password_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/telegram": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Link My Telegram + * @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе. + */ + post: operations["link_my_telegram_api_users_me_telegram_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/avatar": { parameters: { query?: never; @@ -844,6 +924,26 @@ export interface paths { patch: operations["update_user_api_admin_users__user_id__patch"]; trace?: never; }; + "/api/admin/users/{user_id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set User Password + * @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется. + */ + put: operations["set_user_password_api_admin_users__user_id__password_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/groups": { parameters: { query?: never; @@ -1224,6 +1324,11 @@ export interface components { /** Role */ role: string; }; + /** AdminPasswordSet */ + AdminPasswordSet: { + /** New Password */ + new_password: string; + }; /** AdminUserRead */ AdminUserRead: { /** Id */ @@ -1240,6 +1345,11 @@ export interface components { telegram_id?: number | null; /** Created At */ created_at: string; + /** + * Has Password + * @default false + */ + has_password: boolean; }; /** AdminUserUpdate */ AdminUserUpdate: { @@ -1887,6 +1997,11 @@ export interface components { * @default [] */ groups: components["schemas"]["GroupBrief"][]; + /** + * Has Password + * @default false + */ + has_password: boolean; }; /** MemberAdd */ MemberAdd: { @@ -1990,6 +2105,27 @@ export interface components { /** Comment */ comment?: string | null; }; + /** PasswordChange */ + PasswordChange: { + /** Current Password */ + current_password?: string | null; + /** New Password */ + new_password: string; + }; + /** PasswordLogin */ + PasswordLogin: { + /** Nickname */ + nickname: string; + /** Password */ + password: string; + }; + /** PasswordRegister */ + PasswordRegister: { + /** Nickname */ + nickname: string; + /** Password */ + password: string; + }; /** ProfileStats */ ProfileStats: { /** User Id */ @@ -2198,6 +2334,72 @@ export interface operations { }; }; }; + password_register_api_auth_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordRegister"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + password_login_api_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordLogin"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; telegram_login_api_auth_telegram_post: { parameters: { query?: never; @@ -2370,6 +2572,72 @@ export interface operations { }; }; }; + change_my_password_api_users_me_password_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChange"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + link_my_telegram_api_users_me_telegram_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramAuthPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; upload_my_avatar_api_users_me_avatar_put: { parameters: { query?: never; @@ -3789,6 +4057,41 @@ export interface operations { }; }; }; + set_user_password_api_admin_users__user_id__password_put: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPasswordSet"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_groups_api_admin_groups_get: { parameters: { query?: never; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0b2460a..5088d43 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents"; import { BottomBar } from "./BottomBar"; import { NotificationBell } from "./NotificationBell"; import { NotificationToaster } from "./NotificationToaster"; +import { SetPasswordDialog } from "./SetPasswordDialog"; import { SideMenu } from "./SideMenu"; const TITLES: Record = { @@ -51,6 +52,9 @@ export function AppShell() { setMenuOpen(false)} /> setMenuOpen(true)} /> + + {/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */} + {me && !me.has_password && } ); } diff --git a/frontend/src/components/SetPasswordDialog.tsx b/frontend/src/components/SetPasswordDialog.tsx new file mode 100644 index 0000000..3eb1ee0 --- /dev/null +++ b/frontend/src/components/SetPasswordDialog.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ApiError } from "../api/client"; +import { useLogout, useSetPassword } from "../hooks/auth"; + +/** + * Обязательная установка пароля — для аккаунтов из Telegram и созданных до появления + * паролей. Закрыть окно нельзя: только задать пароль или выйти. Кнопка «Позже» есть + * лишь в dev-сборке (import.meta.env.DEV) — чтобы тестовые stub-игроки не упирались в окно. + */ +export function SetPasswordDialog({ nickname }: { nickname: string }) { + const setPassword = useSetPassword(); + const logout = useLogout(); + const navigate = useNavigate(); + const [password, setPasswordValue] = useState(""); + const [repeat, setRepeat] = useState(""); + const [error, setError] = useState(null); + const [skipped, setSkipped] = useState(false); + + if (skipped) return null; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== repeat) { + setError("Пароли не совпадают."); + return; + } + setError(null); + try { + // Успех кладёт в кэш профиль с has_password=true — окно исчезает само. + await setPassword.mutateAsync({ new_password: password }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль"); + } + }; + + const doLogout = async () => { + await logout.mutateAsync().catch(() => {}); + navigate("/login", { replace: true }); + }; + + return ( +
+
+
Задайте пароль
+

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

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

{error}

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

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

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

{error}

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

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

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

Вход через Telegram

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

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

+

Вход через Telegram

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

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

+ )} +

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

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

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

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

{error}

} + + +
+ ); +} + // Выбор ника при регистрации через Telegram, когда тег занят/некорректен. // Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново). function TelegramNicknamePicker({ diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 681589f..6997a06 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -1,10 +1,10 @@ -import { Pencil } from "lucide-react"; +import { KeyRound, Pencil } from "lucide-react"; import { useState } from "react"; import { ApiError } from "../../api/client"; import { Spinner } from "../../components/Spinner"; import { useToast } from "../../context/ToastContext"; -import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; +import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; // DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV, // поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; @@ -12,17 +12,40 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; export function AdminAccountsPage() { const { data: users, isLoading } = useAdminUsers(); const update = useAdminUpdateUser(); + const setPassword = useAdminSetPassword(); const toast = useToast(); const [editId, setEditId] = useState(null); const [editName, setEditName] = useState(""); + // Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование. + const [passwordId, setPasswordId] = useState(null); + const [newPassword, setNewPassword] = useState(""); if (isLoading) return ; const startEdit = (id: number, nickname: string) => { + setPasswordId(null); setEditId(id); setEditName(nickname); }; + const startPassword = (id: number) => { + setEditId(null); + setPasswordId(id); + setNewPassword(""); + }; + + const savePassword = async () => { + if (passwordId == null || !newPassword) return; + try { + await setPassword.mutateAsync({ userId: passwordId, password: newPassword }); + toast.show("Пароль задан — сообщите его игроку"); + setPasswordId(null); + setNewPassword(""); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Не удалось задать пароль"); + } + }; + const saveName = async () => { const name = editName.trim(); if (editId == null || !name) return; @@ -73,12 +96,39 @@ export function AdminAccountsPage() { Отмена + ) : passwordId === u.id ? ( + // Режим «задать пароль». +
+ setNewPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") savePassword(); + if (e.key === "Escape") setPasswordId(null); + }} + /> + + +
) : (
{u.nickname} {u.role === "admin" && админ}
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"} + {u.role !== "admin" && !u.has_password && " · без пароля"}
{u.role !== "admin" && ( @@ -90,6 +140,13 @@ export function AdminAccountsPage() { > + {import.meta.env.DEV && ( )}