diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 4a36c2d98..a9ee93fc6 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -10,7 +10,9 @@ from django.db import ProgrammingError, connection from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware +from django.utils.translation import gettext_lazy as _ from django_prometheus import middleware +from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_ from netbox.config import clear_config, get_config from netbox.metrics import Metrics @@ -26,6 +28,7 @@ __all__ = ( 'PrometheusAfterMiddleware', 'PrometheusBeforeMiddleware', 'RemoteUserMiddleware', + 'SocialAuthExceptionMiddleware', ) @@ -286,3 +289,13 @@ class MaintenanceModeMiddleware: messages.error(request, error_message) return HttpResponseRedirect(request.path_info) return None + + +class SocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware_): + """ + Subclass of python-social-auth's exception middleware which surfaces a generic, user-friendly + message rather than exposing the raw social_core exception text to (typically unauthenticated) + users when an SSO/SAML login fails. + """ + def get_message(self, request, exception): + return _("Single sign-on failed. Please try again or contact your administrator.") diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e449d0688..cd5fb2e4e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -516,6 +516,7 @@ MIDDLEWARE = [ 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.CoreMiddleware', 'netbox.middleware.MaintenanceModeMiddleware', + 'netbox.middleware.SocialAuthExceptionMiddleware', ] if DEBUG: @@ -725,6 +726,13 @@ SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.user.user_details', ) +# Redirect users back to the login page (surfacing the error via the messages framework) when an +# SSO/SAML authentication failure occurs, rather than raising an HTTP 500. Full exceptions are still +# raised when DEBUG is enabled. LOGIN_URL is an absolute path which respects BASE_PATH; the social +# auth middleware passes this value directly to an HttpResponseRedirect without reversing it. +SOCIAL_AUTH_LOGIN_ERROR_URL = LOGIN_URL +SOCIAL_AUTH_RAISE_EXCEPTIONS = DEBUG + # Load all SOCIAL_AUTH_* settings from the user configuration for param in dir(configuration): if param.startswith('SOCIAL_AUTH_'): diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 55298cb2f..5221fe4b8 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,13 +1,16 @@ import datetime from django.conf import settings -from django.test import Client +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import Client, RequestFactory, SimpleTestCase from django.test.utils import override_settings from django.urls import reverse from rest_framework.test import APIClient +from social_core.exceptions import AuthFailed from core.models import ObjectType from dcim.models import Rack, Site +from netbox.middleware import SocialAuthExceptionMiddleware from users.constants import TOKEN_PREFIX from users.models import Group, ObjectPermission, Token, User from utilities.testing import TestCase @@ -697,3 +700,56 @@ class ObjectPermissionAPIViewTestCase(TestCase): url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.delete(url, format='json', **self.header) self.assertEqual(response.status_code, 204) + + +class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase): + """ + Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than + bubbling up as an HTTP 500 (see #22346). + """ + GENERIC_MESSAGE = "Single sign-on failed. Please try again or contact your administrator." + + class FakeStrategy: + # Mirror social_core's DjangoStrategy.setting(), which reads SOCIAL_AUTH_ from Django + # settings. This ensures the test exercises the real configured values (e.g. + # SOCIAL_AUTH_LOGIN_ERROR_URL) rather than hardcoded stand-ins. + def setting(self, name, default=None, backend=None): + return getattr(settings, f'SOCIAL_AUTH_{name}', default) + + class FakeBackend: + name = 'saml' + + def setUp(self): + self.factory = RequestFactory() + self.middleware = SocialAuthExceptionMiddleware(lambda request: None) + + def _make_request(self): + request = self.factory.get('/') + request.social_strategy = self.FakeStrategy() + request.backend = self.FakeBackend() + # Attach message storage (normally provided by MessageMiddleware) + setattr(request, 'session', {}) + request._messages = FallbackStorage(request) + return request + + def test_generic_message(self): + """ + The raw exception text should never be surfaced to the user. + """ + request = self._make_request() + exception = AuthFailed(self.FakeBackend(), 'raw internal SAML detail') + self.assertEqual(self.middleware.get_message(request, exception), self.GENERIC_MESSAGE) + + def test_redirect_on_failure(self): + """ + A SocialAuthBaseException should redirect to the login page with the generic message set. + """ + request = self._make_request() + exception = AuthFailed(self.FakeBackend(), 'raw internal SAML detail') + response = self.middleware.process_exception(request, exception) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, settings.SOCIAL_AUTH_LOGIN_ERROR_URL) + self.assertEqual(response.url, settings.LOGIN_URL) + messages = [str(m) for m in request._messages] + self.assertEqual(messages, [self.GENERIC_MESSAGE])