middleware.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import logging
  2. import uuid
  3. from django.conf import settings
  4. from django.contrib import auth, messages
  5. from django.contrib.auth.middleware import LoginRequiredMiddleware as LoginRequiredMiddleware_
  6. from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
  7. from django.core.exceptions import ImproperlyConfigured
  8. from django.core.signals import got_request_exception
  9. from django.db import ProgrammingError, connection
  10. from django.db.utils import InternalError
  11. from django.http import Http404, HttpResponseRedirect
  12. from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
  13. from django.utils.translation import gettext_lazy as _
  14. from django_prometheus import middleware
  15. from rest_framework.views import APIView
  16. from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_
  17. from netbox.config import clear_config, get_config
  18. from netbox.metrics import Metrics
  19. from netbox.views import handler_500
  20. from utilities.api import is_api_request, is_graphql_request
  21. from utilities.error_handlers import handle_rest_api_exception
  22. from utilities.request import apply_request_processors
  23. __all__ = (
  24. 'CommonMiddleware',
  25. 'CoreMiddleware',
  26. 'LoginRequiredMiddleware',
  27. 'MaintenanceModeMiddleware',
  28. 'PrometheusAfterMiddleware',
  29. 'PrometheusBeforeMiddleware',
  30. 'RemoteUserMiddleware',
  31. 'SocialAuthExceptionMiddleware',
  32. )
  33. class CommonMiddleware(DjangoCommonMiddleware):
  34. """
  35. Subclass of Django's CommonMiddleware that suppresses the APPEND_SLASH
  36. redirect for REST API requests using an unsafe HTTP method. Redirecting a
  37. POST/PUT/PATCH/DELETE to a trailing-slash URL would either drop the request
  38. body (clients downgrade to GET on a 302) or raise a RuntimeError when
  39. DEBUG is enabled. Letting the original 404 propagate gives the caller a
  40. clear, actionable error instead.
  41. """
  42. UNSAFE_METHODS = frozenset(('DELETE', 'PATCH', 'POST', 'PUT'))
  43. def should_redirect_with_slash(self, request):
  44. if request.method in self.UNSAFE_METHODS and is_api_request(request):
  45. return False
  46. return super().should_redirect_with_slash(request)
  47. class CoreMiddleware:
  48. def __init__(self, get_response):
  49. self.get_response = get_response
  50. def __call__(self, request):
  51. # Assign a random unique ID to the request. This will be used for change logging.
  52. request.id = uuid.uuid4()
  53. # Apply all registered request processors
  54. with apply_request_processors(request):
  55. response = self.get_response(request)
  56. # Set or renew the language cookie based on the user's preference. This handles two cases:
  57. # 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
  58. # the request so we set the cookie here on the login response.
  59. # 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
  60. # the session expiry.
  61. if hasattr(request, '_language_cookie'):
  62. language = request._language_cookie
  63. elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
  64. language = request.user.config.get('locale.language')
  65. else:
  66. language = None
  67. if language:
  68. response.set_cookie(
  69. key=settings.LANGUAGE_COOKIE_NAME,
  70. value=language,
  71. max_age=request.session.get_expiry_age(),
  72. secure=settings.SESSION_COOKIE_SECURE,
  73. )
  74. # Attach the unique request ID as an HTTP header.
  75. response['X-Request-ID'] = request.id
  76. # Enable the Vary header to help with caching of HTMX responses
  77. response['Vary'] = 'HX-Request'
  78. # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
  79. if is_api_request(request):
  80. response['API-Version'] = settings.REST_FRAMEWORK_VERSION
  81. # Clear any cached dynamic config parameters after each request.
  82. clear_config()
  83. return response
  84. def process_exception(self, request, exception):
  85. """
  86. Implement custom error handling logic for production deployments.
  87. """
  88. # Don't catch exceptions when in debug mode
  89. if settings.DEBUG:
  90. return None
  91. # Cleanly handle exceptions that occur from REST or GraphQL API requests
  92. if is_api_request(request) or is_graphql_request(request):
  93. # Fire Django's got_request_exception signal so error-tracking
  94. # integrations (e.g. Sentry) capture the exception.
  95. got_request_exception.send(sender=self.__class__, request=request)
  96. return handle_rest_api_exception(request)
  97. # Ignore Http404s (defer to Django's built-in 404 handling)
  98. if isinstance(exception, Http404):
  99. return None
  100. # Determine the type of exception. If it's a common issue, return a custom error page with instructions.
  101. custom_template = None
  102. if isinstance(exception, ProgrammingError):
  103. custom_template = 'exceptions/programming_error.html'
  104. elif isinstance(exception, ImportError):
  105. custom_template = 'exceptions/import_error.html'
  106. elif isinstance(exception, PermissionError):
  107. custom_template = 'exceptions/permission_error.html'
  108. # Return a custom error message, or fall back to Django's default 500 error handling
  109. if custom_template:
  110. # Fire Django's got_request_exception signal so error-tracking
  111. # integrations (e.g. Sentry) capture the exception.
  112. got_request_exception.send(sender=self.__class__, request=request)
  113. return handler_500(request, template_name=custom_template)
  114. return None
  115. class LoginRequiredMiddleware(LoginRequiredMiddleware_):
  116. """
  117. Require authentication for all views not marked with @login_not_required, when LOGIN_REQUIRED is True.
  118. """
  119. # Third-party URLs which must remain accessible without authentication (e.g. to Prometheus scrapers)
  120. exempt_url_names = ('prometheus-django-metrics',)
  121. def process_view(self, request, view_func, view_args, view_kwargs):
  122. # TODO: Remove this check in NetBox v5.0 along with the LOGIN_REQUIRED parameter
  123. if not settings.LOGIN_REQUIRED:
  124. return None
  125. # DRF views authenticate requests themselves, and respond with a 403 rather than a login redirect
  126. view_class = getattr(view_func, 'cls', None)
  127. if isinstance(view_class, type) and issubclass(view_class, APIView):
  128. return None
  129. if (match := request.resolver_match) and match.url_name in self.exempt_url_names:
  130. return None
  131. return super().process_view(request, view_func, view_args, view_kwargs)
  132. class RemoteUserMiddleware(RemoteUserMiddleware_):
  133. """
  134. Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.
  135. """
  136. async_capable = False
  137. force_logout_if_no_header = False
  138. def __init__(self, get_response):
  139. if get_response is None:
  140. raise ValueError("get_response must be provided.")
  141. self.get_response = get_response
  142. @property
  143. def header(self):
  144. return settings.REMOTE_AUTH_HEADER
  145. def __call__(self, request):
  146. logger = logging.getLogger('netbox.authentication.RemoteUserMiddleware')
  147. # Bypass middleware if remote authentication is not enabled
  148. if not settings.REMOTE_AUTH_ENABLED:
  149. return self.get_response(request)
  150. # AuthenticationMiddleware is required so that request.user exists.
  151. if not hasattr(request, 'user'):
  152. raise ImproperlyConfigured(
  153. "The Django remote user auth middleware requires the"
  154. " authentication middleware to be installed. Edit your"
  155. " MIDDLEWARE setting to insert"
  156. " 'django.contrib.auth.middleware.AuthenticationMiddleware'"
  157. " before the RemoteUserMiddleware class.")
  158. try:
  159. username = request.META[self.header]
  160. except KeyError:
  161. # If specified header doesn't exist then remove any existing
  162. # authenticated remote-user, or return (leaving request.user set to
  163. # AnonymousUser by the AuthenticationMiddleware).
  164. if self.force_logout_if_no_header and request.user.is_authenticated:
  165. self._remove_invalid_user(request)
  166. return self.get_response(request)
  167. # If the user is already authenticated and that user is the user we are
  168. # getting passed in the headers, then the correct user is already
  169. # persisted in the session and we don't need to continue.
  170. if request.user.is_authenticated:
  171. if request.user.get_username() == self.clean_username(username, request):
  172. return self.get_response(request)
  173. # An authenticated user is associated with the request, but
  174. # it does not match the authorized user in the header.
  175. self._remove_invalid_user(request)
  176. # We are seeing this user for the first time in this session, attempt
  177. # to authenticate the user.
  178. if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
  179. logger.debug("Trying to sync Groups")
  180. user = auth.authenticate(
  181. request, remote_user=username, remote_groups=self._get_groups(request))
  182. else:
  183. user = auth.authenticate(request, remote_user=username)
  184. if user:
  185. # User is valid.
  186. # Update the User's Profile if set by request headers
  187. if settings.REMOTE_AUTH_USER_FIRST_NAME in request.META:
  188. user.first_name = request.META[settings.REMOTE_AUTH_USER_FIRST_NAME]
  189. if settings.REMOTE_AUTH_USER_LAST_NAME in request.META:
  190. user.last_name = request.META[settings.REMOTE_AUTH_USER_LAST_NAME]
  191. if settings.REMOTE_AUTH_USER_EMAIL in request.META:
  192. user.email = request.META[settings.REMOTE_AUTH_USER_EMAIL]
  193. user.save()
  194. # Set request.user and persist user in the session
  195. # by logging the user in.
  196. request.user = user
  197. auth.login(request, user)
  198. return self.get_response(request)
  199. def _get_groups(self, request):
  200. logger = logging.getLogger(
  201. 'netbox.authentication.RemoteUserMiddleware')
  202. groups_string = request.META.get(
  203. settings.REMOTE_AUTH_GROUP_HEADER, None)
  204. if groups_string:
  205. groups = groups_string.split(settings.REMOTE_AUTH_GROUP_SEPARATOR)
  206. else:
  207. groups = []
  208. logger.debug(f"Groups are {groups}")
  209. return groups
  210. class PrometheusBeforeMiddleware(middleware.PrometheusBeforeMiddleware):
  211. metrics_cls = Metrics
  212. class PrometheusAfterMiddleware(middleware.PrometheusAfterMiddleware):
  213. metrics_cls = Metrics
  214. def process_response(self, request, response):
  215. response = super().process_response(request, response)
  216. # Increment REST API request counters
  217. if is_api_request(request):
  218. method = self._method(request)
  219. name = self._get_view_name(request)
  220. self.label_metric(self.metrics.rest_api_requests, request, method=method).inc()
  221. self.label_metric(self.metrics.rest_api_requests_by_view_method, request, method=method, view=name).inc()
  222. # Increment GraphQL API request counters
  223. elif is_graphql_request(request):
  224. self.metrics.graphql_api_requests.inc()
  225. return response
  226. class MaintenanceModeMiddleware:
  227. """
  228. Middleware that checks if the application is in maintenance mode
  229. and restricts write-related operations to the database.
  230. """
  231. def __init__(self, get_response):
  232. self.get_response = get_response
  233. def __call__(self, request):
  234. if get_config().MAINTENANCE_MODE:
  235. self._set_session_type(
  236. allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
  237. )
  238. return self.get_response(request)
  239. @staticmethod
  240. def _set_session_type(allow_write):
  241. """
  242. Prevent any write-related database operations.
  243. Args:
  244. allow_write (bool): If True, write operations will be permitted.
  245. """
  246. with connection.cursor() as cursor:
  247. mode = 'READ WRITE' if allow_write else 'READ ONLY'
  248. cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
  249. def process_exception(self, request, exception):
  250. """
  251. Prevent any write-related database operations if an exception is raised.
  252. """
  253. if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError):
  254. error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
  255. 'operations. Please try again later.'
  256. if is_api_request(request) or is_graphql_request(request):
  257. return handle_rest_api_exception(request, error=error_message)
  258. messages.error(request, error_message)
  259. return HttpResponseRedirect(request.path_info)
  260. return None
  261. class SocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware_):
  262. """
  263. Subclass of python-social-auth's exception middleware which surfaces a generic, user-friendly
  264. message rather than exposing the raw social_core exception text to (typically unauthenticated)
  265. users when an SSO/SAML login fails.
  266. """
  267. def get_message(self, request, exception):
  268. return _("Single sign-on failed. Please try again or contact your administrator.")