Добавлена форма изменения ника при регистрации аккаунта на случай, когда уже существует аакунт с ником, совпадающим с ТГ тегом регистрируемого аккаунта

This commit is contained in:
2026-06-17 16:17:14 +03:00
parent 65bebf8b85
commit 865c967d5e
10 changed files with 409 additions and 28 deletions
+13 -5
View File
@@ -15,22 +15,30 @@ from app.models import User
from app.services import audit_service, user_service from app.services import audit_service, user_service
def login_with_identity( def establish_session(
session: Session, response: Response, request: Request, identity: ExternalIdentity session: Session, response: Response, request: Request, user: User, provider: str
) -> User: ) -> User:
user = user_service.get_or_create_from_identity(session, identity) """Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
if not user.is_active: if not user.is_active:
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") 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( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
action="login", action="login",
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"provider": identity.provider}, payload={"provider": provider},
ip=request.client.host if request.client else None, ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
return user 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)
+14
View File
@@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError):
super().__init__("Никнейм уже занят.") 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): class FactionNotAvailableError(ValidationError):
code = "FACTION_NOT_AVAILABLE" code = "FACTION_NOT_AVAILABLE"
+40 -2
View File
@@ -7,14 +7,16 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session 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.registry import enabled_methods
from app.auth.telegram import TelegramProvider from app.auth.telegram import TelegramProvider
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.errors import TelegramNicknameRequiredError
from app.db.session import get_session from app.db.session import get_session
from app.routers.users import build_me from app.routers.users import build_me
from app.schemas import api as s from app.schemas import api as s
from app.services import user_service
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -34,8 +36,44 @@ def telegram_login(
response: Response, response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> s.MeRead: ) -> s.MeRead:
"""Вход/регистрация через Telegram.
Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
с выбранным ником.
"""
identity = TelegramProvider().authenticate(body.model_dump()) 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) return build_me(session, user)
+6
View File
@@ -29,6 +29,12 @@ class TelegramAuthPayload(BaseModel):
photo_url: str | None = None photo_url: str | None = None
class TelegramRegister(TelegramAuthPayload):
# Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен).
# Подпись виджета проверяется по тем же полям; nickname в HMAC не входит.
nickname: str
class DevLogin(BaseModel): class DevLogin(BaseModel):
nickname: str nickname: str
+40 -4
View File
@@ -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 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: def _unique_nickname(session: Session, base: str) -> str:
base = (base or "Игрок").strip()[:60] or "Игрок" base = (base or "Игрок").strip()[:60] or "Игрок"
candidate = base candidate = base
@@ -35,18 +40,24 @@ def _unique_nickname(session: Session, base: str) -> str:
return candidate 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( link = session.exec(
select(AuthIdentity).where( select(AuthIdentity).where(
AuthIdentity.provider == identity.provider, AuthIdentity.provider == identity.provider,
AuthIdentity.external_id == identity.external_id, AuthIdentity.external_id == identity.external_id,
) )
).first() ).first()
if link is not None: return get_user(session, link.user_id) if link is not None else None
return get_user(session, link.user_id)
def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User:
user = User( user = User(
nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id), nickname=nickname,
role="player", role="player",
auth_provider=identity.provider, auth_provider=identity.provider,
telegram_id=identity.telegram_id, telegram_id=identity.telegram_id,
@@ -65,6 +76,31 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) ->
return user 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: def update_nickname(session: Session, user: User, new_nickname: str) -> User:
new_nickname = (new_nickname or "").strip() new_nickname = (new_nickname or "").strip()
if not _NICK_RE.match(new_nickname): if not _NICK_RE.match(new_nickname):
+61
View File
@@ -79,3 +79,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch):
r = client.post("/api/auth/telegram", json=payload) r = client.post("/api/auth/telegram", json=payload)
assert r.status_code == 401 assert r.status_code == 401
_ = csrf_headers # (для единообразия импорта) _ = 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"
+87 -1
View File
@@ -30,7 +30,15 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
/** Telegram Login */ /**
* Telegram Login
* @description Вход/регистрация через Telegram.
*
* Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
* регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
* TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
* с выбранным ником.
*/
post: operations["telegram_login_api_auth_telegram_post"]; post: operations["telegram_login_api_auth_telegram_post"];
delete?: never; delete?: never;
options?: never; options?: never;
@@ -38,6 +46,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/auth/logout": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1369,6 +1401,27 @@ export interface components {
} & { } & {
[key: string]: unknown; [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 */
UserRead: { UserRead: {
/** Id */ /** 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: { logout_api_auth_logout_post: {
parameters: { parameters: {
query?: never; query?: never;
+16
View File
@@ -44,6 +44,8 @@ export function useStubLogin() {
} }
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
export function useTelegramLogin() { export function useTelegramLogin() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -56,6 +58,20 @@ export function useTelegramLogin() {
}); });
} }
// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен).
// payload — те же данные виджета + nickname; подпись проверяется заново на сервере.
export function useTelegramRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (payload: Record<string, unknown>) =>
unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useCreateDevUser() { export function useCreateDevUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
+89 -2
View File
@@ -11,13 +11,21 @@ import {
useMe, useMe,
useStubLogin, useStubLogin,
useTelegramLogin, useTelegramLogin,
useTelegramRegister,
} from "../hooks/auth"; } from "../hooks/auth";
// Данные виджета Telegram, отложенные до выбора ника (когда тег занят).
interface PendingTelegram {
payload: Record<string, unknown>;
suggested: string;
}
export function LoginPage() { export function LoginPage() {
const { data: me, isLoading } = useMe(); const { data: me, isLoading } = useMe();
const { data: config } = useAuthConfig(); const { data: config } = useAuthConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
const methods = config?.methods ?? []; const methods = config?.methods ?? [];
const hasTelegram = methods.includes("telegram"); const hasTelegram = methods.includes("telegram");
@@ -36,6 +44,12 @@ export function LoginPage() {
await telegram.mutateAsync(user); await telegram.mutateAsync(user);
navigate("/", { replace: true }); navigate("/", { replace: true });
} catch (e) { } 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"); setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
} }
}; };
@@ -48,7 +62,17 @@ export function LoginPage() {
</h1> </h1>
<p className="muted center">Учёт партий</p> <p className="muted center">Учёт партий</p>
{hasTelegram && ( {pendingTg ? (
<TelegramNicknamePicker
pending={pendingTg}
onCancel={() => {
setPendingTg(null);
setError(null);
}}
onDone={() => navigate("/", { replace: true })}
/>
) : (
hasTelegram && (
<div className="card"> <div className="card">
<h3>Вход через Telegram</h3> <h3>Вход через Telegram</h3>
{config?.telegram_bot_username ? ( {config?.telegram_bot_username ? (
@@ -63,9 +87,10 @@ export function LoginPage() {
</p> </p>
)} )}
</div> </div>
)
)} )}
{showStub && <DevLogin onError={setError} />} {showStub && !pendingTg && <DevLogin onError={setError} />}
{error && <p className="error-text center">{error}</p>} {error && <p className="error-text center">{error}</p>}
@@ -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<string | null>(
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 (
<div className="card">
<h3>Выберите никнейм</h3>
<p className="muted small">
Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации.
</p>
<div className="field">
<div className="row">
<input
placeholder="Никнейм"
value={nickname}
autoFocus
onChange={(e) => setNickname(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<button className="btn btn-primary" onClick={submit} disabled={register.isPending}>
Зарегистрироваться
</button>
</div>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-ghost small" onClick={onCancel}>
Назад
</button>
</div>
);
}
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке // DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом. // LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
function DevLogin({ onError }: { onError: (msg: string | null) => void }) { function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
+29
View File
@@ -0,0 +1,29 @@
commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev)
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:45:33 2026 +0300
Добавление ssh-ключа к контейнеру tunnel
commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:27:11 2026 +0300
Правки локального размещения дева, перенос тунеля в контейнер
commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 19:15:54 2026 +0300
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main)
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 17:41:06 2026 +0300
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 16:53:41 2026 +0300
first commit