From 3f8667b561b3bca795bab8fdc55f1e8f812d5d19 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:09:57 +0300 Subject: [PATCH 1/7] =?UTF-8?q?=D0=9E=D1=82=D0=B7=D1=8B=D0=B2=20JWT=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logout отзывает предъявленный токен по jti (in-memory denylist до exp); смена и сброс пароля инкрементят users.token_version (claim ver в JWT, сверка в auth/deps) — все прежние сессии отзываются. Своё устройство при смене пароля остаётся в сессии (перевыдача cookie). Миграция 0013. #57 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- .../versions/0013_user_token_version.py | 44 ++++++++++++++ backend/app/auth/deps.py | 9 +++ backend/app/auth/login.py | 2 +- backend/app/core/security.py | 47 +++++++++++++-- backend/app/core/token_revocation.py | 53 +++++++++++++++++ backend/app/models.py | 7 +++ backend/app/routers/admin.py | 5 +- backend/app/routers/auth.py | 5 +- backend/app/routers/users.py | 8 ++- backend/app/services/user_service.py | 7 ++- backend/tests/conftest.py | 2 + backend/tests/test_password_auth.py | 59 +++++++++++++++++++ 12 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/0013_user_token_version.py create mode 100644 backend/app/core/token_revocation.py diff --git a/backend/alembic/versions/0013_user_token_version.py b/backend/alembic/versions/0013_user_token_version.py new file mode 100644 index 0000000..b509484 --- /dev/null +++ b/backend/alembic/versions/0013_user_token_version.py @@ -0,0 +1,44 @@ +"""Пользователь: версия сессий (token_version) для отзыва JWT. + +Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite). + +Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57). + +Revision ID: 0013_user_token_version +Revises: 0012_match_finish_draft +Create Date: 2026-09-13 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0013_user_token_version" +down_revision: Union[str, None] = "0012_match_finish_draft" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + with op.batch_alter_table("users") as b: + if "token_version" not in cols: + b.add_column( + sa.Column( + "token_version", sa.Integer(), nullable=False, server_default="0" + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + with op.batch_alter_table("users") as b: + if "token_version" in cols: + b.drop_column("token_version") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index 541fbd2..ddeadac 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -21,9 +21,14 @@ def get_current_user( payload = security.decode_token(token, security.AUDIENCE_USER) except jwt.PyJWTError as exc: # noqa: F841 raise AuthError("Сессия недействительна.") + if security.is_session_revoked(payload): + raise AuthError("Сессия недействительна.") user = session.get(User, int(payload["sub"])) if user is None or not user.is_active: raise AuthError("Сессия недействительна.") + if int(payload.get("ver", 0)) != int(user.token_version or 0): + # Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57). + raise AuthError("Сессия недействительна.") return user @@ -37,7 +42,11 @@ def get_current_admin( payload = security.decode_token(token, security.AUDIENCE_ADMIN) except jwt.PyJWTError: raise AuthError("Сессия администратора недействительна.") + if security.is_session_revoked(payload): + raise AuthError("Сессия администратора недействительна.") user = session.get(User, int(payload["sub"])) if user is None or user.role != "admin" or not user.is_active: raise ForbiddenError("Доступ только для администратора.") + if int(payload.get("ver", 0)) != int(user.token_version or 0): + raise AuthError("Сессия администратора недействительна.") return user diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index a69fe9e..2541851 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -22,7 +22,7 @@ def establish_session( """Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" if not user.is_active: raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") - security.set_user_session(response, user.id, provider) # type: ignore[arg-type] + security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type] audit_service.record( session, actor_id=user.id, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 5b118bf..fd456da 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -9,6 +9,7 @@ import jwt from fastapi import Request, Response from app.core.config import settings +from app.core.token_revocation import revoked_tokens USER_COOKIE = "fs_session" ADMIN_COOKIE = "fs_admin" @@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool: # ─── JWT ───────────────────────────────────────────────────────────────────── -def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str: +def create_token( + subject: str | int, + audience: str, + ttl_minutes: int, + provider: str = "", + token_version: int = 0, +) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(subject), @@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()), "jti": secrets.token_hex(8), "provider": provider, + # Версия сессий владельца: при её росте (смена/сброс пароля) старые токены + # с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57). + "ver": token_version, } return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) @@ -95,9 +105,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]: return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie") -def set_user_session(response: Response, user_id: int, provider: str) -> None: +def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None: ttl = settings.jwt_user_ttl_minutes - token = create_token(user_id, AUDIENCE_USER, ttl, provider) + token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version) response.set_cookie( key=USER_COOKIE, value=token, @@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None: _set_csrf_cookie(response) -def set_admin_session(response: Response, admin_id: int) -> None: +def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None: ttl = settings.jwt_admin_ttl_minutes - token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local") + token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version) response.set_cookie( key=ADMIN_COOKIE, value=token, @@ -135,6 +145,33 @@ def clear_admin_session(response: Response) -> None: response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value) +def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None: + """Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout. + + Убивает именно этот токен (украденный/оставленный), не трогая другие устройства. + Некорректный/просроченный токен отзывать нечего — молча выходим.""" + token = request.cookies.get(cookie_name) + if not token: + return + try: + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.jwt_algorithm], + audience=audience, + ) + except jwt.PyJWTError: + return + exp = payload.get("exp") + if exp is not None: + revoked_tokens.revoke(payload.get("jti"), float(exp)) + + +def is_session_revoked(payload: dict) -> bool: + """Отозван ли этот токен точечно (через logout).""" + return revoked_tokens.is_revoked(payload.get("jti")) + + def client_ip(request: Request) -> str | None: """IP клиента для журнала аудита. diff --git a/backend/app/core/token_revocation.py b/backend/app/core/token_revocation.py new file mode 100644 index 0000000..ad4597d --- /dev/null +++ b/backend/app/core/token_revocation.py @@ -0,0 +1,53 @@ +"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена). + +Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn. +Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый +процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля +используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список. +""" +from __future__ import annotations + +import threading +import time + +# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память. +_PRUNE_ABOVE = 10_000 + + +class RevokedTokens: + """Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT).""" + + def __init__(self) -> None: + self._revoked: dict[str, float] = {} + self._lock = threading.Lock() + + def revoke(self, jti: str | None, expires_at: float) -> None: + if not jti: + return + now = time.time() + with self._lock: + if len(self._revoked) > _PRUNE_ABOVE: + for key, exp in list(self._revoked.items()): + if exp <= now: + del self._revoked[key] + self._revoked[jti] = expires_at + + def is_revoked(self, jti: str | None) -> bool: + if not jti: + return False + now = time.time() + with self._lock: + exp = self._revoked.get(jti) + if exp is None: + return False + if exp <= now: + del self._revoked[jti] # протухла — заодно вычищаем + return False + return True + + def clear(self) -> None: + with self._lock: + self._revoked.clear() + + +revoked_tokens = RevokedTokens() diff --git a/backend/app/models.py b/backend/app/models.py index 993d68e..4c5d45d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -93,6 +93,13 @@ class User(SQLModel, table=True): sa_column=Column(String(16), nullable=False, server_default="stub"), ) password_hash: str | None = Field(sa_column=Column(String(255), nullable=True)) + # Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя + # (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля + # и сбросе пароля админом — компрометация или утечка токена так прекращается (#57). + token_version: int = Field( + default=0, + sa_column=Column(Integer, nullable=False, server_default="0"), + ) active_group_id: int | None = Field( sa_column=Column( Integer, diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 0448c95..06aa7e3 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -41,7 +41,7 @@ def admin_login( session: Session = Depends(get_session), ) -> s.AdminMe: admin = admin_service.authenticate_admin(session, body.username, body.password) - security.set_admin_session(response, admin.id) # type: ignore[arg-type] + security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type] audit_service.record( session, actor_id=admin.id, @@ -56,7 +56,8 @@ def admin_login( @router.post("/auth/logout", response_model=s.OkResponse) -def admin_logout(response: Response) -> s.OkResponse: +def admin_logout(request: Request, response: Response) -> s.OkResponse: + security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN) security.clear_admin_session(response) return s.OkResponse() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5508402..acc9d87 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -116,6 +116,9 @@ def telegram_register( @router.post("/logout", response_model=s.OkResponse) -def logout(response: Response) -> s.OkResponse: +def logout(request: Request, response: Response) -> s.OkResponse: + # Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная + # cookie перестаёт работать сразу, а не живёт до конца TTL (#57). + security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER) security.clear_user_session(response) return s.OkResponse() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 648b056..b5298fb 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,14 +1,14 @@ """Роутер текущего пользователя.""" from __future__ import annotations -from fastapi import APIRouter, Depends, File, Query, Request, UploadFile +from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile 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.security import client_ip, set_user_session from app.core.errors import NotFoundError from app.db.session import get_session from app.models import User @@ -122,6 +122,7 @@ def update_my_profile( def change_my_password( body: s.PasswordChange, request: Request, + response: Response, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MeRead: @@ -130,6 +131,9 @@ def change_my_password( if had_password: check_current_password(user, body.current_password) user_service.set_password(session, user, body.new_password) + # set_password инкрементит token_version → все ранее выданные токены отозваны (#57). + # Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные. + set_user_session(response, user.id, user.auth_provider, user.token_version) audit_service.record( session, actor_id=user.id, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index b51bc1e..8763606 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -161,9 +161,14 @@ def register_local(session: Session, nickname: str, password: str) -> User: def set_password(session: Session, user: User, new_password: str) -> User: - """Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).""" + """Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет). + + Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля + игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника). + Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver.""" validate_new_password(new_password) user.password_hash = hash_password(new_password) + user.token_version = (user.token_version or 0) + 1 session.add(user) session.commit() session.refresh(user) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8d51281..5f25d20 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -20,6 +20,7 @@ from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 import app.models # noqa: F401,E402 (регистрация моделей) from app.core.ratelimit import login_throttle # noqa: E402 from app.core.security import hash_password # noqa: E402 +from app.core.token_revocation import revoked_tokens # noqa: E402 from app.db.session import get_session # noqa: E402 from app.main import app # noqa: E402 from app.models import AuthIdentity, GroupMember, User # noqa: E402 @@ -50,6 +51,7 @@ def client(engine): app.dependency_overrides[get_session] = _get_session login_throttle.clear() # счётчики неудачных входов глобальны для процесса + revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index 1896b32..be34705 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -4,6 +4,7 @@ from __future__ import annotations from fastapi.testclient import TestClient from sqlmodel import Session, select +from app.main import app from app.models import User from tests.conftest import csrf_headers from tests.test_auth import _telegram_payload @@ -251,6 +252,64 @@ def test_set_password_requires_session(client: TestClient): assert _set_password(client, PASSWORD).status_code == 401 +# ─── Отзыв токена: logout и смена пароля (#57, F2) ──────────────────────────── + +def _me_with_token(cookie_name: str, token: str): + """Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie).""" + return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"}) + + +def test_logout_revokes_presented_token(client: TestClient): + _register(client) + tok = client.cookies.get("fs_session") + assert _me_with_token("fs_session", tok).status_code == 200 # пока жив + + assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200 + # тот же токен, предъявленный после выхода, больше не принимается + assert _me_with_token("fs_session", tok).status_code == 401 + + +def test_logout_does_not_revoke_other_devices(client: TestClient): + _register(client) # устройство A + tok_a = client.cookies.get("fs_session") + # устройство B: независимый вход тем же аккаунтом (свой jti) + b = TestClient(app) + assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200 + tok_b = b.cookies.get("fs_session") + assert tok_a and tok_b and tok_a != tok_b + + assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200 + assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел + assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут + + +def test_password_change_revokes_old_sessions_keeps_current(client: TestClient): + _register(client) + old = client.cookies.get("fs_session") + assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200 + # это устройство осталось в сессии (cookie перевыдан со свежим ver) + assert client.get("/api/users/me").status_code == 200 + # старый токен (другое устройство/утёкший) отозван инкрементом token_version + assert _me_with_token("fs_session", old).status_code == 401 + + +def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin): + player = _telegram_login(client, monkeypatch).json() + stolen = client.cookies.get("fs_session") # действующая сессия игрока + assert _me_with_token("fs_session", stolen).status_code == 200 + + 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 _me_with_token("fs_session", stolen).status_code == 401 + + # ─── Привязка Telegram ─────────────────────────────────────────────────────── def _link_telegram(client: TestClient, monkeypatch, **fields): -- 2.54.0 From 706eeb0af1ad0d92f134122a8a00626133469833 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:12:40 +0300 Subject: [PATCH 2/7] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20(throttle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin_login проходит через LoginThrottle (пара IP+логин, IP и сам аккаунт), как вход игрока; исчерпание лимита -> 429. Неудачные попытки пишутся в аудит (без пароля). Пароль админа — прямой путь к полному контролю, лимиты строже. #56 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/admin_login.py | 49 +++++++++++++++++++++++++++++ backend/app/routers/admin.py | 20 ++++++++++-- backend/tests/test_password_auth.py | 38 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 backend/app/auth/admin_login.py diff --git a/backend/app/auth/admin_login.py b/backend/app/auth/admin_login.py new file mode 100644 index 0000000..7e1d0d9 --- /dev/null +++ b/backend/app/auth/admin_login.py @@ -0,0 +1,49 @@ +"""Вход администратора под защитой от перебора (#56). + +Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин» +и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`). +Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный +барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого. +""" +from __future__ import annotations + +from sqlmodel import Session + +from app.core.errors import InvalidCredentialsError +from app.core.ratelimit import login_throttle +from app.models import User +from app.services import admin_service + +# Неудач за окно LoginThrottle (15 минут): +_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса +_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса +# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно +# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход +# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий). +_ACCOUNT_LIMIT = 50 + + +def _keys(ip: str, username: str) -> dict[str, int]: + pair_key = f"admin-login:{ip}:{username.casefold()}" + return { + pair_key: _PAIR_LIMIT, + f"admin-login-ip:{ip}": _IP_LIMIT, + f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT, + } + + +def login_admin(session: Session, username: str, password: str, ip: str | None) -> User: + """authenticate_admin под защитой от перебора. Сессию открывает вызывающий.""" + username = (username or "").strip() + ip = ip or "unknown" + limits = _keys(ip, username) + login_throttle.check(limits) + try: + admin = admin_service.authenticate_admin(session, username, password) + except InvalidCredentialsError: + login_throttle.fail(limits) + raise + # Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит. + for key in limits: + login_throttle.reset(key) + return admin diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 06aa7e3..a3e5314 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi from fastapi.responses import FileResponse from sqlmodel import Session +from app.auth.admin_login import login_admin from app.auth.deps import get_current_admin from app.core import security from app.core.security import client_ip -from app.core.errors import NotFoundError +from app.core.errors import InvalidCredentialsError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User @@ -40,7 +41,22 @@ def admin_login( response: Response, session: Session = Depends(get_session), ) -> s.AdminMe: - admin = admin_service.authenticate_admin(session, body.username, body.password) + try: + admin = login_admin(session, body.username, body.password, client_ip(request)) + except InvalidCredentialsError: + # Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к + # полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56). + audit_service.record( + session, + actor_id=None, + action="login_failed", + entity_type="admin", + payload={"username": (body.username or "")[:64]}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + session.commit() + raise security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type] audit_service.record( session, diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index be34705..b5ad43a 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -417,3 +417,41 @@ def test_player_cannot_set_passwords_via_admin(client: TestClient): headers=csrf_headers(client), ) assert r.status_code == 401 + + +# ─── Защита от перебора пароля администратора (#56, F1) ─────────────────────── + +def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch): + import app.core.ratelimit as ratelimit + + now = [2000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + make_admin("boss", "secret123") + + for _ in range(5): + r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"}) + assert r.status_code == 401 + + blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"}) + assert blocked.status_code == 429 # даже верный пароль не проверяется + assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS" + + now[0] += 15 * 60 # окно истекло + ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"}) + assert ok.status_code == 200 + + +def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine): + from app.models import AuditLog + + make_admin("boss", "secret123") + assert client.post( + "/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"} + ).status_code == 401 + + with Session(engine) as s: + logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all() + assert any( + log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs + ) + assert all("nope-secret-guess" not in str(log.payload) for log in logs) -- 2.54.0 From 038a788f993caf8ec9a9d1231ffb5a3b8ab2fd50 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:15:04 +0300 Subject: [PATCH 3/7] =?UTF-8?q?Throttle:=20IP-=D0=BD=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=BB=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BD=D0=B0=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D1=82=20+=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D0=B1=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вход игрока и админа получают лимит login-user/admin-login-user, не зависящий от IP: ротация X-Forwarded-For (#58) больше не снимает защиту полностью. Успешный вход сбрасывает счётчики аккаунта. В docstring ratelimit — про сброс при рестарте и необходимость внешнего стора при нескольких воркерах. #60 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 11 ++++++++++- backend/app/core/ratelimit.py | 13 +++++++++++-- backend/tests/test_password_auth.py | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index b40058b..7c60e8f 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -27,6 +27,11 @@ _BCRYPT_MAX_BYTES = 72 # одного аккаунта; на IP — против перебора по многим логинам с одного адреса. _PAIR_LIMIT = 5 _IP_LIMIT = 20 +# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно +# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал +# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий); +# успешный вход его сбрасывает. +_ACCOUNT_LIMIT = 50 # Неверный текущий пароль при смене — на аккаунт. _CURRENT_PASSWORD_LIMIT = 5 @@ -67,14 +72,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None) 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} + account_key = f"login-user:{nickname.casefold()}" + limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT} login_throttle.check(limits) try: user = authenticate_player(session, nickname, password) except InvalidCredentialsError: login_throttle.fail(limits) raise + # Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP + # оставляем — он общий для всех логинов с адреса. login_throttle.reset(pair_key) + login_throttle.reset(account_key) return user diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py index 0a6176a..941b5de 100644 --- a/backend/app/core/ratelimit.py +++ b/backend/app/core/ratelimit.py @@ -1,7 +1,16 @@ """Ограничение неудачных попыток ввода пароля (защита от перебора). -Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn. -Перезапуск их обнуляет; для окна в 15 минут это приемлемо. +Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn +(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это +приемлемо. Ограничения устойчивости (#60): + * рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после + перезапуска, но окно короткое, а рестарты редки; + * при уходе от одного воркера лимит делится между процессами (каждый считает своё) — + тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md + про SSE-шину), общий для всех воркеров. +Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт +(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов +(#58) не снимала защиту полностью. """ from __future__ import annotations diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index b5ad43a..a49905c 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -1,6 +1,7 @@ """Вход по логину (нику) и паролю: регистрация, вход, защита от перебора.""" from __future__ import annotations +import pytest from fastapi.testclient import TestClient from sqlmodel import Session, select @@ -181,6 +182,26 @@ def test_throttle_per_ip_across_logins(client: TestClient): assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429 +def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch): + """Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в + IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся.""" + import app.core.ratelimit as ratelimit + from app.auth.password import _ACCOUNT_LIMIT, login_player + from app.core.errors import InvalidCredentialsError, TooManyAttemptsError + + now = [3000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + _register(client, nickname="Жертва", password=PASSWORD) + + with Session(engine) as s: + for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес + with pytest.raises(InvalidCredentialsError): + login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}") + # ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт + with pytest.raises(TooManyAttemptsError): + login_player(s, "Жертва", "wrong-password", ip="203.0.113.7") + + # ─── Установка и смена пароля ──────────────────────────────────────────────── def _set_password(client: TestClient, new: str, current: str | None = None): -- 2.54.0 From 112b583264959d77ceecc4b7330dde9c31ad86bc Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:16:49 +0300 Subject: [PATCH 4/7] =?UTF-8?q?=D0=A0=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F:=20throttle=20=D0=BF=D0=BE=20IP=20?= =?UTF-8?q?=D0=BE=D1=82=20=D1=81=D0=BF=D0=B0=D0=BC=D0=B0=20=D0=B0=D0=BA?= =?UTF-8?q?=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit throttle_register ограничивает частоту POST /api/auth/register с одного IP через тот же LoginThrottle (считаются все попытки). Enumeration ников через 409 не закрываем — ники и так публичны в топе (отмечено в отчёте). #62 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 14 ++++++++++++++ backend/app/routers/auth.py | 3 ++- backend/tests/test_password_auth.py | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index 7c60e8f..6e09643 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -34,6 +34,8 @@ _IP_LIMIT = 20 _ACCOUNT_LIMIT = 50 # Неверный текущий пароль при смене — на аккаунт. _CURRENT_PASSWORD_LIMIT = 5 +# Регистраций с одного IP за окно — против спама аккаунтов (#62). +_REGISTER_IP_LIMIT = 10 def validate_new_password(password: str) -> None: @@ -87,6 +89,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None) return user +def throttle_register(ip: str | None) -> None: + """Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62). + + Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов + с уникальными никами упирается в предел так же, как повторы по занятому нику. + Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе.""" + ip = ip or "unknown" + limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT} + login_throttle.check(limits) + login_throttle.fail(limits) + + def check_current_password(user: User, current_password: str | None) -> None: """Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя: иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем.""" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index acc9d87..a9fd310 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -8,7 +8,7 @@ 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.password import login_player, throttle_register from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security @@ -39,6 +39,7 @@ def password_register( session: Session = Depends(get_session), ) -> s.MeRead: """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" + throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62) user = user_service.register_local(session, body.nickname, body.password) audit_service.record( session, diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index a49905c..65c78ce 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -65,6 +65,27 @@ def test_register_rejects_bad_nickname(client: TestClient): assert _register(client, nickname="x").status_code == 422 +def test_register_is_throttled_per_ip(client: TestClient, monkeypatch): + """Спам регистраций с одного IP упирается в лимит (#62).""" + import app.core.ratelimit as ratelimit + from app.auth.password import _REGISTER_IP_LIMIT + + now = [4000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + + for i in range(_REGISTER_IP_LIMIT): + client.cookies.clear() + assert _register(client, nickname=f"Ник{i}").status_code == 200 + client.cookies.clear() + blocked = _register(client, nickname="Лишний") + assert blocked.status_code == 429 + assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS" + + now[0] += 15 * 60 # окно истекло + client.cookies.clear() + assert _register(client, nickname="ПослеОкна").status_code == 200 + + # ─── Вход ──────────────────────────────────────────────────────────────────── def test_login_after_logout(client: TestClient): -- 2.54.0 From 0799aee684d6ff9bb655a870eb1ca1df07cc0482 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:18:08 +0300 Subject: [PATCH 5/7] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=84=D0=B8=D0=B3:=20fai?= =?UTF-8?q?l-fast=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D1=85=20=D0=B2=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model_validator в Settings: при APP_ENV=production приложение не стартует, если SECRET_KEY дефолтный/короче 32 символов или (при включённом бутстрапе) ADMIN_PASSWORD дефолтный/пустой. dev/test не затронуты — там дефолты остаются нормой. #59 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/core/config.py | 36 +++++++++++++- backend/tests/test_config_security.py | 67 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_config_security.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e73c3aa..0085fbe 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,6 +4,7 @@ from __future__ import annotations from functools import lru_cache from pathlib import Path +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково @@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # (исключён из образа) — там настройки приходят переменными от docker compose. _ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") +# Небезопасные значения по умолчанию (годятся только для dev). В production приложение +# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59). +_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production" +_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password" +_MIN_SECRET_KEY_LENGTH = 32 + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -50,7 +57,7 @@ class Settings(BaseSettings): prod_achievements_dir: str = "/data/achievements" # JWT / cookie - secret_key: str = "change-me-dev-secret-not-for-production" + secret_key: str = _DEFAULT_SECRET_KEY jwt_algorithm: str = "HS256" jwt_user_ttl_minutes: int = 60 * 24 * 7 jwt_admin_ttl_minutes: int = 60 * 8 @@ -66,7 +73,7 @@ class Settings(BaseSettings): # Бутстрап администратора admin_bootstrap_enabled: bool = True admin_username: str = "admin" - admin_password: str = "change-me-admin-password" + admin_password: str = _DEFAULT_ADMIN_PASSWORD admin_nickname: str = "Администратор" # CORS (для раздельного dev-режима фронта). Строка из env, через запятую — @@ -115,6 +122,31 @@ class Settings(BaseSettings): def cookie_domain_value(self) -> str | None: return self.cookie_domain or None + @model_validator(mode="after") + def _forbid_default_secrets_in_prod(self) -> "Settings": + """Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59). + + Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы + с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и + известным паролем администратора. В dev/test проверка не мешает — там дефолты норма.""" + if self.app_env.lower() != "production": + return self + problems: list[str] = [] + if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH: + problems.append( + f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов" + ) + if self.admin_bootstrap_enabled: + password = (self.admin_password or "").strip() + if not password or password == _DEFAULT_ADMIN_PASSWORD: + problems.append("ADMIN_PASSWORD не задан или дефолтный") + if problems: + raise ValueError( + "Небезопасная конфигурация production — задайте секреты в .env: " + + "; ".join(problems) + ) + return self + @lru_cache def get_settings() -> Settings: diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py new file mode 100644 index 0000000..8c73066 --- /dev/null +++ b/backend/tests/test_config_security.py @@ -0,0 +1,67 @@ +"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4).""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.core import config + +_STRONG_SECRET = "k" * 40 +_STRONG_ADMIN_PW = "a-strong-admin-password" + + +def test_production_rejects_default_secret_key(): + with pytest.raises(ValidationError): + config.Settings( + app_env="production", + secret_key=config._DEFAULT_SECRET_KEY, + admin_password=_STRONG_ADMIN_PW, + ) + + +def test_production_rejects_short_secret_key(): + with pytest.raises(ValidationError): + config.Settings( + app_env="production", + secret_key="too-short", + admin_password=_STRONG_ADMIN_PW, + ) + + +def test_production_rejects_default_admin_password(): + with pytest.raises(ValidationError): + config.Settings( + app_env="production", + secret_key=_STRONG_SECRET, + admin_bootstrap_enabled=True, + admin_password=config._DEFAULT_ADMIN_PASSWORD, + ) + + +def test_production_accepts_strong_secrets(): + s = config.Settings( + app_env="production", + secret_key=_STRONG_SECRET, + admin_password=_STRONG_ADMIN_PW, + ) + assert s.is_production + + +def test_production_skips_admin_check_when_bootstrap_disabled(): + # Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт. + s = config.Settings( + app_env="production", + secret_key=_STRONG_SECRET, + admin_bootstrap_enabled=False, + admin_password=config._DEFAULT_ADMIN_PASSWORD, + ) + assert s.is_production + + +def test_development_allows_defaults(): + s = config.Settings( + app_env="development", + secret_key=config._DEFAULT_SECRET_KEY, + admin_password=config._DEFAULT_ADMIN_PASSWORD, + ) + assert s.is_development -- 2.54.0 From ce7fbad58ed2d2534fac8267ea136dded71b366b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:19:42 +0300 Subject: [PATCH 6/7] =?UTF-8?q?API:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D1=85=D0=B5=D0=BC=D1=83=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=20+=20security-=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BD=D0=B0=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.py: openapi.json/docs/redoc отдаются только в dev/test (нужны для gen:api), в production отключены. Caddyfile (edge): HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, скрыт Server; CSP подготовлена, но выключена до проверки на test-клоне (строгая политика ломает SPA/Telegram-виджет/SSE). #61 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/main.py | 10 +++++++--- backend/tests/test_api_hardening.py | 22 ++++++++++++++++++++++ deploy/vps/Caddyfile | 21 ++++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_api_hardening.py diff --git a/backend/app/main.py b/backend/app/main.py index d58e61b..b91cb9c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI): def create_app() -> FastAPI: + # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для + # `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем — + # незачем облегчать разведку поверхности API анонимам (#61). + docs_enabled = not settings.is_production app = FastAPI( title="Forbidden Stars API", version="0.1.0", - openapi_url="/api/openapi.json", - docs_url="/api/docs", - redoc_url="/api/redoc", + openapi_url="/api/openapi.json" if docs_enabled else None, + docs_url="/api/docs" if docs_enabled else None, + redoc_url="/api/redoc" if docs_enabled else None, lifespan=_lifespan, ) diff --git a/backend/tests/test_api_hardening.py b/backend/tests/test_api_hardening.py new file mode 100644 index 0000000..4de3185 --- /dev/null +++ b/backend/tests/test_api_hardening.py @@ -0,0 +1,22 @@ +"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.core import config +from app.main import create_app + + +def test_openapi_open_in_development(client: TestClient): + # Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`. + assert client.get("/api/openapi.json").status_code == 200 + assert client.get("/api/docs").status_code == 200 + + +def test_openapi_closed_in_production(monkeypatch): + monkeypatch.setattr(config.settings, "app_env", "production") + prod_app = create_app() + c = TestClient(prod_app) + assert c.get("/api/openapi.json").status_code == 404 + assert c.get("/api/docs").status_code == 404 + assert c.get("/api/redoc").status_code == 404 diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index e469638..09cc82c 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -18,8 +18,27 @@ # иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит. # Файл заглушки — deploy/vps/maintenance.html. -# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима. +# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима. (edge) { + # Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff — + # запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить + # в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не + # светить используемый прокси. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + -Server + } + + # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая + # политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с + # telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать + # после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61). + # header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый # SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно. # Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно. -- 2.54.0 From ec65f104ee47418143c31192544360bd20e1c867 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:21:27 +0300 Subject: [PATCH 7/7] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=BA=D1=81=D0=B8-=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8:=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20X-Forwarded-For?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entrypoint.sh: --forwarded-allow-ips сужен с "*" до loopback + приватных сетей compose (переопределяемо FORWARDED_ALLOW_IPS) — uvicorn сканирует XFF справа и берёт реальный адрес, подставленное клиентом левое значение игнорируется. Caddyfile: reverse_proxy перезаписывает X-Forwarded-For реальным пиром (header_up {remote_host}) вместо добавления. Итог — достоверный IP для throttle и аудита. Инфра-часть проверяется на test-клоне. #58 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/entrypoint.sh | 12 ++++++++++-- deploy/vps/Caddyfile | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 3425430..0407db9 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -9,8 +9,16 @@ python -m app.bootstrap echo "[entrypoint] Запуск сервера…" # --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS): -# чтобы приложение знало, что снаружи запрос пришёл по HTTPS. +# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент. +# +# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For, +# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только +# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback +# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес, +# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит. +# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается. # --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита # остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown. +FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \ - --proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10 + --proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10 diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index 09cc82c..c4f4f7f 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -5,8 +5,13 @@ # forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST # # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к -# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host — +# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host — # приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile. +# +# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не +# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для +# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это +# делает клиентский IP достоверным. # Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом). # # SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/ @@ -65,11 +70,14 @@ forbiddenstars.ru { handle @sse { reverse_proxy 127.0.0.1:9000 { flush_interval -1 + header_up X-Forwarded-For {remote_host} } } handle { encode zstd gzip - reverse_proxy 127.0.0.1:9000 + reverse_proxy 127.0.0.1:9000 { + header_up X-Forwarded-For {remote_host} + } } import edge } @@ -80,11 +88,14 @@ forbidden-stars.ru { handle @sse { reverse_proxy 127.0.0.1:9001 { flush_interval -1 + header_up X-Forwarded-For {remote_host} } } handle { encode zstd gzip - reverse_proxy 127.0.0.1:9001 + reverse_proxy 127.0.0.1:9001 { + header_up X-Forwarded-For {remote_host} + } } import edge } -- 2.54.0