Quellcode durchsuchen

Closes #23335: Adopt Django's LoginRequiredMiddleware

Enforce LOGIN_REQUIRED for all UI views via a subclass of Django's
LoginRequiredMiddleware, rather than relying on each view to inherit a
login mixin. DRF views (which authenticate requests themselves) and the
Prometheus metrics endpoint are exempt, and views which handle
authentication themselves are marked with @login_not_required.
ConditionalLoginRequiredMixin and TokenConditionalLoginRequiredMixin are
deprecated for removal in v5.0.
Jeremy Stretch vor 2 Stunden
Ursprung
Commit
4a28cbd690

+ 20 - 0
docs/plugins/development/views.md

@@ -47,6 +47,26 @@ A URL pattern has three components:
 
 
 This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
 This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
 
 
+### Authentication
+
+!!! info "This behavior was introduced in NetBox v4.8."
+
+When [`LOGIN_REQUIRED`](../../configuration/security.md#login_required) is enabled (the default), NetBox requires authentication for every view, including those provided by plugins. An unauthenticated request is redirected to the login page. REST API views are exempt, as they authenticate requests themselves.
+
+A view intended for anonymous access, such as a health check or an inbound webhook receiver, must be marked with Django's [`login_not_required`](https://docs.djangoproject.com/en/stable/ref/decorators/#django.contrib.auth.decorators.login_not_required) decorator. A class-based view can be wrapped where its URL is registered. A view which accepts `POST` requests from external clients must also be exempted from CSRF protection with [`csrf_exempt`](https://docs.djangoproject.com/en/stable/ref/csrf/#django.views.decorators.csrf.csrf_exempt), and should authenticate those requests by other means (for example, by validating a webhook signature).
+
+```python
+from django.contrib.auth.decorators import login_not_required
+from django.urls import path
+from django.views.decorators.csrf import csrf_exempt
+from . import views
+
+urlpatterns = [
+    path('health/', login_not_required(views.HealthCheckView.as_view()), name='health_check'),
+    path('webhook/', csrf_exempt(login_not_required(views.WebhookReceiverView.as_view())), name='webhook_receiver'),
+]
+```
+
 ## NetBox Model Views
 ## NetBox Model Views
 
 
 NetBox provides several generic view classes and additional helper functions, to simplify the implementation of plugin logic. These are recommended to be used whenever possible to keep the maintenance overhead of plugins low.
 NetBox provides several generic view classes and additional helper functions, to simplify the implementation of plugin logic. These are recommended to be used whenever possible to keep the maintenance overhead of plugins low.

+ 4 - 0
netbox/account/views.py

@@ -6,6 +6,7 @@ from django.contrib import messages
 from django.contrib.auth import login as auth_login
 from django.contrib.auth import login as auth_login
 from django.contrib.auth import logout as auth_logout
 from django.contrib.auth import logout as auth_logout
 from django.contrib.auth import update_session_auth_hash
 from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.decorators import login_not_required
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.models import update_last_login
@@ -44,6 +45,7 @@ from utilities.views import register_model_view
 #
 #
 
 
 
 
+@method_decorator(login_not_required, name='dispatch')
 class LoginView(View):
 class LoginView(View):
     """
     """
     Perform user authentication via the web UI.
     Perform user authentication via the web UI.
@@ -169,6 +171,7 @@ class LoginView(View):
         return HttpResponseRedirect(redirect_url)
         return HttpResponseRedirect(redirect_url)
 
 
 
 
+@method_decorator(login_not_required, name='dispatch')
 class LogoutView(View):
 class LogoutView(View):
     """
     """
     Deauthenticate a web user.
     Deauthenticate a web user.
@@ -191,6 +194,7 @@ class LogoutView(View):
         return response
         return response
 
 
 
 
+@method_decorator(login_not_required, name='dispatch')
 class SocialAuthBeginView(View):
 class SocialAuthBeginView(View):
     """
     """
     Initiate authentication against a social auth (SSO) backend.
     Initiate authentication against a social auth (SSO) backend.

+ 1 - 1
netbox/dcim/tests/test_views.py

@@ -6846,7 +6846,7 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     def test_set_primary_anonymous_redirects_to_login(self):
     def test_set_primary_anonymous_redirects_to_login(self):
         """
         """
         With LOGIN_REQUIRED, an unauthenticated request is redirected to the login page (via
         With LOGIN_REQUIRED, an unauthenticated request is redirected to the login page (via
-        ConditionalLoginRequiredMixin) rather than 404ing or acting, for both GET and POST.
+        LoginRequiredMiddleware) rather than 404ing or acting, for both GET and POST.
         """
         """
         self.client.logout()
         self.client.logout()
         mac = MACAddress.objects.first()
         mac = MACAddress.objects.first()

+ 1 - 2
netbox/dcim/views.py

@@ -41,7 +41,6 @@ from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
 from utilities.request import safe_for_redirect
 from utilities.request import safe_for_redirect
 from utilities.views import (
 from utilities.views import (
-    ConditionalLoginRequiredMixin,
     GetRelatedModelsMixin,
     GetRelatedModelsMixin,
     GetReturnURLMixin,
     GetReturnURLMixin,
     ObjectPermissionRequiredMixin,
     ObjectPermissionRequiredMixin,
@@ -5888,7 +5887,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
 
 
 
 
 @register_model_view(MACAddress, 'set_primary')
 @register_model_view(MACAddress, 'set_primary')
-class MACAddressSetPrimaryView(ConditionalLoginRequiredMixin, GetReturnURLMixin, View):
+class MACAddressSetPrimaryView(GetReturnURLMixin, View):
     queryset = MACAddress.objects.all()
     queryset = MACAddress.objects.all()
 
 
     def get(self, request, pk):
     def get(self, request, pk):

+ 2 - 0
netbox/netbox/graphql/views.py

@@ -1,6 +1,7 @@
 import json
 import json
 
 
 from django.conf import settings
 from django.conf import settings
+from django.contrib.auth.decorators import login_not_required
 from django.contrib.auth.views import redirect_to_login
 from django.contrib.auth.views import redirect_to_login
 from django.http import HttpResponse, HttpResponseForbidden, HttpResponseNotFound
 from django.http import HttpResponse, HttpResponseForbidden, HttpResponseNotFound
 from django.urls import reverse
 from django.urls import reverse
@@ -16,6 +17,7 @@ from netbox.config import get_config
 from utilities.templatetags.builtins.tags import static_with_params
 from utilities.templatetags.builtins.tags import static_with_params
 
 
 
 
+@method_decorator(login_not_required, name='dispatch')
 class NetBoxGraphQLView(GraphQLView):
 class NetBoxGraphQLView(GraphQLView):
     """
     """
     Extends strawberry's GraphQLView to support DRF's token-based authentication.
     Extends strawberry's GraphQLView to support DRF's token-based authentication.

+ 26 - 0
netbox/netbox/middleware.py

@@ -3,6 +3,7 @@ import uuid
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib import auth, messages
 from django.contrib import auth, messages
+from django.contrib.auth.middleware import LoginRequiredMiddleware as LoginRequiredMiddleware_
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.core.signals import got_request_exception
 from django.core.signals import got_request_exception
@@ -12,6 +13,7 @@ from django.http import Http404, HttpResponseRedirect
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django_prometheus import middleware
 from django_prometheus import middleware
+from rest_framework.views import APIView
 from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_
 from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_
 
 
 from netbox.config import clear_config, get_config
 from netbox.config import clear_config, get_config
@@ -24,6 +26,7 @@ from utilities.request import apply_request_processors
 __all__ = (
 __all__ = (
     'CommonMiddleware',
     'CommonMiddleware',
     'CoreMiddleware',
     'CoreMiddleware',
+    'LoginRequiredMiddleware',
     'MaintenanceModeMiddleware',
     'MaintenanceModeMiddleware',
     'PrometheusAfterMiddleware',
     'PrometheusAfterMiddleware',
     'PrometheusBeforeMiddleware',
     'PrometheusBeforeMiddleware',
@@ -134,6 +137,29 @@ class CoreMiddleware:
         return None
         return None
 
 
 
 
+class LoginRequiredMiddleware(LoginRequiredMiddleware_):
+    """
+    Require authentication for all views not marked with @login_not_required, when LOGIN_REQUIRED is True.
+    """
+    # Third-party URLs which must remain accessible without authentication (e.g. to Prometheus scrapers)
+    exempt_url_names = ('prometheus-django-metrics',)
+
+    def process_view(self, request, view_func, view_args, view_kwargs):
+        # TODO: Remove this check in NetBox v5.0 along with the LOGIN_REQUIRED parameter
+        if not settings.LOGIN_REQUIRED:
+            return None
+
+        # DRF views authenticate requests themselves, and respond with a 403 rather than a login redirect
+        view_class = getattr(view_func, 'cls', None)
+        if isinstance(view_class, type) and issubclass(view_class, APIView):
+            return None
+
+        if (match := request.resolver_match) and match.url_name in self.exempt_url_names:
+            return None
+
+        return super().process_view(request, view_func, view_args, view_kwargs)
+
+
 class RemoteUserMiddleware(RemoteUserMiddleware_):
 class RemoteUserMiddleware(RemoteUserMiddleware_):
     """
     """
     Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.
     Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.

+ 1 - 0
netbox/netbox/settings.py

@@ -531,6 +531,7 @@ MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'django_htmx.middleware.HtmxMiddleware',
     'django_htmx.middleware.HtmxMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
+    'netbox.middleware.LoginRequiredMiddleware',
     'netbox.middleware.CoreMiddleware',
     'netbox.middleware.CoreMiddleware',
     'netbox.middleware.MaintenanceModeMiddleware',
     'netbox.middleware.MaintenanceModeMiddleware',
     'netbox.middleware.SocialAuthExceptionMiddleware',
     'netbox.middleware.SocialAuthExceptionMiddleware',

+ 96 - 3
netbox/netbox/tests/test_middleware.py

@@ -1,15 +1,20 @@
 import json
 import json
 from contextlib import contextmanager
 from contextlib import contextmanager
 from types import SimpleNamespace
 from types import SimpleNamespace
+from unittest import skipIf
 from unittest.mock import Mock, patch
 from unittest.mock import Mock, patch
 
 
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
 from django.core.signals import got_request_exception
 from django.core.signals import got_request_exception
 from django.db.utils import InternalError
 from django.db.utils import InternalError
-from django.test import RequestFactory, override_settings
-from django.urls import reverse
+from django.test import Client, RequestFactory, override_settings
+from django.urls import ResolverMatch, reverse
+from django.views.generic import View
 from rest_framework import status
 from rest_framework import status
+from rest_framework.views import APIView
 
 
-from netbox.middleware import CoreMiddleware, MaintenanceModeMiddleware
+from netbox.middleware import CoreMiddleware, LoginRequiredMiddleware, MaintenanceModeMiddleware
 from utilities.testing import TestCase
 from utilities.testing import TestCase
 
 
 
 
@@ -156,3 +161,91 @@ class CoreMiddlewareTestCase(TestCase):
             response = self.process_internal_error(request, 'Simulated maintenance mode GraphQL error')
             response = self.process_internal_error(request, 'Simulated maintenance mode GraphQL error')
 
 
         self.assert_json_500_response(response)
         self.assert_json_500_response(response)
+
+
+class LoginRequiredMiddlewareTestCase(TestCase):
+
+    def setUp(self):
+        super().setUp()
+
+        self.anonymous_client = Client()
+
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_anonymous_request_redirected_to_login(self):
+        for url in (reverse('home'), reverse('dcim:site_list')):
+            with self.subTest(url=url):
+                response = self.anonymous_client.get(url)
+                self.assertHttpStatus(response, 302)
+                self.assertEqual(response['Location'], f"{reverse('login')}?next={url}")
+
+    @override_settings(LOGIN_REQUIRED=False)
+    def test_anonymous_request_permitted_when_login_not_required(self):
+        response = self.anonymous_client.get(reverse('home'))
+        self.assertHttpStatus(response, 200)
+
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_exempt_views(self):
+        """
+        Views which must be reachable without a session respond themselves rather than redirecting to the login page.
+        """
+        login_url = reverse('login')
+        requests = (
+            ('get', reverse('login'), 200),
+            ('get', reverse('logout'), 302),
+            ('post', reverse('social_auth_begin', args=['invalid']), 404),
+            ('get', reverse('social:complete', args=['invalid']), 404),
+            ('get', reverse('media_failure'), 200),
+            ('get', reverse('api-root'), 403),
+        )
+        for method, url, status_code in requests:
+            with self.subTest(url=url):
+                response = getattr(self.anonymous_client, method)(url)
+                self.assertHttpStatus(response, status_code)
+                self.assertFalse(response.get('Location', '').startswith(login_url))
+
+        # The GraphQL API enforces LOGIN_REQUIRED itself
+        response = self.anonymous_client.post(
+            reverse('graphql'),
+            data='{"query": "{ __typename }"}',
+            content_type='application/json',
+            HTTP_ACCEPT='application/json',
+        )
+        self.assertHttpStatus(response, 403)
+        self.assertEqual(response.content, b'No credentials provided.')
+
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_process_view_exemptions(self):
+        """
+        DRF views and exempt URL names do not require login. Any other view does, even under the REST API path.
+        """
+        middleware = LoginRequiredMiddleware(lambda request: None)
+        view = View.as_view()
+        request = RequestFactory().get(f"{reverse('api-root')}non-drf-view/")
+        request.user = AnonymousUser()
+
+        self.assertIsNone(middleware.process_view(request, APIView.as_view(), (), {}))
+        self.assertHttpStatus(middleware.process_view(request, view, (), {}), 302)
+
+        request.resolver_match = ResolverMatch(view, (), {}, url_name='prometheus-django-metrics')
+        self.assertIsNone(middleware.process_view(request, view, (), {}))
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_plugin_views(self):
+        """
+        Plugin views which do not enforce authentication themselves require login. Plugin REST API views do not.
+        """
+        url = reverse('plugins:dummy_plugin:dummy_model_list')
+        response = self.anonymous_client.get(url)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(response['Location'], f"{reverse('login')}?next={url}")
+        self.assertHttpStatus(self.client.get(url), 200)
+
+        response = self.anonymous_client.get(reverse('plugins-api:dummy_plugin-api:dummymodel-list'))
+        self.assertHttpStatus(response, 403)
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=False)
+    def test_plugin_views_login_not_required(self):
+        response = self.anonymous_client.get(reverse('plugins:dummy_plugin:dummy_model_list'))
+        self.assertHttpStatus(response, 200)

+ 11 - 1
netbox/netbox/tests/test_views.py

@@ -24,7 +24,8 @@ from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMetho
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.search.backends import search_backend
 from netbox.search.backends import search_backend
 from netbox.views import generic
 from netbox.views import generic
-from users.models import User
+from users.constants import TOKEN_PREFIX
+from users.models import Token, User
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.testing import TestCase
 from utilities.testing import TestCase
 from utilities.views import get_action_url
 from utilities.views import get_action_url
@@ -352,6 +353,15 @@ class MediaViewTestCase(TestCase):
         # Unauthenticated request should return a 404 (not found)
         # Unauthenticated request should return a 404 (not found)
         self.assertHttpStatus(response, 404)
         self.assertHttpStatus(response, 404)
 
 
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_media_token_authentication(self):
+        token = Token.objects.create(version=2, user=self.user)
+        url = reverse('media', kwargs={'path': 'foo.txt'})
+        response = Client().get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}')
+
+        # Token-authenticated request should return a 404 (not found) rather than redirect to the login page
+        self.assertHttpStatus(response, 404)
+
     def test_image_attachment_with_permission(self):
     def test_image_attachment_with_permission(self):
         self.add_permissions('extras.view_imageattachment')
         self.add_permissions('extras.view_imageattachment')
         url = reverse('media', kwargs={'path': self.image_attachment.image.name})
         url = reverse('media', kwargs={'path': self.image_attachment.image.name})

+ 1 - 1
netbox/netbox/urls.py

@@ -71,7 +71,7 @@ _patterns = [
     path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'),
     path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'),
     path('graphql/workers/<str:name>.js', GraphiQLWorkerView.as_view(), name='graphiql_worker'),
     path('graphql/workers/<str:name>.js', GraphiQLWorkerView.as_view(), name='graphiql_worker'),
 
 
-    # Serving static media in Django to pipe it through LoginRequiredMiddleware
+    # Serve uploaded media via Django to enforce authentication and permissions
     path('media/<path:path>', MediaView.as_view(), name='media'),
     path('media/<path:path>', MediaView.as_view(), name='media'),
     path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
     path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
 
 

+ 3 - 0
netbox/netbox/views/errors.py

@@ -2,10 +2,12 @@ import platform
 import sys
 import sys
 
 
 from django.conf import settings
 from django.conf import settings
+from django.contrib.auth.decorators import login_not_required
 from django.http import HttpResponseServerError
 from django.http import HttpResponseServerError
 from django.shortcuts import render
 from django.shortcuts import render
 from django.template import loader
 from django.template import loader
 from django.template.exceptions import TemplateDoesNotExist
 from django.template.exceptions import TemplateDoesNotExist
+from django.utils.decorators import method_decorator
 from django.views.decorators.csrf import requires_csrf_token
 from django.views.decorators.csrf import requires_csrf_token
 from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
 from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
 from django.views.generic import View
 from django.views.generic import View
@@ -19,6 +21,7 @@ __all__ = (
 )
 )
 
 
 
 
+@method_decorator(login_not_required, name='dispatch')
 class StaticMediaFailureView(View):
 class StaticMediaFailureView(View):
     """
     """
     Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
     Display a user-friendly error message with troubleshooting tips when a static media file fails to load.

+ 5 - 5
netbox/netbox/views/generic/feature_views.py

@@ -17,7 +17,7 @@ from tenancy.forms import ContactAssignmentFilterForm
 from tenancy.models import ContactAssignment
 from tenancy.models import ContactAssignment
 from tenancy.tables import ContactAssignmentTable
 from tenancy.tables import ContactAssignmentTable
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
-from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab, get_default_template
+from utilities.views import GetReturnURLMixin, ViewTab, get_default_template
 
 
 from .base import BaseMultiObjectView
 from .base import BaseMultiObjectView
 from .object_views import ObjectChildrenView
 from .object_views import ObjectChildrenView
@@ -33,7 +33,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ObjectChangeLogView(ConditionalLoginRequiredMixin, View):
+class ObjectChangeLogView(View):
     """
     """
     Present a history of changes made to a particular object. The model class must be passed as a keyword argument
     Present a history of changes made to a particular object. The model class must be passed as a keyword argument
     when referencing this view in a URL path. For example:
     when referencing this view in a URL path. For example:
@@ -80,7 +80,7 @@ class ObjectChangeLogView(ConditionalLoginRequiredMixin, View):
         })
         })
 
 
 
 
