feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ API Key Authentication Middleware
|
||||
|
||||
Provides reusable dependencies for API key authentication across all endpoints.
|
||||
Story 3.4: Authentification API via X-API-Key
|
||||
Story 6.4: Bind user_id to structlog context when user is resolved (logs include user_id).
|
||||
"""
|
||||
|
||||
from typing import Optional, Any, Union
|
||||
@@ -69,8 +70,11 @@ async def get_user_from_api_key(
|
||||
|
||||
try:
|
||||
from services.auth_service import get_user_by_api_key
|
||||
|
||||
from core.logging import bind_request_context
|
||||
|
||||
user = get_user_by_api_key(x_api_key)
|
||||
if user is not None:
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
return user
|
||||
|
||||
except ValueError as e:
|
||||
@@ -125,21 +129,26 @@ async def get_authenticated_user_optional(
|
||||
try:
|
||||
user = await get_user_from_api_key(x_api_key)
|
||||
if user:
|
||||
from core.logging import bind_request_context
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
return user
|
||||
except APIKeyError:
|
||||
# Invalid API key, fall through to JWT
|
||||
pass
|
||||
|
||||
|
||||
# Fall back to JWT
|
||||
if credentials:
|
||||
try:
|
||||
from routes.auth_routes import get_current_user
|
||||
|
||||
|
||||
user = await get_current_user(credentials)
|
||||
if user:
|
||||
from core.logging import bind_request_context
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
return user
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -167,20 +176,25 @@ async def get_authenticated_user(
|
||||
# get_user_from_api_key will raise APIKeyError for invalid keys
|
||||
user = await get_user_from_api_key(x_api_key)
|
||||
if user:
|
||||
from core.logging import bind_request_context
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
return user
|
||||
# Should not reach here - get_user_from_api_key returns None only if no key provided
|
||||
raise APIKeyError("INVALID_API_KEY", "Clé API invalide ou non reconnue.")
|
||||
|
||||
|
||||
# Fall back to JWT
|
||||
if credentials:
|
||||
try:
|
||||
from routes.auth_routes import get_current_user
|
||||
|
||||
|
||||
user = await get_current_user(credentials)
|
||||
if user:
|
||||
from core.logging import bind_request_context
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
return user
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -198,10 +212,12 @@ async def require_authenticated_user(
|
||||
User object (guaranteed to be authenticated)
|
||||
"""
|
||||
user = await get_authenticated_user(credentials, x_api_key)
|
||||
|
||||
|
||||
if not user:
|
||||
raise APIKeyError("MISSING_API_KEY", "Authentification requise. Utilisez X-API-Key ou Authorization: Bearer.")
|
||||
|
||||
|
||||
from core.logging import bind_request_context
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -14,14 +14,10 @@ import logging
|
||||
import json
|
||||
from services.storage_tracker import _get_async_redis, KEY_PREFIX
|
||||
|
||||
try:
|
||||
import structlog
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
_HAS_STRUCTLOG = True
|
||||
except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
_HAS_STRUCTLOG = False
|
||||
logger = get_logger(__name__)
|
||||
_HAS_STRUCTLOG = True
|
||||
|
||||
|
||||
class FileCleanupManager:
|
||||
|
||||
@@ -13,12 +13,9 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
# Import APIKeyError for handling
|
||||
from middleware.api_key_auth import APIKeyError
|
||||
|
||||
try:
|
||||
import structlog
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def format_error_response(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Rate Limiting Middleware for SaaS robustness
|
||||
Protects against abuse and ensures fair usage
|
||||
Protects against abuse and ensures fair usage.
|
||||
When REDIS_URL is set, uses Redis for sliding-window counters (shared across instances).
|
||||
Otherwise falls back to in-memory per-process limits.
|
||||
"""
|
||||
import time
|
||||
import asyncio
|
||||
@@ -39,6 +41,60 @@ class RateLimitConfig:
|
||||
whitelist_ips: list = field(default_factory=lambda: ["127.0.0.1", "::1"])
|
||||
|
||||
|
||||
KEY_PREFIX_IP = "rate_limit:ip"
|
||||
KEY_PREFIX_SIZE_HOUR = "rate_limit:size_hour"
|
||||
|
||||
|
||||
async def _redis_sliding_window_is_allowed(redis_client, client_id: str, window_key: str, window_seconds: int, max_requests: int) -> bool:
|
||||
"""Redis sliding window. Key: rate_limit:ip:{client_id}:{window_key}. Returns True if under limit."""
|
||||
now = time.time()
|
||||
key = f"{KEY_PREFIX_IP}:{client_id}:{window_key}"
|
||||
pipe = redis_client.pipeline()
|
||||
pipe.zremrangebyscore(key, "-inf", now - window_seconds)
|
||||
pipe.zcard(key)
|
||||
results = await pipe.execute()
|
||||
count_after_cleanup = results[1]
|
||||
if count_after_cleanup >= max_requests:
|
||||
return False
|
||||
pipe2 = redis_client.pipeline()
|
||||
pipe2.zadd(key, {str(now): now})
|
||||
pipe2.expire(key, window_seconds + 60)
|
||||
await pipe2.execute()
|
||||
return True
|
||||
|
||||
|
||||
async def _check_request_redis(redis_client, client_id: str, config: RateLimitConfig) -> tuple[bool, str]:
|
||||
"""Check request limits using Redis. Returns (allowed, reason)."""
|
||||
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "minute", 60, config.requests_per_minute):
|
||||
return False, f"Rate limit exceeded. Max {config.requests_per_minute} requests per minute."
|
||||
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "hour", 3600, config.requests_per_hour):
|
||||
return False, f"Hourly limit exceeded. Max {config.requests_per_hour} requests per hour."
|
||||
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "day", 86400, config.requests_per_day):
|
||||
return False, f"Daily limit exceeded. Max {config.requests_per_day} requests per day."
|
||||
return True, ""
|
||||
|
||||
|
||||
async def _check_translation_redis(redis_client, client_id: str, config: RateLimitConfig, file_size_mb: float = 0) -> tuple[bool, str]:
|
||||
"""Check translation limits using Redis. Returns (allowed, reason)."""
|
||||
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "trans_minute", 60, config.translations_per_minute):
|
||||
return False, f"Translation rate limit exceeded. Max {config.translations_per_minute} translations per minute."
|
||||
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "trans_hour", 3600, config.translations_per_hour):
|
||||
return False, f"Hourly translation limit exceeded. Max {config.translations_per_hour} translations per hour."
|
||||
# Hourly total size (MB) per client — same semantics as in-memory total_size_hour
|
||||
now = time.time()
|
||||
hour_ts = int(now // 3600)
|
||||
size_key = f"{KEY_PREFIX_SIZE_HOUR}:{client_id}:{hour_ts}"
|
||||
try:
|
||||
cur_raw = await redis_client.get(size_key)
|
||||
cur = float(cur_raw or 0)
|
||||
if cur + file_size_mb > config.max_total_size_per_hour_mb:
|
||||
return False, f"Hourly data limit exceeded. Max {config.max_total_size_per_hour_mb}MB per hour."
|
||||
await redis_client.set(size_key, cur + file_size_mb, ex=7200)
|
||||
except Exception as e:
|
||||
logger.warning("Redis size-hour check failed: %s; allowing request", e)
|
||||
return True, ""
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""Token bucket algorithm for rate limiting"""
|
||||
|
||||
@@ -215,23 +271,40 @@ class RateLimitManager:
|
||||
"""Check if request is allowed, return (allowed, reason, client_id)"""
|
||||
client_id = self.get_client_id(request)
|
||||
self._total_requests += 1
|
||||
|
||||
|
||||
if self.is_whitelisted(client_id):
|
||||
return True, "", client_id
|
||||
|
||||
|
||||
try:
|
||||
from core.redis import get_async_redis
|
||||
redis_client = get_async_redis()
|
||||
if redis_client:
|
||||
allowed, reason = await _check_request_redis(redis_client, client_id, self.config)
|
||||
return allowed, reason, client_id
|
||||
except Exception as e:
|
||||
logger.warning("Redis rate limit check failed, using in-memory: %s", e)
|
||||
|
||||
client = self.clients[client_id]
|
||||
allowed, reason = await client.check_request()
|
||||
|
||||
return allowed, reason, client_id
|
||||
|
||||
async def check_translation(self, request: Request, file_size_mb: float = 0) -> tuple[bool, str]:
|
||||
"""Check if translation is allowed"""
|
||||
client_id = self.get_client_id(request)
|
||||
self._total_translations += 1
|
||||
|
||||
|
||||
if self.is_whitelisted(client_id):
|
||||
return True, ""
|
||||
|
||||
|
||||
try:
|
||||
from core.redis import get_async_redis
|
||||
redis_client = get_async_redis()
|
||||
if redis_client:
|
||||
allowed, reason = await _check_translation_redis(redis_client, client_id, self.config, file_size_mb)
|
||||
return allowed, reason
|
||||
except Exception as e:
|
||||
logger.warning("Redis translation limit check failed, using in-memory: %s", e)
|
||||
|
||||
client = self.clients[client_id]
|
||||
return await client.check_translation(file_size_mb)
|
||||
|
||||
@@ -239,7 +312,16 @@ class RateLimitManager:
|
||||
"""Check if translation is allowed for a specific client ID"""
|
||||
if self.is_whitelisted(client_id):
|
||||
return True
|
||||
|
||||
|
||||
try:
|
||||
from core.redis import get_async_redis
|
||||
redis_client = get_async_redis()
|
||||
if redis_client:
|
||||
allowed, _ = await _check_translation_redis(redis_client, client_id, self.config, file_size_mb)
|
||||
return allowed
|
||||
except Exception as e:
|
||||
logger.warning("Redis translation limit check failed, using in-memory: %s", e)
|
||||
|
||||
client = self.clients[client_id]
|
||||
allowed, _ = await client.check_translation(file_size_mb)
|
||||
return allowed
|
||||
@@ -295,6 +377,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self.manager = rate_limit_manager
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# CORS preflight must not be rate-limited (no ACAO header → browser blocks the real request)
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
# Skip rate limiting for health checks and static files
|
||||
if request.url.path in ["/health", "/", "/docs", "/openapi.json", "/redoc"]:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
"""
|
||||
Security Headers Middleware for SaaS robustness
|
||||
Adds security headers to all responses
|
||||
Security & request logging middlewares.
|
||||
|
||||
RequestLoggingMiddleware is responsible for:
|
||||
- Assigning a request_id to each request
|
||||
- Binding request_id and user_id into structlog context so all logs
|
||||
include these fields in JSON output (Story 6.4).
|
||||
"""
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from core.logging import get_logger, bind_request_context, clear_request_context
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all responses"""
|
||||
|
||||
|
||||
def __init__(self, app, config: dict = None):
|
||||
super().__init__(app)
|
||||
self.config = config or {}
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# Prevent clickjacking
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
|
||||
# Prevent MIME type sniffing
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
|
||||
# Enable XSS filter
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
|
||||
|
||||
# Referrer policy
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
|
||||
|
||||
# Permissions policy
|
||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
|
||||
|
||||
# Content Security Policy (adjust for your frontend)
|
||||
if not request.url.path.startswith("/docs") and not request.url.path.startswith("/redoc"):
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
@@ -46,73 +51,90 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"connect-src 'self' http://localhost:* https://localhost:* ws://localhost:*; "
|
||||
"worker-src 'self' blob:; "
|
||||
)
|
||||
|
||||
|
||||
# HSTS (only in production with HTTPS)
|
||||
if self.config.get("enable_hsts", False):
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Log all requests for monitoring and debugging"""
|
||||
|
||||
"""Log all requests for monitoring and debugging with structured context."""
|
||||
|
||||
def __init__(self, app, log_body: bool = False):
|
||||
super().__init__(app)
|
||||
self.log_body = log_body
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
# Generate request ID
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
request.state.request_id = request_id
|
||||
|
||||
|
||||
# Attempt to get user id if auth has already populated it
|
||||
user_id = getattr(getattr(request.state, "user", None), "id", None)
|
||||
|
||||
# Bind context so all downstream logs include request_id/user_id
|
||||
bind_request_context(request_id=request_id, user_id=str(user_id) if user_id else None)
|
||||
|
||||
# Get client info
|
||||
client_ip = self._get_client_ip(request)
|
||||
|
||||
|
||||
# Log request start
|
||||
start_time = time.time()
|
||||
logger.info(
|
||||
f"[{request_id}] {request.method} {request.url.path} "
|
||||
f"from {client_ip} - Started"
|
||||
"request_started",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# Log request completion
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
f"[{request_id}] {request.method} {request.url.path} "
|
||||
f"- {response.status_code} in {duration:.3f}s"
|
||||
"request_completed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=round(duration * 1000, 3),
|
||||
)
|
||||
|
||||
|
||||
# Add request ID to response headers
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.error(
|
||||
f"[{request_id}] {request.method} {request.url.path} "
|
||||
f"- ERROR in {duration:.3f}s: {str(e)}"
|
||||
logger.exception(
|
||||
"request_error",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
duration_ms=round(duration * 1000, 3),
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Clear contextvars so they don't leak across requests
|
||||
clear_request_context()
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Get real client IP from headers or connection"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
|
||||
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
|
||||
return "unknown"
|
||||
|
||||
@@ -53,30 +53,15 @@ class QuotaResult:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis backend
|
||||
# Redis backend (shared client from core.redis)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_async_redis = None
|
||||
|
||||
|
||||
def _get_async_redis():
|
||||
"""Return async Redis client or None. Uses REDIS_URL from env. Single shared client."""
|
||||
global _async_redis
|
||||
if _async_redis is not None:
|
||||
return _async_redis if _async_redis is not False else None
|
||||
url = os.getenv("REDIS_URL", "").strip()
|
||||
if not url:
|
||||
_async_redis = False
|
||||
return None
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
_async_redis = redis.Redis.from_url(url, decode_responses=True)
|
||||
logger.info("Tier quota: using Redis for daily quota")
|
||||
return _async_redis
|
||||
except Exception as e:
|
||||
logger.warning("Tier quota: Redis unavailable (%s), using in-memory fallback", e)
|
||||
_async_redis = False
|
||||
return None
|
||||
"""Return async Redis client or None. Uses shared client from core.redis."""
|
||||
from core.redis import get_async_redis
|
||||
|
||||
return get_async_redis()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user