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,18 +1,18 @@
"""Роутер загрузки документов на проверку плагиата."""
"""Загрузка документов на проверку плагиата."""
import io
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from minio import Minio
from minio.error import S3Error
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.celery_app import celery_app
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
from app.core.security import get_current_user
from app.core.minio_client import get_minio
from app.core.rate_limiter import acquire_concurrent_slot, check_and_increment_limit
from app.core.security import get_current_verified_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
@@ -22,102 +22,104 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/documents", tags=["documents"])
# Допустимые форматы
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
MAX_FILE_SIZE_MB = 100
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 # 100 МБ
def get_minio_client() -> Minio:
"""Создать MinIO клиент."""
return Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=False,
)
CONTENT_TYPE_MAP = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
}
@router.post("/check", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED)
async def upload_for_plagiarism_check(
file: UploadFile = File(..., description="PDF, DOCX или TXT файл до 100 МБ"),
current_user: User = Depends(get_current_user),
request: Request,
file: UploadFile,
current_user: User = Depends(get_current_verified_user),
db: AsyncSession = Depends(get_db),
) -> TaskResponse:
"""
Загрузить документ для проверки на плагиат.
Загрузить документ на проверку плагиата.
Файл сохраняется в MinIO, затем диспатчится задача index.extract_and_check.
Результат доступен через GET /tasks/{task_id}.
Файл сохраняется в MinIO. В пути используется UUID документа,
не ID пользователя (пользователь не должен знать свой internal ID).
"""
# Проверить расширение файла
# ── 1. Проверяем заголовки ДО чтения тела ─────────────────────────────────
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя файла не указано",
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Имя файла не указано")
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Неподдерживаемый формат файла. Допустимые форматы: {', '.join(ALLOWED_EXTENSIONS)}",
detail=f"Неподдерживаемый формат. Допустимые: {', '.join(ALLOWED_EXTENSIONS)}",
)
# Проверить лимит по тарифу
limit_check = check_and_increment_limit(current_user.id, "plagiarism", current_user.plan)
if not limit_check["allowed"]:
# Content-Length позволяет отклонить слишком большой файл до его чтения в память.
# Это не 100% защита (заголовок можно не передать), поэтому проверяем ещё раз после чтения.
content_length = request.headers.get("content-length")
if content_length and int(content_length) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Файл слишком большой (максимум {MAX_FILE_SIZE_BYTES // 1024 // 1024} МБ)",
)
# ── 2. Rate limits ─────────────────────────────────────────────────────────
limit_result = await check_and_increment_limit(current_user.id, "plagiarism", current_user.plan)
if not limit_result["allowed"]:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Превышен месячный лимит проверок плагиата для тарифа '{current_user.plan}'. "
f"Использовано {limit_check['current']} из {limit_check['limit']}."
f"Превышен месячный лимит проверок (тариф '{current_user.plan}'): "
f"{limit_result['current']}/{limit_result['limit']}. "
f"Сбросится {limit_result['reset_at']}."
),
headers={"Retry-After": "86400"},
)
# Проверить лимит одновременных задач
if not check_concurrent_limit(current_user.id, current_user.plan):
slot_acquired = await acquire_concurrent_slot(current_user.id, current_user.plan)
if not slot_acquired:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Превышен лимит одновременных задач.",
)
# Прочитать файл
file_data = await file.read()
# ── 3. Читаем файл и проверяем реальный размер ────────────────────────────
file_data = await file.read()
if len(file_data) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Файл слишком большой. Максимальный размер: {MAX_FILE_SIZE_MB} МБ",
detail=f"Файл слишком большой (максимум {MAX_FILE_SIZE_BYTES // 1024 // 1024} МБ)",
)
# Загрузить в MinIO
minio_key = f"uploads/{current_user.id}/{uuid.uuid4()}{ext}"
minio_client = get_minio_client()
# ── 4. Сохраняем в MinIO ───────────────────────────────────────────────────
# В пути — UUID документа, НЕ user_id. Так не раскрываем внутренний ID пользователя.
doc_uuid = str(uuid.uuid4())
minio_key = f"uploads/{doc_uuid}{ext}"
content_type = CONTENT_TYPE_MAP.get(ext, "application/octet-stream")
try:
# Создать бакет если не существует
if not minio_client.bucket_exists(settings.MINIO_BUCKET_DOCS):
minio_client.make_bucket(settings.MINIO_BUCKET_DOCS)
import io
minio_client.put_object(
get_minio().put_object(
bucket_name=settings.MINIO_BUCKET_DOCS,
object_name=minio_key,
data=io.BytesIO(file_data),
length=len(file_data),
content_type=file.content_type or "application/octet-stream",
content_type=content_type,
)
logger.info(f"Файл загружен в MinIO: {minio_key}")
except S3Error as e:
logger.error(f"Ошибка загрузки в MinIO: {e}")
except Exception as e:
logger.error("Ошибка загрузки в MinIO: %s", e)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Ошибка сохранения файла. Попробуйте позже.",
)
# Создать задачу в БД
# ── 5. Создаём задачу и диспатчим ─────────────────────────────────────────
task = Task(
user_id=current_user.id,
type="plagiarism",
@@ -126,30 +128,23 @@ async def upload_for_plagiarism_check(
"filename": file.filename,
"minio_key": minio_key,
"file_size_bytes": len(file_data),
"content_type": file.content_type,
},
)
db.add(task)
await db.flush()
task_id = task.id
# Диспатч в индексер воркер
celery_result = celery_app.send_task(
"index.extract_and_check",
args=[task_id, minio_key, file.filename],
args=[task.id, minio_key, file.filename],
queue="queue.index",
)
task.celery_task_id = celery_result.id
task.queue_position = 1
task.eta_seconds = 120 # ~2 минуты с учётом 4 уровней проверки
task.eta_seconds = 120
await db.commit()
await db.refresh(task)
logger.info(
f"Задача проверки плагиата {task_id!r} создана для пользователя {current_user.id}, "
f"файл: {file.filename!r}"
)
logger.info("Задача плагиата %s создана для пользователя %d", task.public_id, current_user.id)
return TaskResponse.model_validate(task)