-class ObjectImageAttachmentsView(ConditionalLoginRequiredMixin, View):
+class ObjectImageAttachmentsView(View):
     """
     """
     Render all images attached to the object as linked thumbnails.
     Render all images attached to the object as linked thumbnails.
 
 
@@ -110,7 +110,7 @@ class ObjectImageAttachmentsView(ConditionalLoginRequiredMixin, View):
         })
         })
 
 
 
 
-class ObjectJournalView(ConditionalLoginRequiredMixin, View):
+class ObjectJournalView(View):
     """
     """
     Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this
     Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this
     view in a URL path. For example:
     view in a URL path. For example:
@@ -166,7 +166,7 @@ class ObjectJournalView(ConditionalLoginRequiredMixin, View):
         })
         })
 
 
 
 
-class ObjectJobsView(ConditionalLoginRequiredMixin, View):
+class ObjectJobsView(View):
     """
     """
     Render a list of all Job assigned to an object. For example:
     Render a list of all Job assigned to an object. For example:
 
 

+ 8 - 7
netbox/netbox/views/misc.py

@@ -5,11 +5,13 @@ from collections import namedtuple
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
+from django.contrib.auth.decorators import login_not_required
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
 from django.db.models import Q
 from django.db.models import Q
 from django.http import Http404
 from django.http import Http404
