Browse Source

Fixed #31405 -- Added LoginRequiredMiddleware.

Co-authored-by: Adam Johnson <me@adamj.eu>
Co-authored-by: Mehmet İnce <mehmet@mehmetince.net>
Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com>
Hisham Mahmood 10 months ago
parent
commit
c7fc9f20b4

+ 5 - 1
django/contrib/admin/sites.py

@@ -7,11 +7,12 @@ from django.contrib.admin import ModelAdmin, actions
 from django.contrib.admin.exceptions import AlreadyRegistered, NotRegistered
 from django.contrib.admin.views.autocomplete import AutocompleteJsonView
 from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth.decorators import login_not_required
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models.base import ModelBase
 from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
 from django.template.response import TemplateResponse
-from django.urls import NoReverseMatch, Resolver404, resolve, reverse
+from django.urls import NoReverseMatch, Resolver404, resolve, reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.functional import LazyObject
 from django.utils.module_loading import import_string
@@ -259,6 +260,8 @@ class AdminSite:
                 return self.admin_view(view, cacheable)(*args, **kwargs)
 
             wrapper.admin_site = self
+            # Used by LoginRequiredMiddleware.
+            wrapper.login_url = reverse_lazy("admin:login", current_app=self.name)
             return update_wrapper(wrapper, view)
 
         # Admin-site-wide views.
@@ -402,6 +405,7 @@ class AdminSite:
         return LogoutView.as_view(**defaults)(request)
 
     @method_decorator(never_cache)
+    @login_not_required
     def login(self, request, extra_context=None):
         """
         Display the login form for the given HttpRequest.

+ 2 - 1
django/contrib/auth/apps.py

@@ -5,7 +5,7 @@ from django.db.models.signals import post_migrate
 from django.utils.translation import gettext_lazy as _
 
 from . import get_user_model
-from .checks import check_models_permissions, check_user_model
+from .checks import check_middleware, check_models_permissions, check_user_model
 from .management import create_permissions
 from .signals import user_logged_in
 
@@ -28,3 +28,4 @@ class AuthConfig(AppConfig):
             user_logged_in.connect(update_last_login, dispatch_uid="update_last_login")
         checks.register(check_user_model, checks.Tags.models)
         checks.register(check_models_permissions, checks.Tags.models)
+        checks.register(check_middleware)

+ 42 - 0
django/contrib/auth/checks.py

@@ -4,10 +4,27 @@ from types import MethodType
 from django.apps import apps
 from django.conf import settings
 from django.core import checks
+from django.utils.module_loading import import_string
 
 from .management import _get_builtin_permissions
 
 
+def _subclass_index(class_path, candidate_paths):
+    """
+    Return the index of dotted class path (or a subclass of that class) in a
+    list of candidate paths. If it does not exist, return -1.
+    """
+    cls = import_string(class_path)
+    for index, path in enumerate(candidate_paths):
+        try:
+            candidate_cls = import_string(path)
+            if issubclass(candidate_cls, cls):
+                return index
+        except (ImportError, TypeError):
+            continue
+    return -1
+
+
 def check_user_model(app_configs=None, **kwargs):
     if app_configs is None:
         cls = apps.get_model(settings.AUTH_USER_MODEL)
@@ -218,3 +235,28 @@ def check_models_permissions(app_configs=None, **kwargs):
             codenames.add(codename)
 
     return errors
+
+
+def check_middleware(app_configs, **kwargs):
+    errors = []
+
+    login_required_index = _subclass_index(
+        "django.contrib.auth.middleware.LoginRequiredMiddleware",
+        settings.MIDDLEWARE,
+    )
+
+    if login_required_index != -1:
+        auth_index = _subclass_index(
+            "django.contrib.auth.middleware.AuthenticationMiddleware",
+            settings.MIDDLEWARE,
+        )
+        if auth_index == -1 or auth_index > login_required_index:
+            errors.append(
+                checks.Error(
+                    "In order to use django.contrib.auth.middleware."
+                    "LoginRequiredMiddleware, django.contrib.auth.middleware."
+                    "AuthenticationMiddleware must be defined before it in MIDDLEWARE.",
+                    id="auth.E013",
+                )
+            )
+    return errors

+ 12 - 0
django/contrib/auth/decorators.py

@@ -60,6 +60,10 @@ def user_passes_test(
                     return view_func(request, *args, **kwargs)
                 return _redirect_to_login(request)
 
+        # Attributes used by LoginRequiredMiddleware.
+        _view_wrapper.login_url = login_url
+        _view_wrapper.redirect_field_name = redirect_field_name
+
         return wraps(view_func)(_view_wrapper)
 
     return decorator
@@ -82,6 +86,14 @@ def login_required(
     return actual_decorator
 
 
+def login_not_required(view_func):
+    """
+    Decorator for views that allows access to unauthenticated requests.
+    """
+    view_func.login_required = False
+    return view_func
+
+
 def permission_required(perm, login_url=None, raise_exception=False):
     """
     Decorator for views that checks whether a user has a particular permission

