From 865c967d5e628722a3e2d47a4dc44c1d16b0f2c9 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 16:17:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D0=B9,=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B0=D0=B0=D0=BA=D1=83=D0=BD=D1=82=20=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=BC,=20=D1=81=D0=BE=D0=B2=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=8E=D1=89=D0=B8=D0=BC=20=D1=81=20=D0=A2?= =?UTF-8?q?=D0=93=20=D1=82=D0=B5=D0=B3=D0=BE=D0=BC=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/auth/login.py | 18 ++-- backend/app/core/errors.py | 14 ++++ backend/app/routers/auth.py | 42 +++++++++- backend/app/schemas/api.py | 6 ++ backend/app/services/user_service.py | 44 +++++++++- backend/tests/test_auth.py | 61 ++++++++++++++ frontend/src/api/schema.d.ts | 88 +++++++++++++++++++- frontend/src/hooks/auth.ts | 16 ++++ frontend/src/pages/LoginPage.tsx | 119 +++++++++++++++++++++++---- h -u origin dev | 29 +++++++ 10 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 h -u origin dev diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index 9a54a6e..5601f08 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -15,22 +15,30 @@ from app.models import User from app.services import audit_service, user_service -def login_with_identity( - session: Session, response: Response, request: Request, identity: ExternalIdentity +def establish_session( + session: Session, response: Response, request: Request, user: User, provider: str ) -> User: - user = user_service.get_or_create_from_identity(session, identity) + """Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" if not user.is_active: raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") - security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type] + security.set_user_session(response, user.id, provider) # type: ignore[arg-type] audit_service.record( session, actor_id=user.id, action="login", entity_type="user", entity_id=user.id, - payload={"provider": identity.provider}, + payload={"provider": provider}, ip=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) session.commit() return user + + +def login_with_identity( + session: Session, response: Response, request: Request, identity: ExternalIdentity +) -> User: + """Вход с авто-созданием (dev-вход по нику): ник подбирается автоматически.""" + user = user_service.get_or_create_from_identity(session, identity) + return establish_session(session, response, request, user, identity.provider) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 5541f74..b82911a 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError): super().__init__("Никнейм уже занят.") +class TelegramNicknameRequiredError(ConflictError): + """Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен — + фронт должен показать выбор ника и повторить регистрацию через /telegram/register. + `suggested` идёт в details для предзаполнения поля.""" + + code = "TELEGRAM_NICKNAME_REQUIRED" + + def __init__(self, suggested: str, message: str | None = None) -> None: + super().__init__( + message or f"Ник «{suggested}» уже занят — выберите другой.", + details={"suggested": suggested}, + ) + + class FactionNotAvailableError(ValidationError): code = "FACTION_NOT_AVAILABLE" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c7807dc..d3c0ba2 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -7,14 +7,16 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session -from app.auth.login import login_with_identity +from app.auth.login import establish_session 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.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 router = APIRouter(prefix="/auth", tags=["auth"]) @@ -34,8 +36,44 @@ def telegram_login( response: Response, session: Session = Depends(get_session), ) -> s.MeRead: + """Вход/регистрация через Telegram. + + Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход — + регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409 + TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register + с выбранным ником. + """ identity = TelegramProvider().authenticate(body.model_dump()) - user = login_with_identity(session, response, request, identity) + user = user_service.find_by_identity(session, identity) + if user is None: + suggested = (identity.suggested_nickname or "").strip() + if not user_service.nickname_format_ok(suggested) or not user_service.nickname_available( + session, suggested + ): + raise TelegramNicknameRequiredError(suggested) + user = user_service.register_from_identity(session, identity, suggested) + establish_session(session, response, request, user, identity.provider) + return build_me(session, user) + + +@router.post("/telegram/register", response_model=s.MeRead) +def telegram_register( + body: s.TelegramRegister, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Завершение регистрации через Telegram с выбранным ником. + + Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность + доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел + зарегистрироваться (гонка) — просто входим. + """ + identity = TelegramProvider().authenticate(body.model_dump(exclude={"nickname"})) + user = user_service.find_by_identity(session, identity) + if user is None: + user = user_service.register_from_identity(session, identity, body.nickname) + establish_session(session, response, request, user, identity.provider) return build_me(session, user) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index bd90d8c..b8fe7b8 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -29,6 +29,12 @@ class TelegramAuthPayload(BaseModel): photo_url: str | None = None +class TelegramRegister(TelegramAuthPayload): + # Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен). + # Подпись виджета проверяется по тем же полям; nickname в HMAC не входит. + nickname: str + + class DevLogin(BaseModel): nickname: str diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 9295eb5..7696b63 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -25,6 +25,11 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N return existing is None or existing.id == exclude_user_id +def nickname_format_ok(nickname: str) -> bool: + """Ник подходит по формату (2–64, буквы/цифры/пробел/.-_).""" + return bool(_NICK_RE.match((nickname or "").strip())) + + def _unique_nickname(session: Session, base: str) -> str: base = (base or "Игрок").strip()[:60] or "Игрок" candidate = base @@ -35,18 +40,24 @@ def _unique_nickname(session: Session, base: str) -> str: return candidate -def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: +def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None: + """Существующий пользователь по внешней личности (provider+external_id). + + Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому + вход с любого устройства/после смены тега ведёт к одному и тому же пользователю. + """ link = session.exec( select(AuthIdentity).where( AuthIdentity.provider == identity.provider, AuthIdentity.external_id == identity.external_id, ) ).first() - if link is not None: - return get_user(session, link.user_id) + return get_user(session, link.user_id) if link is not None else None + +def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User: user = User( - nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id), + nickname=nickname, role="player", auth_provider=identity.provider, telegram_id=identity.telegram_id, @@ -65,6 +76,31 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> return user +def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: + """Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику).""" + user = find_by_identity(session, identity) + if user is not None: + return user + nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id) + return _create_from_identity(session, identity, nickname) + + +def register_from_identity( + session: Session, identity: ExternalIdentity, nickname: str +) -> User: + """Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram). + + В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий + ник вызывает ошибку, чтобы пользователь выбрал другой. + """ + nickname = (nickname or "").strip() + if not nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not nickname_available(session, nickname): + raise NicknameTakenError() + return _create_from_identity(session, identity, nickname) + + def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not _NICK_RE.match(new_nickname): diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 94f9fca..810ad4f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -79,3 +79,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch): r = client.post("/api/auth/telegram", json=payload) assert r.status_code == 401 _ = csrf_headers # (для единообразия импорта) + + +def test_telegram_same_id_no_duplicate_account(client: TestClient, monkeypatch): + """Вход с двух устройств / после смены тега — один аккаунт (ключ = числовой id).""" + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + r1 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="firsttag")) + assert r1.status_code == 200, r1.text + + client.cookies.clear() # «другое устройство» — без сессии + # Тот же telegram id (777), но другой тег и свежий auth_date. + r2 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="changedtag")) + assert r2.status_code == 200, r2.text + assert r2.json()["id"] == r1.json()["id"] # тот же пользователь + assert r2.json()["nickname"] == r1.json()["nickname"] # ник не пересоздаётся + + +def test_telegram_register_nickname_collision_then_pick(client: TestClient, engine, monkeypatch): + """Первый вход через ТГ, тег совпал с чужим ником → просим выбрать ник → регистрируем.""" + from sqlmodel import Session + + from app.core.config import settings + from app.models import User + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + with Session(engine) as s: # существующий игрок с ником = тегу из payload (ivan_tg) + s.add(User(nickname="ivan_tg", role="player", auth_provider="stub")) + s.commit() + + payload = _telegram_payload("TEST_BOT_TOKEN") # id=777, username=ivan_tg + r = client.post("/api/auth/telegram", json=payload) + assert r.status_code == 409, r.text + err = r.json()["error"] + assert err["code"] == "TELEGRAM_NICKNAME_REQUIRED" + assert err["details"]["suggested"] == "ivan_tg" + + # Повтор с выбранным свободным ником (та же подпись виджета). + r2 = client.post("/api/auth/telegram/register", json={**payload, "nickname": "Иван-новый"}) + assert r2.status_code == 200, r2.text + assert r2.json()["nickname"] == "Иван-новый" + assert r2.json()["telegram_id"] == 777 + + +def test_telegram_register_rejects_taken_nickname(client: TestClient, engine, monkeypatch): + """На шаге выбора ника всё ещё занятый ник отклоняется (NICKNAME_TAKEN).""" + from sqlmodel import Session + + from app.core.config import settings + from app.models import User + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + with Session(engine) as s: + s.add(User(nickname="ivan_tg", role="player", auth_provider="stub")) + s.add(User(nickname="занятый", role="player", auth_provider="stub")) + s.commit() + + payload = _telegram_payload("TEST_BOT_TOKEN") + r = client.post("/api/auth/telegram/register", json={**payload, "nickname": "занятый"}) + assert r.status_code == 409, r.text + assert r.json()["error"]["code"] == "NICKNAME_TAKEN" diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d26fd50..be9e2a6 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -30,7 +30,15 @@ export interface paths { }; get?: never; put?: never; - /** Telegram Login */ + /** + * Telegram Login + * @description Вход/регистрация через Telegram. + * + * Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход — + * регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409 + * TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register + * с выбранным ником. + */ post: operations["telegram_login_api_auth_telegram_post"]; delete?: never; options?: never; @@ -38,6 +46,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/telegram/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Telegram Register + * @description Завершение регистрации через Telegram с выбранным ником. + * + * Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность + * доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел + * зарегистрироваться (гонка) — просто входим. + */ + post: operations["telegram_register_api_auth_telegram_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/logout": { parameters: { query?: never; @@ -1369,6 +1401,27 @@ export interface components { } & { [key: string]: unknown; }; + /** TelegramRegister */ + TelegramRegister: { + /** Id */ + id: number; + /** Auth Date */ + auth_date: number; + /** Hash */ + hash: string; + /** First Name */ + first_name?: string | null; + /** Last Name */ + last_name?: string | null; + /** Username */ + username?: string | null; + /** Photo Url */ + photo_url?: string | null; + /** Nickname */ + nickname: string; + } & { + [key: string]: unknown; + }; /** UserRead */ UserRead: { /** Id */ @@ -1459,6 +1512,39 @@ export interface operations { }; }; }; + telegram_register_api_auth_telegram_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramRegister"]; + }; + }; + 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"]; + }; + }; + }; + }; logout_api_auth_logout_post: { parameters: { query?: never; diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 9948e11..383212d 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -44,6 +44,8 @@ export function useStubLogin() { } // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. +// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED — +// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета). export function useTelegramLogin() { const qc = useQueryClient(); return useMutation({ @@ -56,6 +58,20 @@ export function useTelegramLogin() { }); } +// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен). +// payload — те же данные виджета + nickname; подпись проверяется заново на сервере. +export function useTelegramRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Record) => + unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + export function useCreateDevUser() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index be1b8d0..2d94dfa 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -11,13 +11,21 @@ import { useMe, useStubLogin, useTelegramLogin, + useTelegramRegister, } from "../hooks/auth"; +// Данные виджета Telegram, отложенные до выбора ника (когда тег занят). +interface PendingTelegram { + payload: Record; + suggested: string; +} + export function LoginPage() { const { data: me, isLoading } = useMe(); const { data: config } = useAuthConfig(); const navigate = useNavigate(); const [error, setError] = useState(null); + const [pendingTg, setPendingTg] = useState(null); const methods = config?.methods ?? []; const hasTelegram = methods.includes("telegram"); @@ -36,6 +44,12 @@ export function LoginPage() { await telegram.mutateAsync(user); navigate("/", { replace: true }); } catch (e) { + // Тег занят/некорректен → просим выбрать ник и регистрируем тем же payload. + if (e instanceof ApiError && e.code === "TELEGRAM_NICKNAME_REQUIRED") { + const suggested = (e.details as { suggested?: string } | undefined)?.suggested ?? ""; + setPendingTg({ payload: user, suggested }); + return; + } setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram"); } }; @@ -48,24 +62,35 @@ export function LoginPage() {

Учёт партий

- {hasTelegram && ( -
-

Вход через Telegram

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

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

- )} -
+ {pendingTg ? ( + { + setPendingTg(null); + setError(null); + }} + 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 на ваш домен). +

+ )} +
+ ) )} - {showStub && } + {showStub && !pendingTg && } {error &&

{error}

} @@ -83,6 +108,68 @@ export function LoginPage() { ); } +// Выбор ника при регистрации через Telegram, когда тег занят/некорректен. +// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново). +function TelegramNicknamePicker({ + pending, + onCancel, + onDone, +}: { + pending: PendingTelegram; + onCancel: () => void; + onDone: () => void; +}) { + const register = useTelegramRegister(); + const [nickname, setNickname] = useState(pending.suggested); + const [error, setError] = useState( + pending.suggested ? `Ник «${pending.suggested}» уже занят — выберите другой.` : null, + ); + + const submit = async () => { + const nick = nickname.trim(); + if (!nick) { + setError("Введите никнейм."); + return; + } + setError(null); + try { + await register.mutateAsync({ ...pending.payload, nickname: nick }); + onDone(); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Не удалось зарегистрироваться"); + } + }; + + return ( +
+

Выберите никнейм

+

+ Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации. +

+
+
+ setNickname(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + }} + /> + +
+
+ {error &&

{error}

} + +
+ ); +} + // DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке // LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом. function DevLogin({ onError }: { onError: (msg: string | null) => void }) { diff --git a/h -u origin dev b/h -u origin dev new file mode 100644 index 0000000..36b4302 --- /dev/null +++ b/h -u origin dev @@ -0,0 +1,29 @@ +commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev) +Author: NotBigGhost +Date: Wed Jun 17 04:45:33 2026 +0300 + + Добавление ssh-ключа к контейнеру tunnel + +commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366 +Author: NotBigGhost +Date: Wed Jun 17 04:27:11 2026 +0300 + + Правки локального размещения дева, перенос тунеля в контейнер + +commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90 +Author: NotBigGhost +Date: Tue Jun 16 19:15:54 2026 +0300 + + Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель + +commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main) +Author: NotBigGhost +Date: Tue Jun 16 17:41:06 2026 +0300 + + v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика + +commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7 +Author: NotBigGhost +Date: Tue Jun 16 16:53:41 2026 +0300 + + first commit