Corp/backend/eusrr_backend/middleware.py

183 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 использует встроенную аутентификацию
"/o/", # OAuth2/OIDC endpoints сами проверяют клиента и пользователя
"/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/", # Админка всегда свежая
"/o/", # OAuth2/OIDC responses могут содержать одноразовые коды
"/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