-from django.shortcuts import redirect, render
+from django.shortcuts import render
+from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 from django.views.generic import View
 from django.views.static import serve
 from django.views.static import serve
@@ -26,7 +28,7 @@ from netbox.search.backends import search_backend
 from netbox.tables import SearchTable
 from netbox.tables import SearchTable
 from utilities.htmx import htmx_partial
 from utilities.htmx import htmx_partial
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
-from utilities.views import ConditionalLoginRequiredMixin, TokenConditionalLoginRequiredMixin
+from utilities.views import TokenConditionalLoginRequiredMixin
 
 
 __all__ = (
 __all__ = (
     'HomeView',
     'HomeView',
@@ -39,13 +41,10 @@ logger = logging.getLogger(f'netbox.{__name__}')
 Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
 Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
 
 
 
 
-class HomeView(ConditionalLoginRequiredMixin, View):
+class HomeView(View):
     template_name = 'home.html'
     template_name = 'home.html'
 
 
     def get(self, request):
     def get(self, request):
-        if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
-            return redirect('login')
-
         # Construct the user's custom dashboard layout
         # Construct the user's custom dashboard layout
         try:
         try:
             dashboard = get_dashboard(request.user).get_layout()
             dashboard = get_dashboard(request.user).get_layout()
@@ -80,7 +79,7 @@ class HomeView(ConditionalLoginRequiredMixin, View):
         })
         })
 
 
 
 
