浏览代码

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 月之前
父节点
当前提交
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.exceptions import AlreadyRegistered, NotRegistered
 from django.contrib.admin.views.autocomplete import AutocompleteJsonView
 from django.contrib.admin.views.autocomplete import AutocompleteJsonView
 from django.contrib.auth import REDIRECT_FIELD_NAME
 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.core.exceptions import ImproperlyConfigured
 from django.db.models.base import ModelBase
 from django.db.models.base import ModelBase
 from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
 from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
 from django.template.response import TemplateResponse
 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.decorators import method_decorator
 from django.utils.functional import LazyObject
 from django.utils.functional import LazyObject
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
@@ -259,6 +260,8 @@ class AdminSite:
                 return self.admin_view(view, cacheable)(*args, **kwargs)
                 return self.admin_view(view, cacheable)(*args, **kwargs)
 
 
             wrapper.admin_site = self
             wrapper.admin_site = self
+            # Used by LoginRequiredMiddleware.
+            wrapper.login_url = reverse_lazy("admin:login", current_app=self.name)
             return update_wrapper(wrapper, view)
             return update_wrapper(wrapper, view)
 
 
         # Admin-site-wide views.
         # Admin-site-wide views.
@@ -402,6 +405,7 @@ class AdminSite:
         return LogoutView.as_view(**defaults)(request)
         return LogoutView.as_view(**defaults)(request)
 
 
     @method_decorator(never_cache)
     @method_decorator(never_cache)
+    @login_not_required
     def login(self, request, extra_context=None):
     def login(self, request, extra_context=None):
         """
         """
         Display the login form for the given HttpRequest.
         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 django.utils.translation import gettext_lazy as _
 
 
 from . import get_user_model
 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 .management import create_permissions
 from .signals import user_logged_in
 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")
             user_logged_in.connect(update_last_login, dispatch_uid="update_last_login")
         checks.register(check_user_model, checks.Tags.models)
         checks.register(check_user_model, checks.Tags.models)
         checks.register(check_models_permissions, 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.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.core import checks
 from django.core import checks
+from django.utils.module_loading import import_string
 
 
 from .management import _get_builtin_permissions
 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):
 def check_user_model(app_configs=None, **kwargs):
     if app_configs is None:
     if app_configs is None:
         cls = apps.get_model(settings.AUTH_USER_MODEL)
         cls = apps.get_model(settings.AUTH_USER_MODEL)
@@ -218,3 +235,28 @@ def check_models_permissions(app_configs=None, **kwargs):
             codenames.add(codename)
             codenames.add(codename)
 
 
     return errors
     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 view_func(request, *args, **kwargs)
                 return _redirect_to_login(request)
                 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 wraps(view_func)(_view_wrapper)
 
 
     return decorator
     return decorator
@@ -82,6 +86,14 @@ def login_required(
     return actual_decorator
     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):
 def permission_required(perm, login_url=None, raise_exception=False):
     """
     """
     Decorator for views that checks whether a user has a particular permission
     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 functools import partial
+from urllib.parse import urlparse
 
 
+from django.conf import settings
 from django.contrib import auth
 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.backends import RemoteUserBackend
+from django.contrib.auth.views import redirect_to_login
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
+from django.shortcuts import resolve_url
 from django.utils.deprecation import MiddlewareMixin
 from django.utils.deprecation import MiddlewareMixin
 from django.utils.functional import SimpleLazyObject
 from django.utils.functional import SimpleLazyObject
 
 
@@ -34,6 +38,56 @@ class AuthenticationMiddleware(MiddlewareMixin):
         request.auser = partial(auser, request)
         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):
 class RemoteUserMiddleware(MiddlewareMixin):
     """
     """
     Middleware for utilizing web-server-provided authentication.
     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 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_required
