From c7fc9f20b49b5889a9a8f47de45165ac443c1a21 Mon Sep 17 00:00:00 2001 From: Hisham Mahmood Date: Sun, 5 May 2024 11:21:28 +0500 Subject: [PATCH] Fixed #31405 -- Added LoginRequiredMiddleware. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adam Johnson Co-authored-by: Mehmet İnce Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> --- django/contrib/admin/sites.py | 6 +- django/contrib/auth/apps.py | 3 +- django/contrib/auth/checks.py | 42 ++++++ django/contrib/auth/decorators.py | 12 ++ django/contrib/auth/middleware.py | 56 +++++++- django/contrib/auth/views.py | 7 +- docs/ref/checks.txt | 4 + docs/ref/middleware.txt | 58 +++++++++ docs/ref/settings.txt | 5 +- docs/releases/5.1.txt | 14 ++ docs/topics/auth/default.txt | 17 +++ tests/auth_tests/test_checks.py | 110 +++++++++++++++- tests/auth_tests/test_decorators.py | 35 +++++ tests/auth_tests/test_middleware.py | 141 ++++++++++++++++++++- tests/auth_tests/test_views.py | 80 +++++++++++- tests/auth_tests/urls.py | 53 +++++++- tests/deprecation/test_middleware_mixin.py | 2 + 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 ```` of 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 `. +.. 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 `. 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 + ` 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 `. + +.. 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 ` 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,