Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #31405 -- Added LoginRequiredAuthenticationMiddleware force all views to require authentication by default. #17792

Merged
merged 1 commit into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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):
nessita marked this conversation as resolved.
Show resolved Hide resolved
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):
Copy link
Contributor

@bigfootjon bigfootjon Apr 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using MiddlewareMixin requires a context-switch in async code. Considering that contrib.auth has an async interface (as applied here: you can use await request.auser() to get the current user, instead of request.user). can this middleware be rewritten to not require a context switch in async code?

Ref: 4296102

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the above commit reference after I added testing and cleaned up the implementation.

"""
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
sarahboyce marked this conversation as resolved.
Show resolved Hide resolved
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):
sarahboyce marked this conversation as resolved.
Show resolved Hide resolved
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.

sarahboyce marked this conversation as resolved.
Show resolved Hide resolved
.. 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`.

nessita marked this conversation as resolved.
Show resolved Hide resolved
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