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:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -68,24 +68,26 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
def _admin_user_read(u: User) -> s.AdminUserRead:
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
has_password=u.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||
def list_users(
|
||||
query: str | None = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminUserRead]:
|
||||
return [
|
||||
s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
)
|
||||
for u in admin_service.list_users(session, query)
|
||||
]
|
||||
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||
@@ -107,15 +109,30 @@ def update_user(
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=iso_utc(u.created_at),
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
||||
def set_user_password(
|
||||
user_id: int,
|
||||
body: s.AdminPasswordSet,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AdminUserRead:
|
||||
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
||||
u = admin_service.set_player_password(session, user_id, body.new_password)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload={"password": "set_by_admin"},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(...),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (файл исключён из прод/тест-образа).
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user