Skip to content

Commit

Permalink
Fixed #31405 -- Added LoginRequiredMiddleware.
Browse files Browse the repository at this point in the history
Co-authored-by: Adam Johnson <[email protected]>
Co-authored-by: Mehmet İnce <[email protected]>
Co-authored-by: Sarah Boyce <[email protected]>
  • Loading branch information
4 people committed May 22, 2024
1 parent 9a27c76 commit c86e727
Show file tree
Hide file tree
Showing 17 changed files with 633 additions and 12 deletions.
6 changes: 5 additions & 1 deletion django/contrib/admin/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
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
Expand Down Expand Up @@ -259,6 +260,8 @@ def wrapper(*args, **kwargs):
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.
Expand Down Expand Up @@ -402,6 +405,7 @@ def logout(self, request, extra_context=None):
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.
Expand Down
3 changes: 2 additions & 1 deletion django/contrib/auth/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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

Expand All @@ -28,3 +28,4 @@ def ready(self):
user_logged_in.connect(update_last_login, dispatch_uid="update_last_login")
checks.register(check_user_model, checks.Tags.models)
checks.register(check_models_permissions, checks.Tags.models)
checks.register(check_middleware)
42 changes: 42 additions & 0 deletions django/contrib/auth/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,27 @@
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)
Expand Down Expand Up @@ -218,3 +235,28 @@ def check_models_permissions(app_configs=None, **kwargs):
codenames.add(codename)

return errors


def check_middleware(app_configs, **kwargs):
errors = []

login_required_index = _subclass_index(
"django.contrib.auth.middleware.LoginRequiredMiddleware",
settings.MIDDLEWARE,
)

if login_required_index != -1:
auth_index = _subclass_index(
"django.contrib.auth.middleware.AuthenticationMiddleware",
settings.MIDDLEWARE,
)
if auth_index == -1 or auth_index > login_required_index:
errors.append(
checks.Error(
"In order to use django.contrib.auth.middleware."
"LoginRequiredMiddleware, django.contrib.auth.middleware."
"AuthenticationMiddleware must be defined before it in MIDDLEWARE.",
id="auth.E013",
)
)
return errors
12 changes: 12 additions & 0 deletions django/contrib/auth/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ def _view_wrapper(request, *args, **kwargs):
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
Expand All @@ -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
Expand Down
56 changes: 55 additions & 1 deletion django/contrib/auth/middleware.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -34,6 +38,56 @@ def process_request(self, 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):
"""
Middleware for utilizing web-server-provided authentication.
Expand Down
7 changes: 6 additions & 1 deletion django/contrib/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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,
Expand Down Expand Up @@ -62,6 +62,7 @@ def get_default_redirect_url(self):
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.
Expand Down Expand Up @@ -210,6 +211,7 @@ def get_context_data(self, **kwargs):
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
Expand Down Expand Up @@ -244,11 +246,13 @@ def form_valid(self, form):
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
Expand Down Expand Up @@ -335,6 +339,7 @@ def get_context_data(self, **kwargs):
return context


@method_decorator(login_not_required, name="dispatch")
class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
template_name = "registration/password_reset_complete.html"
title = _("Password reset complete")
Expand Down
4 changes: 4 additions & 0 deletions docs/ref/checks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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``
----------------
Expand Down
58 changes: 58 additions & 0 deletions docs/ref/middleware.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/ref/settings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/releases/5.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------

Expand Down
17 changes: 17 additions & 0 deletions docs/topics/auth/default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down

0 comments on commit c86e727

Please sign in to comment.