summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHisham Mahmood <hishammahmood41@gmail.com>2024-05-05 11:21:28 +0500
committerSarah Boyce <42296566+sarahboyce@users.noreply.github.com>2024-05-22 08:51:17 +0200
commitc7fc9f20b49b5889a9a8f47de45165ac443c1a21 (patch)
tree113e55d5b047f479375638c1f17d9c127aedf618
parent7857507c7fc43350701700d4215a37baea7655f0 (diff)
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>
-rw-r--r--django/contrib/admin/sites.py6
-rw-r--r--django/contrib/auth/apps.py3
-rw-r--r--django/contrib/auth/checks.py42
-rw-r--r--django/contrib/auth/decorators.py12
-rw-r--r--django/contrib/auth/middleware.py56
-rw-r--r--django/contrib/auth/views.py7
-rw-r--r--docs/ref/checks.txt4
-rw-r--r--docs/ref/middleware.txt58
-rw-r--r--docs/ref/settings.txt5
-rw-r--r--docs/releases/5.1.txt14
-rw-r--r--docs/topics/auth/default.txt17
-rw-r--r--tests/auth_tests/test_checks.py110
-rw-r--r--tests/auth_tests/test_decorators.py35
-rw-r--r--tests/auth_tests/test_middleware.py141
-rw-r--r--tests/auth_tests/test_views.py80
-rw-r--r--tests/auth_tests/urls.py53
-rw-r--r--tests/deprecation/test_middleware_mixin.py2
17 files changed, 633 insertions, 12 deletions
diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py
index bb02cb08ac..dc67262afc 100644
--- a/django/contrib/admin/sites.py
+++ b/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.
diff --git a/django/contrib/auth/apps.py b/django/contrib/auth/apps.py
index 4882a27c42..ad6f816809 100644
--- a/django/contrib/auth/apps.py
+++ b/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)
diff --git a/django/contrib/auth/checks.py b/django/contrib/auth/checks.py
index ee8082524d..f2f9a74a6c 100644
--- a/django/contrib/auth/checks.py
+++ b/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
diff --git a/django/contrib/auth/decorators.py b/django/contrib/auth/decorators.py
index b220cc2bd3..ea1cef0795 100644
--- a/django/contrib/auth/decorators.py
+++ b/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
diff --git a/django/contrib/auth/middleware.py b/django/contrib/auth/middleware.py
index 6b8dd4340e..761929d67d 100644
--- a/django/contrib/auth/middleware.py
+++ b/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.
diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py
index 0d16104655..9a6d18bcd2 100644
--- a/django/contrib/auth/views.py
+++ b/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")
diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt
index efc8cf666a..d78a6f76b2 100644
--- a/docs/ref/checks.txt
+++ b/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``
----------------
diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt
index 63b38da0a0..ba9bef7e6f 100644
--- a/docs/ref/middleware.txt
+++ b/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.
diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
index fdd44a887d..ee25eab0dd 100644
--- a/docs/ref/settings.txt
+++ b/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
diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt
index faaa5c9833..f2b7663576 100644
--- a/docs/releases/5.1.txt
+++ b/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
--------------
diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt
index 795a1bdacc..1d2ea8132d 100644
--- a/docs/topics/auth/default.txt
+++ b/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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/auth_tests/test_checks.py b/tests/auth_tests/test_checks.py
index 5757946f95..3d70451e9d 100644
--- a/tests/auth_tests/test_checks.py
+++ b/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",
+ )
+ ],
+ )
diff --git a/tests/auth_tests/test_decorators.py b/tests/auth_tests/test_decorators.py
index 48fa915c5c..e585b28bd5 100644
--- a/tests/auth_tests/test_decorators.py
+++ b/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
diff --git a/tests/auth_tests/test_middleware.py b/tests/auth_tests/test_middleware.py
index e7c5a525cd..a837eb8b96 100644
--- a/tests/auth_tests/test_middleware.py
+++ b/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)
diff --git a/tests/auth_tests/test_views.py b/tests/auth_tests/test_views.py
index 53e33785b0..97d0448ab1 100644
--- a/tests/auth_tests/test_views.py
+++ b/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)
diff --git a/tests/auth_tests/urls.py b/tests/auth_tests/urls.py
index 99fa22e4f4..cb6a0ed1cf 100644
--- a/tests/auth_tests/urls.py
+++ b/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),
]
diff --git a/tests/deprecation/test_middleware_mixin.py b/tests/deprecation/test_middleware_mixin.py
index 3b6ad6d8ee..f4eafc14e3 100644
--- a/tests/deprecation/test_middleware_mixin.py
+++ b/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,