+from django.contrib.auth.decorators import login_not_required, login_required
 from django.contrib.auth.forms import (
 from django.contrib.auth.forms import (
     AuthenticationForm,
     AuthenticationForm,
     PasswordChangeForm,
     PasswordChangeForm,
@@ -62,6 +62,7 @@ class RedirectURLMixin:
         raise ImproperlyConfigured("No URL to redirect to. Provide a next_page.")
         raise ImproperlyConfigured("No URL to redirect to. Provide a next_page.")
 
 
 
 
+@method_decorator(login_not_required, name="dispatch")
 class LoginView(RedirectURLMixin, FormView):
 class LoginView(RedirectURLMixin, FormView):
     """
     """
     Display the login form and handle the login action.
     Display the login form and handle the login action.
@@ -210,6 +211,7 @@ class PasswordContextMixin:
         return context
         return context
 
 
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetView(PasswordContextMixin, FormView):
 class PasswordResetView(PasswordContextMixin, FormView):
     email_template_name = "registration/password_reset_email.html"
     email_template_name = "registration/password_reset_email.html"
     extra_email_context = None
     extra_email_context = None
@@ -244,11 +246,13 @@ class PasswordResetView(PasswordContextMixin, FormView):
 INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
 INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
 
 
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetDoneView(PasswordContextMixin, TemplateView):
 class PasswordResetDoneView(PasswordContextMixin, TemplateView):
     template_name = "registration/password_reset_done.html"
     template_name = "registration/password_reset_done.html"
     title = _("Password reset sent")
     title = _("Password reset sent")
 
 
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetConfirmView(PasswordContextMixin, FormView):
 class PasswordResetConfirmView(PasswordContextMixin, FormView):
     form_class = SetPasswordForm
     form_class = SetPasswordForm
     post_reset_login = False
     post_reset_login = False
@@ -335,6 +339,7 @@ class PasswordResetConfirmView(PasswordContextMixin, FormView):
         return context
         return context
 
 
 
 
+@method_decorator(login_not_required, name="dispatch")
 class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
 class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
     template_name = "registration/password_reset_complete.html"
     template_name = "registration/password_reset_complete.html"
     title = _("Password reset complete")
     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.
   for its builtin permission names to be at most 100 characters.
 * **auth.E012**: The permission codenamed ``<codename>`` of model ``<model>``
 * **auth.E012**: The permission codenamed ``<codename>`` of model ``<model>``
   is longer than 100 characters.
   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``
 ``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
 every incoming ``HttpRequest`` object. See :ref:`Authentication in web requests
 <auth-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
 .. class:: RemoteUserMiddleware
 
 
 Middleware for utilizing web server provided authentication. See
 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.
    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`
 #. :class:`~django.contrib.messages.middleware.MessageMiddleware`
 
 
    After ``SessionMiddleware``: can use session-based storage.
    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
 The URL or :ref:`named URL pattern <naming-url-patterns>` where requests are
 redirected for login when using the
 redirected for login when using the
 :func:`~django.contrib.auth.decorators.login_required` decorator,
 :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
 .. 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
 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
 Minor features
 --------------
 --------------
 
 

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

@@ -656,8 +656,25 @@ inheritance list.
     ``is_active`` flag on a user, but the default
     ``is_active`` flag on a user, but the default
     :setting:`AUTHENTICATION_BACKENDS` reject inactive users.
     :setting:`AUTHENTICATION_BACKENDS` reject inactive users.
 
 
+.. _disable-login-required-middleware-for-views:
+
 .. currentmodule:: django.contrib.auth.decorators
 .. 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
 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.auth.models import AbstractBaseUser
+from django.contrib.sessions.middleware import SessionMiddleware
 from django.core import checks
 from django.core import checks
 from django.db import models
 from django.db import models
 from django.db.models import Q, UniqueConstraint
 from django.db.models import Q, UniqueConstraint
@@ -345,3 +354,102 @@ class ModelsPermissionsChecksTests(SimpleTestCase):
                 default_permissions = ()
                 default_permissions = ()
 
 
         self.assertEqual(checks.run_checks(self.apps.get_app_configs()), [])
         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.conf import settings
 from django.contrib.auth import models
 from django.contrib.auth import models
 from django.contrib.auth.decorators import (
 from django.contrib.auth.decorators import (
+    login_not_required,
     login_required,
     login_required,
     permission_required,
     permission_required,
     user_passes_test,
     user_passes_test,
@@ -113,6 +114,40 @@ class LoginRequiredTestCase(AuthViewsTestCase):
         await self.test_login_required_async_view(login_url="/somewhere/")
         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):
 class PermissionsRequiredDecoratorTest(TestCase):
     """
     """
     Tests for the permission_required decorator
     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.contrib.auth.models import User
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.http import HttpRequest, HttpResponse
 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):
 class TestAuthenticationMiddleware(TestCase):
@@ -50,3 +56,134 @@ class TestAuthenticationMiddleware(TestCase):
         self.assertEqual(auser, self.user)
         self.assertEqual(auser, self.user)
         auser_second = await self.request.auser()
         auser_second = await self.request.auser()
         self.assertIs(auser, auser_second)
         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.db import connection
 from django.http import HttpRequest, HttpResponse
 from django.http import HttpRequest, HttpResponse
 from django.middleware.csrf import CsrfViewMiddleware, get_token
 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.test.client import RedirectCycleError
 from django.urls import NoReverseMatch, reverse, reverse_lazy
 from django.urls import NoReverseMatch, reverse, reverse_lazy
 from django.utils.http import urlsafe_base64_encode
 from django.utils.http import urlsafe_base64_encode
@@ -472,6 +472,29 @@ class PasswordResetTest(AuthViewsTestCase):
         with self.assertRaisesMessage(ImproperlyConfigured, msg):
         with self.assertRaisesMessage(ImproperlyConfigured, msg):
             self.client.get("/reset/missing_parameters/")
             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")
 @override_settings(AUTH_USER_MODEL="auth_tests.CustomUser")
 class CustomUserPasswordResetTest(AuthViewsTestCase):
 class CustomUserPasswordResetTest(AuthViewsTestCase):
@@ -661,6 +684,38 @@ class ChangePasswordTest(AuthViewsTestCase):
             response, "/password_reset/", fetch_redirect_response=False
             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):
 class SessionAuthenticationTests(AuthViewsTestCase):
     def test_user_password_change_updates_session(self):
     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/")
         response = self.login(url="/login/get_default_redirect_url/?next=/test/")
         self.assertRedirects(response, "/test/", fetch_redirect_response=False)
         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):
 class LoginURLSettings(AuthViewsTestCase):
     """Tests for settings.LOGIN_URL."""
     """Tests for settings.LOGIN_URL."""