+ 55 - 1
django/contrib/auth/middleware.py

@@ -1,9 +1,13 @@
 from functools import partial
+from urllib.parse import urlparse
 
+from django.conf import settings
 from django.contrib import auth
-from django.contrib.auth import load_backend
+from django.contrib.auth import REDIRECT_FIELD_NAME, load_backend
 from django.contrib.auth.backends import RemoteUserBackend
+from django.contrib.auth.views import redirect_to_login
 from django.core.exceptions import ImproperlyConfigured
+from django.shortcuts import resolve_url
 from django.utils.deprecation import MiddlewareMixin
 from django.utils.functional import SimpleLazyObject
 
@@ -34,6 +38,56 @@ class AuthenticationMiddleware(MiddlewareMixin):
         request.auser = partial(auser, request)
 
 
+class LoginRequiredMiddleware(MiddlewareMixin):
+    """
+    Middleware that redirects all unauthenticated requests to a login page.
+
+    Views using the login_not_required decorator will not be redirected.
+    """
+
+    redirect_field_name = REDIRECT_FIELD_NAME
+
+    def process_view(self, request, view_func, view_args, view_kwargs):
+        if request.user.is_authenticated:
+            return None
+
+        if not getattr(view_func, "login_required", True):
+            return None
+
+        return self.handle_no_permission(request, view_func)
+
+    def get_login_url(self, view_func):
+        login_url = getattr(view_func, "login_url", None) or settings.LOGIN_URL
+        if not login_url:
+            raise ImproperlyConfigured(
+                "No login URL to redirect to. Define settings.LOGIN_URL or "
+                "provide a login_url via the 'django.contrib.auth.decorators."
+                "login_required' decorator."
+            )
+        return str(login_url)
+
+    def get_redirect_field_name(self, view_func):
+        return getattr(view_func, "redirect_field_name", self.redirect_field_name)
+
+    def handle_no_permission(self, request, view_func):
+        path = request.build_absolute_uri()
+        resolved_login_url = resolve_url(self.get_login_url(view_func))
+        # If the login url is the same scheme and net location then use the
+        # path as the "next" url.
+        login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
+        current_scheme, current_netloc = urlparse(path)[:2]
+        if (not login_scheme or login_scheme == current_scheme) and (
+            not login_netloc or login_netloc == current_netloc
+        ):
+            path = request.get_full_path()
+
+        return redirect_to_login(
+            path,
+            resolved_login_url,
+            self.get_redirect_field_name(view_func),
+        )
+
+
 class RemoteUserMiddleware(MiddlewareMixin):
     """
     Middleware for utilizing web-server-provided authentication.

+ 6 - 1
django/contrib/auth/views.py

@@ -7,7 +7,7 @@ from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
 from django.contrib.auth import login as auth_login
 from django.contrib.auth import logout as auth_logout
 from django.contrib.auth import update_session_auth_hash
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_not_required, login_required
 from django.contrib.auth.forms import (
     AuthenticationForm,
     PasswordChangeForm,
@@ -62,6 +62,7 @@ class RedirectURLMixin:
         raise ImproperlyConfigured("No URL to redirect to. Provide a next_page.")
 
 
+@method_decorator(login_not_required, name="dispatch")
 class LoginView(RedirectURLMixin, FormView):
     """
     Display the login form and handle the login action.
@@ -210,6 +211,7 @@ class PasswordContextMixin:
         return context
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetView(PasswordContextMixin, FormView):
     email_template_name = "registration/password_reset_email.html"
     extra_email_context = None
@@ -244,11 +246,13 @@ class PasswordResetView(PasswordContextMixin, FormView):
 INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetDoneView(PasswordContextMixin, TemplateView):
     template_name = "registration/password_reset_done.html"
     title = _("Password reset sent")
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetConfirmView(PasswordContextMixin, FormView):
     form_class = SetPasswordForm
     post_reset_login = False
@@ -335,6 +339,7 @@ class PasswordResetConfirmView(PasswordContextMixin, FormView):
         return context
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
     template_name = "registration/password_reset_complete.html"
     title = _("Password reset complete")

+ 4 - 0
docs/ref/checks.txt

@@ -868,6 +868,10 @@ The following checks are performed on the default
   for its builtin permission names to be at most 100 characters.
 * **auth.E012**: The permission codenamed ``<codename>`` of model ``<model>``
   is longer than 100 characters.
+* **auth.E013**: In order to use
+  :class:`django.contrib.auth.middleware.LoginRequiredMiddleware`,
+  :class:`django.contrib.auth.middleware.AuthenticationMiddleware` must be
+  defined before it in MIDDLEWARE.
 
 ``contenttypes``
 ----------------

+ 58 - 0
docs/ref/middleware.txt

@@ -495,6 +495,58 @@ Adds the ``user`` attribute, representing the currently-logged-in user, to
 every incoming ``HttpRequest`` object. See :ref:`Authentication in web requests
 <auth-web-requests>`.
 
+.. class:: LoginRequiredMiddleware
+
+.. versionadded:: 5.1
+
+Redirects all unauthenticated requests to a login page. For admin views, this
+redirects to the admin login. For all other views, this will redirect to
+:setting:`settings.LOGIN_URL <LOGIN_URL>`. This can be customized by using the
+:func:`~.django.contrib.auth.decorators.login_required` decorator and setting
+``login_url`` or ``redirect_field_name`` for the view. For example::
+
+    @method_decorator(
+        login_required(login_url="/login/", redirect_field_name="redirect_to"),
+        name="dispatch",
+    )
+    class MyView(View):
+        pass
+
+
+    @login_required(login_url="/login/", redirect_field_name="redirect_to")
+    def my_view(request): ...
+
+Views using the :func:`~django.contrib.auth.decorators.login_not_required`
+decorator are exempt from this requirement.
+
+.. admonition:: Ensure that your login view does not require a login.
+
+    To prevent infinite redirects, ensure you have
+    :ref:`enabled unauthenticated requests
+    <disable-login-required-middleware-for-views>` to your login view.
+
+**Methods and Attributes**
+
+.. attribute:: redirect_field_name
+
+    Defaults to ``"next"``.
+
+.. method:: get_login_url()
+
+    Returns the URL that unauthenticated requests will be redirected to. If
+    defined, this returns the ``login_url`` set on the
+    :func:`~.django.contrib.auth.decorators.login_required` decorator. Defaults
+    to :setting:`settings.LOGIN_URL <LOGIN_URL>`.
+
+.. method:: get_redirect_field_name()
+
+    Returns the name of the query parameter that contains the URL the user
+    should be redirected to after a successful login. If defined, this returns
+    the ``redirect_field_name`` set on the
+    :func:`~.django.contrib.auth.decorators.login_required` decorator. Defaults
+    to :attr:`redirect_field_name`. If ``None`` is returned, a query parameter
+    won't be added.
+
 .. class:: RemoteUserMiddleware
 
 Middleware for utilizing web server provided authentication. See
@@ -597,6 +649,12 @@ Here are some hints about the ordering of various Django middleware classes:
 
    After ``SessionMiddleware``: uses session storage.
 
+#. :class:`~django.contrib.auth.middleware.LoginRequiredMiddleware`
+
+   .. versionadded:: 5.1
+
+   After ``AuthenticationMiddleware``: uses user object.
+
 #. :class:`~django.contrib.messages.middleware.MessageMiddleware`
 
    After ``SessionMiddleware``: can use session-based storage.

+ 3 - 2
docs/ref/settings.txt

@@ -3060,8 +3060,9 @@ Default: ``'/accounts/login/'``
 The URL or :ref:`named URL pattern <naming-url-patterns>` where requests are
 redirected for login when using the
 :func:`~django.contrib.auth.decorators.login_required` decorator,
