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>
This commit is contained in:
Hisham Mahmood 2024-05-05 11:21:28 +05:00 committed by Sarah Boyce
parent 7857507c7f
commit c7fc9f20b4
17 changed files with 633 additions and 12 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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")

View File

@ -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``
---------------- ----------------

View File

@ -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.

View File

@ -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.LoginRequiredMixin`,
:class:`~django.contrib.auth.mixins.AccessMixin`. :class:`~django.contrib.auth.mixins.AccessMixin`, or when
:class:`~django.contrib.auth.middleware.LoginRequiredMiddleware` is installed.
.. setting:: LOGOUT_REDIRECT_URL .. setting:: LOGOUT_REDIRECT_URL

View File

@ -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
-------------- --------------

View File

@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -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",
)
],
)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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),
] ]

View File

@ -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,