-class SearchView(ConditionalLoginRequiredMixin, View):
+class SearchView(View):
 
 
     def get(self, request):
     def get(self, request):
         results = []
         results = []
@@ -134,6 +133,8 @@ class SearchView(ConditionalLoginRequiredMixin, View):
         })
         })
 
 
 
 
+# Exempt from LoginRequiredMiddleware, as token authentication is performed within the view
+@method_decorator(login_not_required, name='dispatch')
 class MediaView(TokenConditionalLoginRequiredMixin, View):
 class MediaView(TokenConditionalLoginRequiredMixin, View):
     """
     """
     Serve uploaded media files, enforcing authentication and view permission on the associated object.
     Serve uploaded media files, enforcing authentication and view permission on the associated object.

+ 12 - 2
netbox/utilities/views.py

@@ -42,9 +42,13 @@ __all__ = (
 # View Mixins
 # View Mixins
 #
 #
 
 
+# TODO: Remove in NetBox v5.0
 class ConditionalLoginRequiredMixin(AccessMixin):
 class ConditionalLoginRequiredMixin(AccessMixin):
     """
     """
     Similar to Django's LoginRequiredMixin, but enforces authentication only if LOGIN_REQUIRED is True.
     Similar to Django's LoginRequiredMixin, but enforces authentication only if LOGIN_REQUIRED is True.
+
+    Deprecated: LoginRequiredMiddleware now enforces LOGIN_REQUIRED for all views. This mixin will be removed in
+    NetBox v5.0.
     """
     """
     def dispatch(self, request, *args, **kwargs):
     def dispatch(self, request, *args, **kwargs):
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
@@ -52,7 +56,13 @@ class ConditionalLoginRequiredMixin(AccessMixin):
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
 
 
 
 
+# TODO: Remove in NetBox v5.0
 class TokenConditionalLoginRequiredMixin(ConditionalLoginRequiredMixin):
 class TokenConditionalLoginRequiredMixin(ConditionalLoginRequiredMixin):
+    """
+    Extends ConditionalLoginRequiredMixin to authenticate the request by API token, if one is provided.
+
+    Deprecated: This mixin will be removed in NetBox v5.0.
+    """
     def dispatch(self, request, *args, **kwargs):
     def dispatch(self, request, *args, **kwargs):
         # Attempt to authenticate the user using a DRF token, if provided
         # Attempt to authenticate the user using a DRF token, if provided
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
@@ -67,7 +77,7 @@ class TokenConditionalLoginRequiredMixin(ConditionalLoginRequiredMixin):
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
 
 
 
 
-class ContentTypePermissionRequiredMixin(ConditionalLoginRequiredMixin):
+class ContentTypePermissionRequiredMixin(AccessMixin):
     """
     """
     Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
     Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
     This is related to ObjectPermissionRequiredMixin, except that it does not enforce object-level permissions,
     This is related to ObjectPermissionRequiredMixin, except that it does not enforce object-level permissions,
@@ -103,7 +113,7 @@ class ContentTypePermissionRequiredMixin(ConditionalLoginRequiredMixin):
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
 
 
 
 
-class ObjectPermissionRequiredMixin(ConditionalLoginRequiredMixin):
+class ObjectPermissionRequiredMixin(AccessMixin):
     """
     """
     Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
     Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
     permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
     permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered