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

@@ -0,0 +1,36 @@
"""Singleton пул Redis-соединений.
Один ConnectionPool на весь процесс — не создаём новое соединение на каждый запрос.
Используем redis.asyncio для совместимости с FastAPI async endpoints.
"""
import redis.asyncio as aioredis
from app.config import settings
_pool: aioredis.ConnectionPool | None = None
def get_pool() -> aioredis.ConnectionPool:
"""Инициализировать пул при первом вызове, затем возвращать тот же."""
global _pool
if _pool is None:
_pool = aioredis.ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=50,
decode_responses=True,
)
return _pool
def get_redis() -> aioredis.Redis:
"""Получить Redis клиент из пула (не создаёт новое TCP-соединение)."""
return aioredis.Redis(connection_pool=get_pool())
async def close_pool() -> None:
"""Закрыть пул при остановке приложения."""
global _pool
if _pool:
await _pool.aclose()
_pool = None