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

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
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)
+14
View File
@@ -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"
+40 -2
View File
@@ -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)
+6
View File
@@ -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
+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
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):
+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)
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"