diff --git a/docker-compose.env b/docker-compose.env index f9c54dc..13698a7 100644 --- a/docker-compose.env +++ b/docker-compose.env @@ -30,12 +30,12 @@ RACK_TEST_DATABASE='/tmp/rack/rack.sqlite' # # KeyDb Database # -USE_KEYDB=1 -KEYDB_HOST='127.0.0.1' -KEYDB_PORT=6379 -KEYDB_DBINDEX=0 -KEYDB_PASSWORD='' -KEYDB_POOL_SIZE=4 +RACK_USE_KEYDB=1 +RACK_KEYDB_HOST='127.0.0.1' +RACK_KEYDB_PORT=6379 +RACK_KEYDB_DBINDEX=0 +RACK_KEYDB_PASSWORD='' +RACK_KEYDB_POOL_SIZE=4 # # S3 Object Storage diff --git a/docker-compose.yml b/docker-compose.yml index 305e005..6eecf8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,9 @@ services: env_file : - .env environment : - POSTGRES_DB : ${rack_DATABASE_NAME} - POSTGRES_USER : ${rack_DATABASE_USERNAME} - POSTGRES_PASSWORD: ${rack_DATABASE_PASSWORD} + POSTGRES_DB : ${RACK_DATABASE_NAME} + POSTGRES_USER : ${RACK_DATABASE_USERNAME} + POSTGRES_PASSWORD: ${RACK_DATABASE_PASSWORD} ports : - "${rack_DATABASE_PORT:-5432}:5432" volumes : @@ -29,7 +29,7 @@ services: env_file : - .env ports : - - "${rack_KEYDB_PORT:-6379}:6379" + - "${RACK_KEYDB_PORT:-6379}:6379" command : keydb-server --protected-mode no --save "" volumes : - keydb_data:/data diff --git a/net/xeaf/rack/managers/account_manager.py b/net/xeaf/rack/managers/account_manager.py index d79100d..875bac8 100644 --- a/net/xeaf/rack/managers/account_manager.py +++ b/net/xeaf/rack/managers/account_manager.py @@ -7,6 +7,7 @@ """ Описание класса AccountManager """ + from net.xeaf.rack.enums import AuthRejectReason from net.xeaf.rack.models.account_model import AccountModel diff --git a/net/xeaf/rack/utils/drf/__init__.py b/net/xeaf/rack/utils/drf/__init__.py index f4bbfdf..7761e66 100644 --- a/net/xeaf/rack/utils/drf/__init__.py +++ b/net/xeaf/rack/utils/drf/__init__.py @@ -9,4 +9,5 @@ """ from .custom_exception_handler import custom_exception_handler +from .custom_logging_middleware import CustomLoggingMiddleware from .expiring_token_authentication import ExpiringTokenAuthentication diff --git a/net/xeaf/rack/utils/drf/custom_exception_handler.py b/net/xeaf/rack/utils/drf/custom_exception_handler.py index 66b0ef6..561f285 100644 --- a/net/xeaf/rack/utils/drf/custom_exception_handler.py +++ b/net/xeaf/rack/utils/drf/custom_exception_handler.py @@ -8,6 +8,8 @@ Описание функции custom_exception_handler """ +import logging + from rest_framework import status from rest_framework.exceptions import MethodNotAllowed from rest_framework.exceptions import NotAuthenticated @@ -21,6 +23,7 @@ from net.xeaf.rack.models.responses import ErrorResponse from net.xeaf.rack.utils.exceptions import ForbiddenException from net.xeaf.rack.utils.exceptions import NotFoundException from net.xeaf.rack.utils.exceptions import UnauthorizedException +from wsgiapp import settings def custom_exception_handler(exc: CoreException | Exception, context: dict) -> Response: @@ -39,7 +42,12 @@ def custom_exception_handler(exc: CoreException | Exception, context: dict) -> R # Это наше исключение if isinstance(exc, CoreException): - return ErrorResponse(status_code=exc.status_code, detail=exc.detail, meta=exc.meta, pure_status=exc.is_pure_status()) + return _make_error_response( + status_code=exc.status_code, + detail=exc.detail, + meta=exc.meta, + pure_status=exc.is_pure_status(), + exc_info=exc) # Проверка на стандартное исключение валидации if isinstance(exc, ValidationError): @@ -47,18 +55,38 @@ def custom_exception_handler(exc: CoreException | Exception, context: dict) -> R # Проверка на стандартное исключение авторизации if isinstance(exc, NotAuthenticated): - return ErrorResponse(status_code=status.HTTP_401_UNAUTHORIZED, detail=UnauthorizedException.Msg.DEFAULT_MESSAGE) + return _make_error_response( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=UnauthorizedException.Msg.DEFAULT_MESSAGE, + meta=None, + pure_status=False, + exc_info=exc) # Проверка на стандартное исключение прав доступа if isinstance(exc, PermissionDenied): - return ErrorResponse(status_code=status.HTTP_403_FORBIDDEN, detail=ForbiddenException.Msg.DEFAULT_MESSAGE) + return _make_error_response( + status_code=status.HTTP_403_FORBIDDEN, + detail=ForbiddenException.Msg.DEFAULT_MESSAGE, + meta=None, + pure_status=False, + exc_info=exc) # Проверка на стандартное исключение отсутствия реализации if isinstance(exc, MethodNotAllowed): - return ErrorResponse(status_code=status.HTTP_404_NOT_FOUND, detail=NotFoundException.Msg.DEFAULT_MESSAGE) + return _make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + detail=NotFoundException.Msg.DEFAULT_MESSAGE, + meta=None, + pure_status=True, + exc_info=exc) # Если ничего другое не прошло - return ErrorResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) + return _make_error_response( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(exc), + meta=None, + pure_status=True, + exc_info=exc) def _process_validation_exception(exc: ValidationError) -> ErrorResponse: @@ -70,18 +98,53 @@ def _process_validation_exception(exc: ValidationError) -> ErrorResponse: :return: Ответ """ + # Обработка списка if isinstance(exc.detail, list): if len(exc.detail) == 1: detail = str(exc.detail[0]) else: detail = exc.detail + + # Обработка словаря elif isinstance(exc.detail, dict): detail = {} for field, error_info in exc.detail.items(): if isinstance(error_info, list): error = error_info[0] detail[field] = str(error) + + # Обработка остальных типов else: detail = str(exc.detail) - return ErrorResponse(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) + return _make_error_response(status_code=status.HTTP_400_BAD_REQUEST, detail=detail, meta=None, pure_status=False, exc_info=exc) + + +def _make_error_response(status_code: int, detail: list | dict | str | bool | None, meta: dict | None, pure_status: bool, + exc_info: Exception) -> ErrorResponse: + """ + Создает ответ с ошибкой + + :param status_code: Код статуса + :param detail: Детали ошибки + :param meta: Дополнительная информация + :param pure_status: Признак отправки чистого кода состояния + :param exc_info: Исключение + + :return: Ответ + """ + + logger = logging.getLogger(settings.LOGGER_NAME) + + if status_code == status.HTTP_400_BAD_REQUEST: + logger.debug(str(exc_info), exc_info=exc_info) + elif status_code == status.HTTP_401_UNAUTHORIZED: + logger.warning(str(exc_info), exc_info=exc_info) + elif status_code == status.HTTP_403_FORBIDDEN: + logger.warning(str(exc_info), exc_info=exc_info) + elif status_code == status.HTTP_404_NOT_FOUND: + logger.error(str(exc_info), exc_info=exc_info) + else: + logger.critical(str(exc_info), exc_info=exc_info) + + return ErrorResponse(status_code=status_code, detail=detail, meta=meta, pure_status=pure_status) diff --git a/net/xeaf/rack/utils/drf/custom_logging_middleware.py b/net/xeaf/rack/utils/drf/custom_logging_middleware.py new file mode 100644 index 0000000..1e9fa85 --- /dev/null +++ b/net/xeaf/rack/utils/drf/custom_logging_middleware.py @@ -0,0 +1,207 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CustomLoggingMiddleware +""" + +import json +import logging +from logging import Logger +from typing import Any + +from django.conf import settings +from django.http import HttpRequest +from django.http import HttpResponse +from rest_framework import status + + +class CustomLoggingMiddleware: + """ + Класс для логирования запросов и ответов DRF + """ + + DEFAULT_SENSITIVE_KEYS = {'password', 'old_password', 'new_password', 'token', 'access_token'} + """ Словарь ключей, которые нужно замаскировать в логах """ + + MAX_LOG_LENGTH = 2048 + """ Максимальная длина объекта данных лога """ + + sensitive_keys: set = {} + """ Актуальный словарь ключей, которые нужно замаскировать в логах """ + + logger: Logger + """ Объект доступа к логгеру """ + + def __init__(self, get_response): + """ + Инициализация + """ + + self.get_response = get_response + self.logger = logging.getLogger(settings.LOGGER_NAME) + + self.sensitive_keys = set(self.DEFAULT_SENSITIVE_KEYS) + if settings.LOGGER_SENSITIVE_KEYS: + self.sensitive_keys.update(settings.LOGGER_SENSITIVE_KEYS) + + def __call__(self, request: HttpRequest): + """ + Собираем данные запроса и отправляем в лог + """ + + # Обрабатываем данные запроса + path = request.path + method = request.method + query_params = self._process_query_params(request) + + # Передаем запрос дальше по конвейеру Django / DRF + response = self.get_response(request) + + # Обрабатываем данные запроса, ответа и перехваченного исключения + body = self._process_body(request) + exc = getattr(request, "_raised_exception", None) + status_code = response.status_code + request_id = request.META.get('HTTP_X_REQUEST_ID', '') + response_content = self._process_response(response) + + # Обрабатываем данные пользователя + user_id = "" + if hasattr(request, 'user'): + if request.user.is_authenticated: + user_id = str(request.user.id) + + # Формируем единое сообщение для лога + log_parts = [ + f"{status_code} [{method}] {path}" + ] + + if user_id: + log_parts.append(f"User ID: {user_id}") + if request_id: + log_parts.append(f"Request ID: {request_id}") + if query_params: + log_parts.append(f"Query Params: {query_params}") + if body: + log_parts.append(f"Request Body: {body}") + if response_content: + log_parts.append(f"Response Content: {response_content}") + + log_message = "\n".join(log_parts) + "\n" + + # Создаем и записываем лог + self._write_log(log_message, status_code, exc) + + return response + + def _process_query_params(self, request: HttpRequest) -> dict[str, Any]: + """ + Обрабатывает параметры запроса и маскирует конфиденциальные данные + + :param request: Объект запроса + + :return: Обработанные параметры запроса + """ + + raw_query_params = dict(request.GET.lists()) + return self._mask_sensitive_data(raw_query_params) + + def _process_body(self, request: HttpRequest) -> str: + """ + Обрабатывает тело запроса и маскирует конфиденциальные данные + + :param request: Объект запроса + + :return: Обработанное тело запроса + """ + + if request.body: + return self._process_data(request.body) + + return "" + + def _process_response(self, response: HttpResponse) -> str: + """ + Обрабатываем данные ответа + + :param response: HttpResponse + + :return: Строковое представление данных ответа + """ + + if hasattr(response, 'content'): + return self._process_data(response.content) + + return "" + + def _process_data(self, data: Any) -> str: + """ + Обрабатывает данные запроса или ответа и возвращает их в виде строки + + :param data: Данные запроса или ответа + + :return: Обработанные данные в виде строки + """ + + data_len = len(data) + if data_len > self.MAX_LOG_LENGTH: + return f"" + + try: + raw_response = data.decode('utf-8') + try: + data_json = json.loads(raw_response) + masked_response = self._mask_sensitive_data(data_json) + result = json.dumps(masked_response, ensure_ascii=False, indent=2) + except json.JSONDecodeError: + result = raw_response + except UnicodeDecodeError: + result = f"" + + return result + + def _write_log(self, log_message: str, status_code: int, exc: Exception | None): + """ + Записывает лог с учетом исключения + + :param log_message: Сообщение лога + :param status_code: Код статуса ответа + :param exc: Исключение + """ + + if exc: + + log_message += f"\nException: {str(exc)}" + + if status_code == status.HTTP_400_BAD_REQUEST: + self.logger.debug(log_message, exc_info=exc) + elif status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]: # UNAUTHORIZED, FORBIDDEN + self.logger.warning(log_message, exc_info=exc) + elif status_code == status.HTTP_404_NOT_FOUND: + self.logger.error(log_message, exc_info=exc) + else: + self.logger.critical(log_message, exc_info=exc) + else: + self.logger.debug(log_message) + + def _mask_sensitive_data(self, data: Any) -> Any: + """ + Рекурсивно маскирует конфиденциальные данные в словарях и списках + + :param data: Данные для маскировки + + :return: Маскированные данные + """ + + if isinstance(data, dict): + return { + key: '***' if key.lower() in self.sensitive_keys else self._mask_sensitive_data(value) + for key, value in data.items() + } + elif isinstance(data, list): + return [self._mask_sensitive_data(item) for item in data] + + return data diff --git a/net/xeaf/rack/utils/settings/configs/logging.py b/net/xeaf/rack/utils/settings/configs/logging.py index 82c6606..53426b1 100644 --- a/net/xeaf/rack/utils/settings/configs/logging.py +++ b/net/xeaf/rack/utils/settings/configs/logging.py @@ -17,6 +17,11 @@ from net.xeaf.rack.utils.settings.logger import LogDebugFilter # LOGGER_NAME = Environment.get_str("LOGGER_NAME", "rack") +# +# Ключи полей, которые необходимо скрывать в журнале +# +LOGGER_SENSITIVE_KEYS = Environment.get_str_as_list("LOGGER_SENSITIVE_KEYS", "") + # # Уровень журналирования # @@ -61,7 +66,7 @@ LOGGING = { # "main_file": { "level": "INFO" if LOG_LEVEL == "DEBUG" else LOG_LEVEL, - "class": "logging.handlers.RotatingFileHandler", + "class": "concurrent_log_handler.ConcurrentRotatingFileHandler", "filename": LOG_FILE, "maxBytes": 1024 * 1024 * 10, "backupCount": 5, @@ -70,11 +75,11 @@ LOGGING = { }, # - # Отладка + # Лог отладки # "debug_file": { "level": "DEBUG", - "class": "logging.handlers.RotatingFileHandler", + "class": "concurrent_log_handler.ConcurrentRotatingFileHandler", "filename": DEBUG_LOG_FILE, "maxBytes": 1024 * 1024 * 10, "backupCount": 5, diff --git a/net/xeaf/rack/utils/settings/configs/middleware.py b/net/xeaf/rack/utils/settings/configs/middleware.py index d4f5e46..3577c7b 100644 --- a/net/xeaf/rack/utils/settings/configs/middleware.py +++ b/net/xeaf/rack/utils/settings/configs/middleware.py @@ -9,6 +9,7 @@ """ MIDDLEWARE = [ + 'net.xeaf.rack.utils.drf.CustomLoggingMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware',