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:
2026-09-13 15:36:50 +03:00
22 changed files with 1599 additions and 57 deletions
+17 -9
View File
@@ -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`); прод-контейнер это значение **игнорирует** и всегда
+90
View File
@@ -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)
+2 -2
View File
@@ -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
+36
View File
@@ -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()
+70
View File
@@ -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()
+35 -18
View File
@@ -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 возможность, вынесена в
+41 -3
View File
@@ -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,
+53
View File
@@ -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(...),
+32 -1
View 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
+11
View File
@@ -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 (файл исключён из прод/тест-образа).
+68 -1
View File
@@ -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):
+2
View File
@@ -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()
+5 -4
View File
@@ -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):
+360
View File
@@ -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
+303
View File
@@ -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;
+4
View File
@@ -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>
);
}
+15
View File
@@ -203,6 +203,21 @@ export function useAdminUpdateUser() {
});
}
// Новый пароль игроку — когда он забыл свой (почту приложение не хранит).
export function useAdminSetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { userId: number; password: string }) =>
unwrap(
await api.PUT("/api/admin/users/{user_id}/password", {
params: { path: { user_id: args.userId } },
body: { new_password: args.password },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
}
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
interface AchievementInput {
+46
View File
@@ -45,6 +45,52 @@ export function useStubLogin() {
});
}
// Вход по логину (нику) и паролю. Перебор сервер ограничивает: 429 TOO_MANY_ATTEMPTS.
export function usePasswordLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { nickname: string; password: string }) =>
unwrap(await api.POST("/api/auth/login", { body })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
// Регистрация по логину (нику) и паролю; Telegram привязывается позже в профиле.
export function useRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { nickname: string; password: string }) =>
unwrap(await api.POST("/api/auth/register", { body })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
// Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
export function useSetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { new_password: string; current_password?: string }): Promise<Me> =>
unwrap(await api.PUT("/api/users/me/password", { body })),
onSuccess: (me) => qc.setQueryData(qk.me, me),
});
}
// Привязка Telegram к аккаунту: тот же payload виджета, что при входе; ник не меняется.
export function useLinkTelegram() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (payload: Record<string, unknown>): Promise<Me> =>
unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })),
onSuccess: (me) => qc.setQueryData(qk.me, me),
});
}
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
+128
View File
@@ -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>
);
}
+112 -2
View File
@@ -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({
+59 -2
View File
@@ -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} />
)}