View File

@@ -6,8 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.celery_app import celery_app
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
from app.core.security import get_current_user
from app.core.rate_limiter import acquire_concurrent_slot, check_and_increment_limit
from app.core.security import get_current_verified_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
@@ -17,41 +17,43 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/search", tags=["search"])
# Примерное время ожидания в секундах для каждой позиции в очереди
ETA_PER_POSITION_SECONDS = 15
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED)
async def create_search_task(
data: SearchRequest,
current_user: User = Depends(get_current_user),
# Поиск требует подтверждённого email — защита от массового abuse
current_user: User = Depends(get_current_verified_user),
db: AsyncSession = Depends(get_db),
) -> TaskResponse:
"""
Создать задачу семантического поиска источников.
Создать задачу поиска источников.
Возвращает TaskResponse с queue_position и eta_seconds сразу.
Результат доступен через GET /tasks/{task_id} или WebSocket /ws/tasks/{task_id}.
Возвращает сразу с task public_id и позицией в очереди.
Результат — через GET /tasks/{public_id} или WS /ws/tasks/{public_id}?token=JWT.
"""
# Проверить лимит по тарифу
limit_check = check_and_increment_limit(current_user.id, "search", current_user.plan)
if not limit_check["allowed"]:
# Проверяем лимит (атомарно, Lua-скрипт)
limit_result = await check_and_increment_limit(current_user.id, "search", current_user.plan)
if not limit_result["allowed"]:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Превышен дневной лимит поиска для тарифа '{current_user.plan}'. "
f"Использовано {limit_check['current']} из {limit_check['limit']}."
f"Превышен дневной лимит поиска (тариф '{current_user.plan}'): "
f"{limit_result['current']}/{limit_result['limit']}. "
f"Сбросится {limit_result['reset_at']}."
),
headers={"Retry-After": "86400"},
)
# Проверить лимит одновременных задач
if not check_concurrent_limit(current_user.id, current_user.plan):
# Проверяем лимит одновременных задач (атомарно)
slot_acquired = await acquire_concurrent_slot(current_user.id, current_user.plan)
if not slot_acquired:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Превышен лимит одновременных задач. Дождитесь завершения текущих.",
)
# Создать задачу в БД
task = Task(
user_id=current_user.id,
type="search",
@@ -65,31 +67,21 @@ async def create_search_task(
},
)
db.add(task)
await db.flush()
task_id = task.id
await db.flush() # получаем id и public_id
# Диспатч в GPU воркер
celery_result = celery_app.send_task(
"gpu.search_semantic",
args=[task_id, data.query],
kwargs={
"lang": data.lang,
"year_from": data.year_from,
"year_to": data.year_to,
},
args=[task.id, data.query],
kwargs={"lang": data.lang, "year_from": data.year_from, "year_to": data.year_to},
queue="queue.gpu",
)
task.celery_task_id = celery_result.id
task.queue_position = 1 # TODO: реальный подсчёт через Redis
task.queue_position = 1
task.eta_seconds = ETA_PER_POSITION_SECONDS
await db.commit()
await db.refresh(task)
logger.info(
f"Задача поиска {task_id!r} создана для пользователя {current_user.id}, "
f"запрос: {data.query[:50]!r}"
)
logger.info("Задача поиска %s создана для пользователя %d", task.public_id, current_user.id)
return TaskResponse.model_validate(task)

View File

@@ -1,4 +1,8 @@
"""Роутер для управления задачами пользователя."""
"""Управление задачами пользователя.
В URL используется public_id (не внутренний UUID), чтобы не раскрывать
структуру внутренних идентификаторов.
"""
import logging
@@ -24,7 +28,10 @@ async def list_tasks(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[TaskResponse]:
"""Получить список задач текущего пользователя (от новых к старым)."""
"""Список задач текущего пользователя (новые первыми)."""
# Жёсткий потолок limit — не даём вытащить всю таблицу одним запросом
limit = min(limit, 100)
result = await db.execute(
select(Task)
.where(Task.user_id == current_user.id)
@@ -32,52 +39,47 @@ async def list_tasks(
.limit(limit)
.offset(offset)
)
tasks = result.scalars().all()
return [TaskResponse.model_validate(t) for t in tasks]
return [TaskResponse.model_validate(t) for t in result.scalars().all()]
@router.get("/{task_id}", response_model=TaskResponse)
@router.get("/{public_id}", response_model=TaskResponse)
async def get_task(
task_id: str,
public_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> TaskResponse:
"""Получить детали конкретной задачи."""
"""Получить задачу по публичному ID. Возвращает 404 если задача чужая — не раскрываем факт существования."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
select(Task).where(
Task.public_id == public_id,
Task.user_id == current_user.id, # ownership проверяется в одном запросе
)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Задача не найдена",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена")
return TaskResponse.model_validate(task)
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
task_id: str,
public_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""
Удалить задачу.
Нельзя удалить задачу в статусе 'processing'.
"""
"""Удалить задачу. Нельзя удалить задачу в статусе 'processing'."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
select(Task).where(
Task.public_id == public_id,
Task.user_id == current_user.id,
)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Задача не найдена",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена")
if task.status == "processing":
raise HTTPException(