@@ -1355,6 +1417,22 @@ class LogoutTest(AuthViewsTestCase):
         self.assertContains(response, "Logged out")
         self.assertContains(response, "Logged out")
         self.confirm_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):
 def get_perm(Model, perm):
     ct = ContentType.objects.get_for_model(Model)
     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 import admin
 from django.contrib.auth import views
 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.forms import AuthenticationForm
 from django.contrib.auth.urls import urlpatterns as auth_urlpatterns
 from django.contrib.auth.urls import urlpatterns as auth_urlpatterns
 from django.contrib.auth.views import LoginView
 from django.contrib.auth.views import LoginView
@@ -9,6 +13,8 @@ from django.http import HttpRequest, HttpResponse
 from django.shortcuts import render
 from django.shortcuts import render
 from django.template import RequestContext, Template
 from django.template import RequestContext, Template
 from django.urls import path, re_path, reverse_lazy
 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.decorators.cache import never_cache
 from django.views.i18n import set_language
 from django.views.i18n import set_language
 
 
@@ -88,6 +94,42 @@ class CustomDefaultRedirectURLLoginView(LoginView):
         return "/custom/"
         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
 # special urls for auth test cases
 urlpatterns = auth_urlpatterns + [
 urlpatterns = auth_urlpatterns + [
     path(
     path(
@@ -198,7 +240,14 @@ urlpatterns = auth_urlpatterns + [
         "login_and_permission_required_exception/",
         "login_and_permission_required_exception/",
         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"),
     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),
     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.admindocs.middleware import XViewMiddleware
 from django.contrib.auth.middleware import (
 from django.contrib.auth.middleware import (
     AuthenticationMiddleware,
     AuthenticationMiddleware,
+    LoginRequiredMiddleware,
     RemoteUserMiddleware,
     RemoteUserMiddleware,
 )
 )
 from django.contrib.flatpages.middleware import FlatpageFallbackMiddleware
 from django.contrib.flatpages.middleware import FlatpageFallbackMiddleware
@@ -34,6 +35,7 @@ from django.utils.deprecation import MiddlewareMixin
 class MiddlewareMixinTests(SimpleTestCase):
 class MiddlewareMixinTests(SimpleTestCase):
     middlewares = [
     middlewares = [
         AuthenticationMiddleware,
         AuthenticationMiddleware,
+        LoginRequiredMiddleware,
         BrokenLinkEmailsMiddleware,
         BrokenLinkEmailsMiddleware,
         CacheMiddleware,
         CacheMiddleware,
         CommonMiddleware,
         CommonMiddleware,