-:class:`~django.contrib.auth.mixins.LoginRequiredMixin`, or
-:class:`~django.contrib.auth.mixins.AccessMixin`.
+:class:`~django.contrib.auth.mixins.LoginRequiredMixin`,
+:class:`~django.contrib.auth.mixins.AccessMixin`, or when
+:class:`~django.contrib.auth.middleware.LoginRequiredMiddleware` is installed.
 
 .. setting:: LOGOUT_REDIRECT_URL
 

+ 14 - 0
docs/releases/5.1.txt

@@ -26,6 +26,20 @@ only officially support the latest release of each series.
 What's new in Django 5.1
 ========================
 
+Middleware to require authentication by default
+-----------------------------------------------
+
+The new :class:`~django.contrib.auth.middleware.LoginRequiredMiddleware`
+redirects all unauthenticated requests to a login page. Views can allow
+unauthenticated requests by using the new
+:func:`~django.contrib.auth.decorators.login_not_required` decorator.
+
+The :class:`~django.contrib.auth.middleware.LoginRequiredMiddleware` respects
+the ``login_url`` and ``redirect_field_name`` values set via the
+:func:`~.django.contrib.auth.decorators.login_required` decorator, but does not
+support setting ``login_url`` or ``redirect_field_name`` via the
+:class:`~django.contrib.auth.mixins.LoginRequiredMixin`.
+
 Minor features
 --------------
 

+ 17 - 0
docs/topics/auth/default.txt

@@ -656,8 +656,25 @@ inheritance list.
     ``is_active`` flag on a user, but the default
     :setting:`AUTHENTICATION_BACKENDS` reject inactive users.
 
+.. _disable-login-required-middleware-for-views:
+
 .. currentmodule:: django.contrib.auth.decorators
 
+The ``login_not_required`` decorator
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 5.1
+
+When :class:`~django.contrib.auth.middleware.LoginRequiredMiddleware` is
+installed, all views require authentication by default. Some views, such as the
+login view, may need to disable this behavior.
+
+.. function:: login_not_required()
+
+    Allows unauthenticated requests without redirecting to the login page when
+    :class:`~django.contrib.auth.middleware.LoginRequiredMiddleware` is
+    installed.
+
 Limiting access to logged-in users that pass a test
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 109 - 1
tests/auth_tests/test_checks.py

@@ -1,5 +1,14 @@
-from django.contrib.auth.checks import check_models_permissions, check_user_model
+from django.contrib.auth.checks import (
+    check_middleware,
+    check_models_permissions,
+    check_user_model,
+)
+from django.contrib.auth.middleware import (
+    AuthenticationMiddleware,
+    LoginRequiredMiddleware,
+)
 from django.contrib.auth.models import AbstractBaseUser
+from django.contrib.sessions.middleware import SessionMiddleware
 from django.core import checks
 from django.db import models
 from django.db.models import Q, UniqueConstraint
@@ -345,3 +354,102 @@ class ModelsPermissionsChecksTests(SimpleTestCase):
                 default_permissions = ()
 
         self.assertEqual(checks.run_checks(self.apps.get_app_configs()), [])
