# 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), trusted_proxies=getattr(settings, "LOG_TRUSTED_PROXY_CIDRS", ()), ) 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