181 lines
6.6 KiB
Python
181 lines
6.6 KiB
Python
# backend/eusrr_backend/middleware.py
|
||
"""
|
||
Кастомные middleware для проекта EUSRR.
|
||
|
||
Минимальный набор для работы Django Admin + REST API.
|
||
"""
|
||
|
||
import logging
|
||
from time import monotonic
|
||
|
||
from django.conf import settings
|
||
|
||
from .logging_utils import (
|
||
bind_log_context,
|
||
new_request_id,
|
||
reset_log_context,
|
||
resolve_client_ip,
|
||
update_log_context,
|
||
)
|
||
|
||
|
||
logger = logging.getLogger("eusrr.http")
|
||
|
||
|
||
class RequestLoggingMiddleware:
|
||
"""Add correlation context and emit one completion event per HTTP request."""
|
||
|
||
def __init__(self, get_response):
|
||
self.get_response = get_response
|
||
|
||
@staticmethod
|
||
def _client_ip(request):
|
||
return resolve_client_ip(
|
||
request.META.get("REMOTE_ADDR"),
|
||
forwarded_for=request.META.get("HTTP_X_FORWARDED_FOR"),
|
||
real_ip=request.META.get("HTTP_X_REAL_IP"),
|
||
trust_proxy=getattr(settings, "LOG_TRUST_X_FORWARDED_FOR", False),
|
||
)
|
||
|
||
def __call__(self, request):
|
||
request_id = new_request_id(request.headers.get("X-Request-ID"))
|
||
request.request_id = request_id
|
||
user = getattr(request, "user", None)
|
||
user_id = (
|
||
getattr(user, "pk", None)
|
||
if getattr(user, "is_authenticated", False)
|
||
else None
|
||
)
|
||
token = bind_log_context(
|
||
request_id=request_id,
|
||
user_id=user_id,
|
||
http_method=request.method,
|
||
path=request.path_info,
|
||
client_ip=self._client_ip(request),
|
||
)
|
||
started = monotonic()
|
||
try:
|
||
response = self.get_response(request)
|
||
user = getattr(request, "user", None)
|
||
if getattr(user, "is_authenticated", False):
|
||
update_log_context(user_id=getattr(user, "pk", None))
|
||
duration_ms = round((monotonic() - started) * 1000, 2)
|
||
status_code = response.status_code
|
||
log = (
|
||
logger.error
|
||
if status_code >= 500
|
||
else logger.warning
|
||
if status_code >= 400
|
||
else logger.info
|
||
)
|
||
log(
|
||
"HTTP request completed",
|
||
extra={
|
||
"event": "http.request.completed",
|
||
"status_code": status_code,
|
||
"duration_ms": duration_ms,
|
||
"response_bytes": response.get("Content-Length"),
|
||
},
|
||
)
|
||
response["X-Request-ID"] = request_id
|
||
return response
|
||
except Exception:
|
||
logger.exception(
|
||
"HTTP request failed",
|
||
extra={
|
||
"event": "http.request.failed",
|
||
"duration_ms": round((monotonic() - started) * 1000, 2),
|
||
},
|
||
)
|
||
raise
|
||
finally:
|
||
reset_log_context(token)
|
||
|
||
|
||
class AuthRequiredMiddleware:
|
||
"""
|
||
Базовая защита: неавторизованные пользователи не могут заходить куда-либо,
|
||
кроме API и админки (которые управляют аутентификацией сами).
|
||
|
||
Разрешенные зоны без аутентификации:
|
||
- /api/ - REST API с JWT/Token аутентификацией
|
||
- /admin/ - Django Admin со своей системой аутентификации
|
||
- /static/ - статические файлы
|
||
- /media/ - медиафайлы
|
||
"""
|
||
|
||
EXEMPT_PREFIXES = (
|
||
"/api/", # REST API управляет своей аутентификацией
|
||
"/admin/", # Django Admin использует встроенную аутентификацию
|
||
"/static/", # Статика
|
||
"/media/", # Медиа
|
||
)
|
||
|
||
def __init__(self, get_response):
|
||
self.get_response = get_response
|
||
self.allowed_prefixes = tuple(
|
||
getattr(settings, "AUTH_REQUIRED_EXEMPT_PREFIXES", self.EXEMPT_PREFIXES)
|
||
)
|
||
|
||
def __call__(self, request):
|
||
# Аутентифицированные пользователи проходят везде
|
||
if request.user.is_authenticated:
|
||
return self.get_response(request)
|
||
|
||
path = request.path_info
|
||
|
||
# Пропускаем разрешенные префиксы - они сами управляют аутентификацией
|
||
# Django Admin сам редиректит на /admin/login/ если нужно
|
||
if any(path.startswith(p) for p in self.allowed_prefixes):
|
||
return self.get_response(request)
|
||
|
||
# Все остальное - 403 Forbidden для неаутентифицированных
|
||
from django.http import HttpResponseForbidden, JsonResponse
|
||
|
||
# Для API запросов возвращаем JSON
|
||
if request.headers.get("Accept", "").startswith("application/json"):
|
||
return JsonResponse({"detail": "Authentication required"}, status=403)
|
||
|
||
# Для браузерных запросов - HTML 403
|
||
return HttpResponseForbidden("403 Forbidden: Authentication required")
|
||
|
||
|
||
class CacheControlMiddleware:
|
||
"""
|
||
Добавляет правильные заголовки Cache-Control для HTML-страниц.
|
||
|
||
Стратегия:
|
||
- HTML страницы: private cache на 60 секунд (только для браузера)
|
||
- API: обрабатывается на уровне DRF
|
||
- Статика/медиа: обрабатывается веб-сервером
|
||
"""
|
||
|
||
NO_CACHE_PREFIXES = (
|
||
"/api/", # API управляет своим кэшем
|
||
"/admin/", # Админка всегда свежая
|
||
"/static/", # Обрабатывается веб-сервером
|
||
"/media/", # Обрабатывается веб-сервером
|
||
)
|
||
|
||
def __init__(self, get_response):
|
||
self.get_response = get_response
|
||
|
||
def __call__(self, request):
|
||
response = self.get_response(request)
|
||
|
||
# Применяем только к успешным HTML GET-запросам
|
||
if (
|
||
response.status_code == 200
|
||
and request.method == "GET"
|
||
and response.get("Content-Type", "").startswith("text/html")
|
||
):
|
||
path = request.path_info
|
||
|
||
# Проверяем исключения
|
||
if not any(path.startswith(p) for p in self.NO_CACHE_PREFIXES):
|
||
# Если заголовок уже установлен - не перезаписываем
|
||
if not response.has_header("Cache-Control"):
|
||
response["Cache-Control"] = "private, max-age=60"
|
||
|
||
return response
|