+
+
+class LoginRequiredMiddlewareSubclass(LoginRequiredMiddleware):
+    redirect_field_name = "redirect_to"
+
+
+class AuthenticationMiddlewareSubclass(AuthenticationMiddleware):
+    pass
+
+
+class SessionMiddlewareSubclass(SessionMiddleware):
+    pass
+
+
+@override_system_checks([check_middleware])
+class MiddlewareChecksTests(SimpleTestCase):
+    @override_settings(
+        MIDDLEWARE=[
+            "auth_tests.test_checks.SessionMiddlewareSubclass",
+            "auth_tests.test_checks.AuthenticationMiddlewareSubclass",
+            "auth_tests.test_checks.LoginRequiredMiddlewareSubclass",
+        ]
+    )
+    def test_middleware_subclasses(self):
+        errors = checks.run_checks()
+        self.assertEqual(errors, [])
+
+    @override_settings(
+        MIDDLEWARE=[
+            "auth_tests.test_checks",
+            "auth_tests.test_checks.NotExist",
+        ]
+    )
+    def test_invalid_middleware_skipped(self):
+        errors = checks.run_checks()
+        self.assertEqual(errors, [])
+
+    @override_settings(
+        MIDDLEWARE=[
+            "django.contrib.does.not.Exist",
+            "django.contrib.sessions.middleware.SessionMiddleware",
+            "django.contrib.auth.middleware.AuthenticationMiddleware",
+            "django.contrib.auth.middleware.LoginRequiredMiddleware",
+        ]
+    )
+    def test_check_ignores_import_error_in_middleware(self):
+        errors = checks.run_checks()
+        self.assertEqual(errors, [])
+
+    @override_settings(
+        MIDDLEWARE=[
+            "django.contrib.sessions.middleware.SessionMiddleware",
+            "django.contrib.auth.middleware.AuthenticationMiddleware",
+            "django.contrib.auth.middleware.LoginRequiredMiddleware",
+        ]
+    )
+    def test_correct_order_with_login_required_middleware(self):
+        errors = checks.run_checks()
+        self.assertEqual(errors, [])
+
+    @override_settings(
+        MIDDLEWARE=[
+            "django.contrib.auth.middleware.LoginRequiredMiddleware",
+            "django.contrib.auth.middleware.AuthenticationMiddleware",
+            "django.contrib.sessions.middleware.SessionMiddleware",
+        ]
+    )
+    def test_incorrect_order_with_login_required_middleware(self):
+        errors = checks.run_checks()
+        self.assertEqual(
+            errors,
+            [
+                checks.Error(
+                    "In order to use django.contrib.auth.middleware."
+                    "LoginRequiredMiddleware, django.contrib.auth.middleware."
+                    "AuthenticationMiddleware must be defined before it in MIDDLEWARE.",
+                    id="auth.E013",
+                )
+            ],
+        )
+
+    @override_settings(
+        MIDDLEWARE=[
+            "django.contrib.auth.middleware.LoginRequiredMiddleware",
+        ]
+    )
+    def test_missing_authentication_with_login_required_middleware(self):
+        errors = checks.run_checks()
+        self.assertEqual(
+            errors,
+            [
+                checks.Error(
+                    "In order to use django.contrib.auth.middleware."
+                    "LoginRequiredMiddleware, django.contrib.auth.middleware."
+                    "AuthenticationMiddleware must be defined before it in MIDDLEWARE.",
+                    id="auth.E013",
+                )
+            ],
+        )

+ 35 - 0
tests/auth_tests/test_decorators.py

