From c73b4519cbdaba35ef08e50b44b74f8cfd852b5e Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 14:02:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20Telegram?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5,=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D0=B8=D0=B3=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 20 ++- backend/app/core/errors.py | 7 + backend/app/routers/admin.py | 57 +++++--- backend/app/routers/users.py | 52 ++++++++ backend/app/schemas/api.py | 12 ++ backend/app/services/admin_service.py | 11 ++ backend/app/services/user_service.py | 47 ++++++- backend/tests/test_password_auth.py | 181 ++++++++++++++++++++++++++ 8 files changed, 365 insertions(+), 22 deletions(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index 8dd5377..b40058b 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -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) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index ad21e09..e502f40 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -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" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 5d58805..0448c95 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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 возможность, вынесена в diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 917a16f..648b056 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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(...), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 352a836..60026aa 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 552f21d..d9b2334 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -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 (файл исключён из прод/тест-образа). diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 545b242..b51bc1e 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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): diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index e00e31e..1896b32 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -6,6 +6,7 @@ from sqlmodel import Session, select from app.models import User from tests.conftest import csrf_headers +from tests.test_auth import _telegram_payload PASSWORD = "correct-horse" @@ -177,3 +178,183 @@ def test_throttle_per_ip_across_logins(client: TestClient): for i in range(20): assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401 assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429 + + +# ─── Установка и смена пароля ──────────────────────────────────────────────── + +def _set_password(client: TestClient, new: str, current: str | None = None): + body = {"new_password": new} + if current is not None: + body["current_password"] = current + return client.put("/api/users/me/password", json=body, headers=csrf_headers(client)) + + +def _telegram_login(client: TestClient, monkeypatch, **fields): + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + return client.post( + "/api/auth/telegram", + json=_telegram_payload("TEST_BOT_TOKEN", **fields), + headers=csrf_headers(client), + ) + + +def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch): + """Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику.""" + r = _telegram_login(client, monkeypatch) + assert r.status_code == 200, r.text + me = r.json() + assert me["has_password"] is False + + r2 = _set_password(client, PASSWORD) + assert r2.status_code == 200, r2.text + assert r2.json()["has_password"] is True + + client.cookies.clear() + r3 = _login(client, nickname=me["nickname"]) + assert r3.status_code == 200, r3.text + assert r3.json()["id"] == me["id"] + + +def test_change_password_requires_current(client: TestClient): + _register(client) + + missing = _set_password(client, "new-password-1") + assert missing.status_code == 403 + assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD" + assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403 + + assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200 + client.cookies.clear() + assert _login(client).status_code == 401 + assert _login(client, password="new-password-1").status_code == 200 + + +def test_change_password_validates_new(client: TestClient): + _register(client) + r = _set_password(client, "short", current=PASSWORD) + assert r.status_code == 422 + client.cookies.clear() + assert _login(client).status_code == 200 # старый пароль не тронут + + +def test_current_password_guessing_is_throttled(client: TestClient): + _register(client) + for _ in range(5): + assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403 + blocked = _set_password(client, "new-password-1", current=PASSWORD) + assert blocked.status_code == 429 + + +def test_set_password_requires_session(client: TestClient): + assert _set_password(client, PASSWORD).status_code == 401 + + +# ─── Привязка Telegram ─────────────────────────────────────────────────────── + +def _link_telegram(client: TestClient, monkeypatch, **fields): + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + return client.post( + "/api/users/me/telegram", + json=_telegram_payload("TEST_BOT_TOKEN", **fields), + headers=csrf_headers(client), + ) + + +def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch): + """Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт.""" + uid = _register(client).json()["id"] + + r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg + assert r.status_code == 200, r.text + assert r.json()["telegram_id"] == 777 + assert r.json()["nickname"] == "Игрок" # ник не меняется на тег + + client.cookies.clear() + r2 = _telegram_login(client, monkeypatch) + assert r2.status_code == 200, r2.text + assert r2.json()["id"] == uid + assert r2.json()["nickname"] == "Игрок" + + +def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch): + assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то + client.cookies.clear() + _register(client) + + r = _link_telegram(client, monkeypatch) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "TELEGRAM_TAKEN" + + +def test_link_telegram_twice(client: TestClient, monkeypatch): + _register(client) + assert _link_telegram(client, monkeypatch).status_code == 200 + r = _link_telegram(client, monkeypatch, id=778) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED" + + +def test_link_telegram_bad_signature(client: TestClient, monkeypatch): + from app.core.config import settings + + _register(client) + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + payload = _telegram_payload("TEST_BOT_TOKEN") + payload["hash"] = "deadbeef" + r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client)) + assert r.status_code == 401 + assert client.get("/api/users/me").json()["telegram_id"] is None + + +# ─── Пароль игроку из админки ──────────────────────────────────────────────── + +def _admin_login(client: TestClient, make_admin): + make_admin("boss", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "boss", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin): + player = _telegram_login(client, monkeypatch).json() + client.cookies.clear() + _admin_login(client, make_admin) + + r = client.put( + f"/api/admin/users/{player['id']}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert r.json()["has_password"] is True + + client.cookies.clear() + assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200 + + +def test_admin_cannot_set_admin_password(client: TestClient, make_admin): + admin_id = _admin_login(client, make_admin) + r = client.put( + f"/api/admin/users/{admin_id}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 422 + + +def test_player_cannot_set_passwords_via_admin(client: TestClient): + uid = _register(client).json()["id"] + r = client.put( + f"/api/admin/users/{uid}/password", + json={"new_password": "from-admin-1"}, + headers=csrf_headers(client), + ) + assert r.status_code == 401