Files
ForbiddenStarsApp/backend/app/main.py
T
NotBigGhostandClaude Opus 5 cb3af484cd CSRF: перевыдавать токен, если сессия есть, а cookie нет
На проде любые изменения данных, включая выход из аккаунта, отвечали 403
CSRF_FAILED, хотя сайт оставался залогиненным. Cookie csrf_token одна на игрока
и админку: вход в админку перезаписывал её со сроком 8 часов, а сессия игрока
живёт 7 дней. Когда токен истекал, сервер выдавал новый только при входе, а войти
и выйти мешала та же проверка. Из этого состояния было не выбраться, кроме как
стереть cookie сайта руками.

Теперь CSRFMiddleware перевыдаёт токен на любом ответе /api, если запрос несёт
сессионную cookie без csrf_token, в том числе на самом отказе. SPA на загрузке
делает GET /api/users/me, поэтому пользователю хватает перезагрузить страницу.
Правится только стартовое сообщение ответа, тело идёт насквозь, и SSE-поток
не буферизуется. Срок токена теперь не короче самой долгой сессии, так что вход
в админку больше не укорачивает токен игрока.

Проверка double-submit не ослаблена: запрос с cookie, но без заголовка или с
чужим токеном по-прежнему получает 403, и cookie в этом случае не перевыдаётся.
Перевыданный токен из кросс-сайтового ответа атакующему ничего не даёт: прочитать
cookie может только JS того же origin.

Тесты закрепляют восстановление на GET и на отказе, срок после входа в админку,
прежнюю строгость проверки, отсутствие токена у анонимов и то, что middleware
не склеивает чанки потока. На старом коде четыре из них падают.

#50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
2026-09-13 13:14:14 +03:00

246 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Фабрика приложения FastAPI: API под /api + отдача собранного SPA."""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from app.core import security
from app.core.config import settings
from app.core.errors import AppError, app_error_handler
from app.routers import (
achievements,
admin,
auth,
events,
groups,
invitations,
matches,
notifications,
reference,
stats,
users,
)
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def _with_fresh_csrf_cookie(send): # noqa: ANN001, ANN202
"""Дописывает свежий csrf_token в заголовки ответа. Трогает только
http.response.start: тело (в т.ч. SSE-поток) проходит насквозь, чанк за чанком."""
async def wrapped(message): # noqa: ANN001
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
already_set = any(
k.lower() == b"set-cookie" and v.startswith(security.CSRF_COOKIE.encode() + b"=")
for k, v in headers
)
if not already_set:
headers.append(security.fresh_csrf_set_cookie())
message = {**message, "headers": headers}
await send(message)
return wrapped
class CSRFMiddleware:
"""Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем
совпадения заголовка X-CSRF-Token и cookie csrf_token.
Если сессия есть, а csrf_token в запросе нет (cookie истекла или её стёрли), любой ответ
на /api — включая отказ ниже — перевыдаёт токен. Иначе состояние не лечилось: токен
выдаётся только при входе, а войти и выйти мешала эта же проверка.
Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE
(/api/events). Чистый ASGI пропускает стримы насквозь.
"""
def __init__(self, app) -> None: # noqa: ANN001
self.app = app
async def __call__(self, scope, receive, send): # noqa: ANN001
if scope["type"] == "http":
request = Request(scope)
if request.url.path.startswith("/api"):
has_session = (
security.USER_COOKIE in request.cookies
or security.ADMIN_COOKIE in request.cookies
)
cookie_token = request.cookies.get(security.CSRF_COOKIE)
if has_session and not cookie_token:
send = _with_fresh_csrf_cookie(send)
if has_session and request.method in _UNSAFE_METHODS:
header_token = request.headers.get(security.CSRF_HEADER)
if not cookie_token or cookie_token != header_token:
response = JSONResponse(
status_code=403,
content={
"error": {
"code": "CSRF_FAILED",
"message": "Неверный или отсутствующий CSRF-токен.",
"details": None,
}
},
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления
async def _notifications_purge_loop() -> None:
"""Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы
удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении)."""
import asyncio
from app.db.session import Session, engine
from app.services import notification_service
def _purge_once() -> None:
with Session(engine) as session:
notification_service.purge_expired(session)
while True:
try:
await asyncio.sleep(_NOTIFICATIONS_PURGE_INTERVAL)
await asyncio.to_thread(_purge_once)
except asyncio.CancelledError:
break
except Exception as exc: # noqa: BLE001
logging.getLogger("fs").warning("Чистка уведомлений пропущена: %s", exc)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
import asyncio
from app.core.events import hub
hub.bind_loop(asyncio.get_running_loop())
# В DEV приложение само подтягивает справочники и админа из .env при старте
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
try:
from app.bootstrap import bootstrap
bootstrap()
except Exception as exc: # noqa: BLE001
logging.getLogger("fs").warning(
"Стартовый bootstrap пропущен (примените миграции): %s", exc
)
purge_task = asyncio.create_task(_notifications_purge_loop())
try:
yield
finally:
purge_task.cancel()
def create_app() -> FastAPI:
app = FastAPI(
title="Forbidden Stars API",
version="0.1.0",
openapi_url="/api/openapi.json",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=_lifespan,
)
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
if settings.is_development and settings.cors_origins_list:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(CSRFMiddleware)
# Обработчики ошибок → единый конверт.
app.add_exception_handler(AppError, app_error_handler)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=422,
content={
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации запроса.",
"details": exc.errors(),
}
},
)
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.router,
events.router, notifications.router, admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
# .dockerignore, импорт просто не выполнится).
if settings.is_development:
for mod_name in ("dev_auth", "dev_admin"):
try:
mod = __import__(f"app.routers.{mod_name}", fromlist=["router"])
app.include_router(mod.router, prefix="/api")
except ImportError:
pass
@app.get("/api/health", tags=["meta"])
def health() -> dict:
return {"status": "ok"}
_mount_spa(app)
return app
def _mount_spa(app: FastAPI) -> None:
"""Отдаём собранный SPA: статика + fallback на index.html для client-routes."""
index_file = _STATIC_DIR / "index.html"
if not index_file.exists():
return # в dev фронт обслуживает Vite на :5173
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str): # noqa: ANN202
# Неизвестный API-путь — это 404 (JSON), а не отдача SPA.
if full_path == "api" or full_path.startswith("api/"):
return JSONResponse(
status_code=404,
content={"error": {"code": "NOT_FOUND", "message": "Не найдено.", "details": None}},
)
candidate = (_STATIC_DIR / full_path).resolve()
if (
full_path
and _STATIC_DIR in candidate.parents
and candidate.is_file()
and candidate.name != "index.html"
):
# Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке).
return FileResponse(candidate)
# index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый
# документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой.
return FileResponse(index_file, headers={"Cache-Control": "no-store"})
app = create_app()