@@ -5,6 +5,7 @@ from asgiref.sync import sync_to_async
 from django.conf import settings
 from django.contrib.auth import models
 from django.contrib.auth.decorators import (
+    login_not_required,
     login_required,
     permission_required,
     user_passes_test,
@@ -113,6 +114,40 @@ class LoginRequiredTestCase(AuthViewsTestCase):
         await self.test_login_required_async_view(login_url="/somewhere/")
 
 
+class LoginNotRequiredTestCase(TestCase):
+    """
+    Tests the login_not_required decorators
+    """
+
+    def test_callable(self):
+        """
+        login_not_required is assignable to callable objects.
+        """
+
+        class CallableView:
+            def __call__(self, *args, **kwargs):
+                pass
+
+        login_not_required(CallableView())
+
+    def test_view(self):
+        """
+        login_not_required is assignable to normal views.
+        """
+
+        def normal_view(request):
+            pass
+
+        login_not_required(normal_view)
+
+    def test_decorator_marks_view_as_login_not_required(self):
+        @login_not_required
+        def view(request):
+            return HttpResponse()
+
+        self.assertFalse(view.login_required)
+
+
 class PermissionsRequiredDecoratorTest(TestCase):
     """
     Tests for the permission_required decorator

+ 139 - 2
tests/auth_tests/test_middleware.py

@@ -1,8 +1,14 @@
-from django.contrib.auth.middleware import AuthenticationMiddleware
+from django.conf import settings
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth.middleware import (
+    AuthenticationMiddleware,
+    LoginRequiredMiddleware,
+)
 from django.contrib.auth.models import User
 from django.core.exceptions import ImproperlyConfigured
 from django.http import HttpRequest, HttpResponse
-from django.test import TestCase
+from django.test import TestCase, modify_settings, override_settings
+from django.urls import reverse
 
 
 class TestAuthenticationMiddleware(TestCase):
@@ -50,3 +56,134 @@ class TestAuthenticationMiddleware(TestCase):
         self.assertEqual(auser, self.user)
         auser_second = await self.request.auser()
         self.assertIs(auser, auser_second)
+
+
+@override_settings(ROOT_URLCONF="auth_tests.urls")
+@modify_settings(
+    MIDDLEWARE={"append": "django.contrib.auth.middleware.LoginRequiredMiddleware"}
+)
+class TestLoginRequiredMiddleware(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(
+            "test_user", "test@example.com", "test_password"
+        )
+
+    def setUp(self):
+        self.middleware = LoginRequiredMiddleware(lambda req: HttpResponse())
+        self.request = HttpRequest()
+
+    def test_public_paths(self):
+        paths = ["public_view", "public_function_view"]
+        for path in paths:
+            response = self.client.get(f"/{path}/")
+            self.assertEqual(response.status_code, 200)
+
+    def test_protected_paths(self):
+        paths = ["protected_view", "protected_function_view"]
+        for path in paths:
+            response = self.client.get(f"/{path}/")
+            self.assertRedirects(
+                response,
+                settings.LOGIN_URL + f"?next=/{path}/",
+                fetch_redirect_response=False,
+            )
+
+    def test_login_required_paths(self):
+        paths = ["login_required_cbv_view", "login_required_decorator_view"]
+        for path in paths:
+            response = self.client.get(f"/{path}/")
+            self.assertRedirects(
+                response,
+                "/custom_login/" + f"?step=/{path}/",
+                fetch_redirect_response=False,
+            )
+
+    def test_admin_path(self):
+        admin_url = reverse("admin:index")
+        response = self.client.get(admin_url)
+        self.assertRedirects(
+            response,
+            reverse("admin:login") + f"?next={admin_url}",
+            target_status_code=200,
+        )
+
+    def test_non_existent_path(self):
+        response = self.client.get("/non_existent/")
+        self.assertEqual(response.status_code, 404)
+
+    def test_paths_with_logged_in_user(self):
+        paths = [
+            "public_view",
+            "public_function_view",
+            "protected_view",
+            "protected_function_view",
+            "login_required_cbv_view",
+            "login_required_decorator_view",
+        ]
+        self.client.login(username="test_user", password="test_password")
+        for path in paths:
+            response = self.client.get(f"/{path}/")
+            self.assertEqual(response.status_code, 200)
+
+    def test_get_login_url_from_view_func(self):
+        def view_func(request):
+            return HttpResponse()
+
+        view_func.login_url = "/custom_login/"
+        login_url = self.middleware.get_login_url(view_func)
+        self.assertEqual(login_url, "/custom_login/")
+
+    @override_settings(LOGIN_URL="/settings_login/")
+    def test_get_login_url_from_settings(self):
+        login_url = self.middleware.get_login_url(lambda: None)
+        self.assertEqual(login_url, "/settings_login/")
+
+    @override_settings(LOGIN_URL=None)
+    def test_get_login_url_no_login_url(self):
+        with self.assertRaises(ImproperlyConfigured) as e:
+            self.middleware.get_login_url(lambda: None)
+        self.assertEqual(
+            str(e.exception),
+            "No login URL to redirect to. Define settings.LOGIN_URL or provide "
+            "a login_url via the 'django.contrib.auth.decorators.login_required' "
+            "decorator.",
+        )
+
+    def test_get_redirect_field_name_from_view_func(self):
+        def view_func(request):
+            return HttpResponse()
+
+        view_func.redirect_field_name = "next_page"
+        redirect_field_name = self.middleware.get_redirect_field_name(view_func)
+        self.assertEqual(redirect_field_name, "next_page")
+
+    @override_settings(
+        MIDDLEWARE=[
+            "django.contrib.sessions.middleware.SessionMiddleware",
+            "django.contrib.auth.middleware.AuthenticationMiddleware",
+            "auth_tests.test_checks.LoginRequiredMiddlewareSubclass",
+        ],
+        LOGIN_URL="/settings_login/",
+    )
+    def test_login_url_resolve_logic(self):
+        paths = ["login_required_cbv_view", "login_required_decorator_view"]
+        for path in paths:
+            response = self.client.get(f"/{path}/")
+            self.assertRedirects(
+                response,
+                "/custom_login/" + f"?step=/{path}/",
+                fetch_redirect_response=False,
+            )
+        paths = ["protected_view", "protected_function_view"]
+        for path in paths:
+            response = self.client.get(f"/{path}/")
+            self.assertRedirects(
+                response,
+                f"/settings_login/?redirect_to=/{path}/",
+                fetch_redirect_response=False,
+            )
+
+    def test_get_redirect_field_name_default(self):
+        redirect_field_name = self.middleware.get_redirect_field_name(lambda: None)
+        self.assertEqual(redirect_field_name, REDIRECT_FIELD_NAME)

+ 79 - 1
tests/auth_tests/test_views.py

@@ -32,7 +32,7 @@ from django.core.exceptions import ImproperlyConfigured
 from django.db import connection
 from django.http import HttpRequest, HttpResponse
 from django.middleware.csrf import CsrfViewMiddleware, get_token
-from django.test import Client, TestCase, override_settings
+from django.test import Client, TestCase, modify_settings, override_settings
 from django.test.client import RedirectCycleError
 from django.urls import NoReverseMatch, reverse, reverse_lazy
 from django.utils.http import urlsafe_base64_encode
@@ -472,6 +472,29 @@ class PasswordResetTest(AuthViewsTestCase):
         with self.assertRaisesMessage(ImproperlyConfigured, msg):
             self.client.get("/reset/missing_parameters/")
 
+    @modify_settings(
+        MIDDLEWARE={"append": "django.contrib.auth.middleware.LoginRequiredMiddleware"}
+    )
+    def test_access_under_login_required_middleware(self):
+        reset_urls = [
+            reverse("password_reset"),
+            reverse("password_reset_done"),
+            reverse("password_reset_confirm", kwargs={"uidb64": "abc", "token": "def"}),
+            reverse("password_reset_complete"),
+        ]
+
+        for url in reset_urls:
+            with self.subTest(url=url):
+                response = self.client.get(url)
+                self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            "/password_reset/", {"email": "staffmember@example.com"}
+        )
+        self.assertRedirects(
+            response, "/password_reset/done/", fetch_redirect_response=False
+        )
+
 
 @override_settings(AUTH_USER_MODEL="auth_tests.CustomUser")
 class CustomUserPasswordResetTest(AuthViewsTestCase):
@@ -661,6 +684,38 @@ class ChangePasswordTest(AuthViewsTestCase):
             response, "/password_reset/", fetch_redirect_response=False
         )
 
+    @modify_settings(
+        MIDDLEWARE={"append": "django.contrib.auth.middleware.LoginRequiredMiddleware"}
+    )
+    def test_access_under_login_required_middleware(self):
+        response = self.client.post(
+            "/password_change/",
+            {
+                "old_password": "password",
+                "new_password1": "password1",
+                "new_password2": "password1",
+            },
+        )
+        self.assertRedirects(
+            response,
+            settings.LOGIN_URL + "?next=/password_change/",
+            fetch_redirect_response=False,
+        )
+
+        self.login()
+
+        response = self.client.post(
+            "/password_change/",
+            {
+                "old_password": "password",
+                "new_password1": "password1",
+                "new_password2": "password1",
+            },
+        )
+        self.assertRedirects(
+            response, "/password_change/done/", fetch_redirect_response=False
+        )
+
 
 class SessionAuthenticationTests(AuthViewsTestCase):
     def test_user_password_change_updates_session(self):
@@ -904,6 +959,13 @@ class LoginTest(AuthViewsTestCase):
         response = self.login(url="/login/get_default_redirect_url/?next=/test/")
         self.assertRedirects(response, "/test/", fetch_redirect_response=False)
 
+    @modify_settings(
+        MIDDLEWARE={"append": "django.contrib.auth.middleware.LoginRequiredMiddleware"}
+    )
+    def test_access_under_login_required_middleware(self):
+        response = self.client.get(reverse("login"))
+        self.assertEqual(response.status_code, 200)
+
 
 class LoginURLSettings(AuthViewsTestCase):
     """Tests for settings.LOGIN_URL."""
@@ -1355,6 +1417,22 @@ class LogoutTest(AuthViewsTestCase):
         self.assertContains(response, "Logged out")
         self.confirm_logged_out()
 
+    @modify_settings(
+        MIDDLEWARE={"append": "django.contrib.auth.middleware.LoginRequiredMiddleware"}
+    )
+    def test_access_under_login_required_middleware(self):
+        response = self.client.post("/logout/")
+        self.assertRedirects(
+            response,
+            settings.LOGIN_URL + "?next=/logout/",
+            fetch_redirect_response=False,
+        )
+
+        self.login()
+
+        response = self.client.post("/logout/")
+        self.assertEqual(response.status_code, 200)
+
 
 def get_perm(Model, perm):
     ct = ContentType.objects.get_for_model(Model)

+ 51 - 2
tests/auth_tests/urls.py

@@ -1,6 +1,10 @@
 from django.contrib import admin
 from django.contrib.auth import views
-from django.contrib.auth.decorators import login_required, permission_required
+from django.contrib.auth.decorators import (
+    login_not_required,
+    login_required,
+    permission_required,
+)
 from django.contrib.auth.forms import AuthenticationForm
 from django.contrib.auth.urls import urlpatterns as auth_urlpatterns
 from django.contrib.auth.views import LoginView
@@ -9,6 +13,8 @@ from django.http import HttpRequest, HttpResponse
 from django.shortcuts import render
 from django.template import RequestContext, Template
 from django.urls import path, re_path, reverse_lazy
+from django.utils.decorators import method_decorator
+from django.views import View
 from django.views.decorators.cache import never_cache
 from django.views.i18n import set_language
 
@@ -88,6 +94,42 @@ class CustomDefaultRedirectURLLoginView(LoginView):
         return "/custom/"
 
 
+class EmptyResponseBaseView(View):
+    def get(self, request, *args, **kwargs):
+        return HttpResponse()
+
+
+@method_decorator(login_not_required, name="dispatch")
+class PublicView(EmptyResponseBaseView):
+    pass
+
+
+class ProtectedView(EmptyResponseBaseView):
+    pass
+
+
+@method_decorator(
+    login_required(login_url="/custom_login/", redirect_field_name="step"),
+    name="dispatch",
+)
+class ProtectedViewWithCustomLoginRequired(EmptyResponseBaseView):
+    pass
+
+
+@login_not_required
+def public_view(request):
+    return HttpResponse()
+
+
+def protected_view(request):
+    return HttpResponse()
+
+
+@login_required(login_url="/custom_login/", redirect_field_name="step")
+def protected_view_with_login_required_decorator(request):
+    return HttpResponse()
+
+
 # special urls for auth test cases
 urlpatterns = auth_urlpatterns + [
     path(
@@ -198,7 +240,14 @@ urlpatterns = auth_urlpatterns + [
         "login_and_permission_required_exception/",
         login_and_permission_required_exception,
     ),
+    path("public_view/", PublicView.as_view()),
+    path("public_function_view/", public_view),
+    path("protected_view/", ProtectedView.as_view()),
+    path("protected_function_view/", protected_view),
+    path(
+        "login_required_decorator_view/", protected_view_with_login_required_decorator
+    ),
+    path("login_required_cbv_view/", ProtectedViewWithCustomLoginRequired.as_view()),
     path("setlang/", set_language, name="set_language"),
-    # This line is only required to render the password reset with is_admin=True
     path("admin/", admin.site.urls),
 ]

+ 2 - 0
tests/deprecation/test_middleware_mixin.py

@@ -5,6 +5,7 @@ from asgiref.sync import async_to_sync, iscoroutinefunction
 from django.contrib.admindocs.middleware import XViewMiddleware
 from django.contrib.auth.middleware import (
     AuthenticationMiddleware,
+    LoginRequiredMiddleware,
     RemoteUserMiddleware,
 )
 from django.contrib.flatpages.middleware import FlatpageFallbackMiddleware
@@ -34,6 +35,7 @@ from django.utils.deprecation import MiddlewareMixin
 class MiddlewareMixinTests(SimpleTestCase):
     middlewares = [
         AuthenticationMiddleware,
+        LoginRequiredMiddleware,
         BrokenLinkEmailsMiddleware,
         CacheMiddleware,
         CommonMiddleware,