Пароль и привязка Telegram в профиле, пароль игрока из админки

PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.

POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.

PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.

#24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
2026-09-13 14:02:19 +03:00
co-authored by Claude Opus 5
parent 82cf7a3393
commit c73b4519cb
8 changed files with 365 additions and 22 deletions
+19 -1
View File
@@ -9,7 +9,11 @@ from functools import lru_cache
from sqlmodel import Session, select
from app.core.errors import InvalidCredentialsError, ValidationError
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
@@ -23,6 +27,8 @@ _BCRYPT_MAX_BYTES = 72
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
_PAIR_LIMIT = 5
_IP_LIMIT = 20
# Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5
def validate_new_password(password: str) -> None:
@@ -70,3 +76,15 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None)
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)
+7
View File
@@ -151,6 +151,13 @@ class TooManyAttemptsError(AppError):
)
class WrongCurrentPasswordError(ForbiddenError):
code = "WRONG_CURRENT_PASSWORD"
def __init__(self) -> None:
super().__init__("Текущий пароль неверен.")
class TelegramAlreadyLinkedError(ConflictError):
code = "TELEGRAM_ALREADY_LINKED"
+37 -20
View File
@@ -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 возможность, вынесена в
+52
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
@@ -116,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
View File
@@ -33,6 +33,12 @@ class PasswordRegister(BaseModel):
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")
@@ -512,6 +518,7 @@ class AdminUserRead(BaseModel):
auth_provider: str
telegram_id: int | None = None
created_at: str
has_password: bool = False
class AdminUserUpdate(BaseModel):
@@ -519,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 (файл исключён из прод/тест-образа).
+46 -1
View File
@@ -10,7 +10,13 @@ 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
@@ -154,6 +160,45 @@ def register_local(session: Session, nickname: str, password: str) -> 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):