fix(api): security, caching, atomic rate limits, url obfuscation

Redis:
- Singleton ConnectionPool (redis.asyncio), 50 connections — не создаём
  новое TCP-соединение на каждый HTTP-запрос

Rate limiter:
- Полностью переписан на async/await
- Lua-скрипт _LUA_CHECK_AND_INCR — атомарная проверка+инкремент без race condition
- Lua-скрипт _LUA_ACQUIRE_CONCURRENT — атомарный захват слота задачи
- Старый паттерн INCR→check→DECR удалён (race condition при конкурентных запросах)

Security:
- get_current_user кэширует пользователя в Redis на 5 минут (TTL)
  Раньше: SELECT users на каждый HTTP-запрос
  Теперь: Redis GET (кэш) → SELECT users (только при промахе)
- hashed_password НЕ кладётся в кэш
- invalidate_user_cache() для сброса при смене тарифа/пароля
- get_ws_user() для WebSocket через ?token=JWT (браузеры не могут
  передавать Authorization header при WS-handshake)

WebSocket:
- Добавлена аутентификация (Depends(get_ws_user))
- Проверка ownership задачи ДО accept() соединения
- Чужой task_id → закрытие с кодом 4004

URL obfuscation:
- Task.public_id = secrets.token_urlsafe(16) = 22 случайных base64url символа
- Клиент работает только с public_id, внутренний UUID не раскрывается
- Все роутеры переключены на public_id в WHERE условиях
- TaskResponse больше не возвращает input_data (там minio_key и т.д.)
- Миграция 002_add_task_public_id.py

MinIO:
- Singleton клиент (не создаём новый на каждый upload)
- ensure_bucket() вызывается один раз при старте (lifespan), не на каждый запрос
- Путь uploads/{doc_uuid}{ext} — user_id убран из пути

CORS:
- Убраны wildcard allow_methods/allow_headers (несовместимы с credentials=True)
- Явный список: methods=[GET,POST,DELETE,OPTIONS], headers=[Authorization,Content-Type,Accept]
- Swagger/OpenAPI доступны только в ENVIRONMENT=development

Documents:
- Content-Length проверяется ДО чтения тела (ранняя отбивка больших файлов)
- Повторная проверка реального размера после чтения (защита от поддельного заголовка)
- Используем get_current_verified_user вместо get_current_user (требуем подтверждённый email)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-05-24 19:51:51 +05:00
parent 7758315632
commit c1bfb5f40e
12 changed files with 493 additions and 317 deletions

View File

@@ -1,113 +1,135 @@
"""Точка входа FastAPI приложения — Академический помощник (anti-plagiarism)."""
"""Точка входа FastAPI приложения — Академический помощник."""
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import auth, documents, reports, search, tasks
from app.config import settings
from app.core.minio_client import ensure_bucket
from app.core.redis_client import close_pool
from app.core.security import get_ws_user
from app.core.websocket_manager import ws_manager
from app.database import engine
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""
Lifecycle менеджер приложения.
Выполняет инициализацию при старте и очистку при остановке.
"""
"""Инициализация при старте, очистка при остановке."""
logger.info("Запуск Академического помощника...")
# Проверка соединений при старте
# Проверяем PostgreSQL
try:
import sqlalchemy
async with engine.connect() as conn:
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
logger.info("PostgreSQL: соединение установлено")
await conn.execute(sqlalchemy.text("SELECT 1"))
logger.info("PostgreSQL: OK")
except Exception as e:
logger.error(f"PostgreSQL: ошибка соединения: {e}")
logger.error(f"PostgreSQL: {e}")
# Инициализируем MinIO бакеты (один раз при старте, не на каждый запрос)
try:
ensure_bucket(settings.MINIO_BUCKET_DOCS)
ensure_bucket(settings.MINIO_BUCKET_BACKUPS)
logger.info("MinIO: бакеты готовы")
except Exception as e:
logger.error(f"MinIO: {e}")
yield
# Очистка при остановке
logger.info("Остановка приложения...")
await engine.dispose()
await close_pool()
app = FastAPI(
title="Академический помощник API",
description=(
"Сервис антиплагиата и поиска академических источников. "
"Семантический поиск через FAISS GPU + Elasticsearch, "
"проверка плагиата в 4 уровня, ГОСТ библиография."
),
version="0.1.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
docs_url="/api/docs" if settings.ENVIRONMENT == "development" else None,
redoc_url=None,
openapi_url="/api/openapi.json" if settings.ENVIRONMENT == "development" else None,
)
# ─── CORS ─────────────────────────────────────────────────────────────────────
# allow_credentials=True несовместим с allow_origins=["*"].
# Указываем явные origins. Wildcard заголовки/методы запрещены с credentials.
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept"],
)
# ─── Роутеры ──────────────────────────────────────────────────────────────────
app.include_router(auth.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(search.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(search.router, prefix="/api")
app.include_router(documents.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
# ─── WebSocket ────────────────────────────────────────────────────────────────
@app.websocket("/ws/tasks/{task_id}")
async def websocket_task_updates(websocket: WebSocket, task_id: str) -> None:
@app.websocket("/ws/tasks/{public_id}")
async def websocket_task_updates(
websocket: WebSocket,
public_id: str,
# Аутентификация через ?token=JWT (браузеры не могут передавать Authorization header в WS)
current_user=Depends(get_ws_user),
) -> None:
"""
WebSocket endpoint для real-time обновлений статуса задачи.
Подписка на обновления задачи в реальном времени.
Клиент подключается и получает обновления при изменении статуса задачи.
Соединение закрывается при получении статуса done/failed.
Подключение: ws://host/ws/tasks/{public_id}?token=<JWT>
Пользователь получает только обновления СВОИХ задач —
ownership проверяется до установки соединения.
"""
await ws_manager.connect(task_id, websocket)
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.task import Task
# Проверяем что задача принадлежит этому пользователю
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Task).where(
Task.public_id == public_id,
Task.user_id == current_user.id,
)
)
task = result.scalar_one_or_none()
if task is None:
await websocket.close(code=4004, reason="Task not found or access denied")
return
await ws_manager.connect(public_id, websocket)
try:
while True:
# Ждём входящих сообщений (ping/pong для поддержания соединения)
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
ws_manager.disconnect(task_id, websocket)
logger.info(f"WebSocket клиент отключился от задачи {task_id!r}")
ws_manager.disconnect(public_id, websocket)
# ─── Health check ──────────────────────────────────────────────────────────────
# ─── Health ────────────────────────────────────────────────────────────────────
@app.get("/health", tags=["monitoring"])
async def health_check() -> JSONResponse:
"""Проверка работоспособности сервиса."""
return JSONResponse(
content={
"status": "ok",
"service": "api",
"version": "0.1.0",
}
)
return JSONResponse({"status": "ok", "version": "0.1.0"})
@app.get("/", include_in_schema=False)
async def root() -> JSONResponse:
"""Корневой endpoint — редирект к документации."""
return JSONResponse(
content={"message": "Академический помощник API", "docs": "/api/docs"}
)
return JSONResponse({"message": "Академический помощник API"})