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:
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user