Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion django/contrib/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import re

from asgiref.sync import sync_to_async

from django.apps import apps as django_apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
Expand Down Expand Up @@ -407,6 +409,6 @@ def check_password_with_timing_attack_mitigation(user, password):
async def acheck_password_with_timing_attack_mitigation(user, password):
"""See check_user_with_timing_attack_mitigation."""
if user is None:
get_user_model()().set_password(password)
await sync_to_async(get_user_model()().set_password)(password)
else:
return await user.acheck_password(password)
5 changes: 4 additions & 1 deletion django/contrib/auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,10 @@ async def aclean_username(self, username, request):
return username

def get_username(self, request):
if isinstance(request, ASGIRequest):
if (
isinstance(request, ASGIRequest)
and self.header == RemoteUserMiddleware.header
):
return request.META["HTTP_" + self.header]
return request.META[self.header]

Expand Down
41 changes: 21 additions & 20 deletions docs/howto/auth-remote-user.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@
How to authenticate using ``REMOTE_USER``
=========================================

This document describes how to make use of external authentication sources
(where the web server sets the ``REMOTE_USER`` environment variable) in your
Django applications. This type of authentication solution is typically seen on
intranet sites, with single sign-on solutions such as IIS and Integrated
Windows Authentication or Apache and `mod_authnz_ldap`_, `CAS`_, `WebAuth`_,
`mod_auth_sspi`_, etc.
This document describes how to make use of external authentication sources in
your Django applications. This type of authentication solution is typically
seen on intranet sites, with single sign-on solutions such as IIS and
Integrated Windows Authentication or Apache and `mod_authnz_ldap`_, `CAS`_,
`WebAuth`_, `mod_auth_sspi`_, etc.

.. _mod_authnz_ldap: https://httpd.apache.org/docs/current/mod/mod_authnz_ldap.html
.. _CAS: https://www.apereo.org/projects/cas
.. _WebAuth: https://uit.stanford.edu/service/authentication
.. _mod_auth_sspi: https://sourceforge.net/projects/mod-auth-sspi

When the web server takes care of authentication it typically sets the
``REMOTE_USER`` environment variable for use in the underlying application. In
Django, ``REMOTE_USER`` is made available in the :attr:`request.META
<django.http.HttpRequest.META>` attribute. Django can be configured to make
use of the ``REMOTE_USER`` value using the ``RemoteUserMiddleware``
or ``PersistentRemoteUserMiddleware``, and
When the web server takes care of authentication it typically provides the
authenticated user as ``REMOTE_USER``. In Django, this value is made available
in :attr:`request.META <django.http.HttpRequest.META>` (as ``REMOTE_USER`` when
supplied as an environment variable, as in WSGI, or ``HTTP_REMOTE_USER`` when
supplied via an HTTP header, as in ASGI). Django can be configured to make use
of the ``REMOTE_USER`` value using the ``RemoteUserMiddleware`` or
``PersistentRemoteUserMiddleware``, and
:class:`~django.contrib.auth.backends.RemoteUserBackend` classes found in
:mod:`django.contrib.auth`.

Expand Down Expand Up @@ -47,7 +47,8 @@ with :class:`~django.contrib.auth.backends.RemoteUserBackend` in the
]

With this setup, ``RemoteUserMiddleware`` will detect the username in
``request.META['REMOTE_USER']`` and will authenticate and auto-login that user
``request.META['REMOTE_USER']`` (or ``request.META['HTTP_REMOTE_USER']`` under
ASGI) and will authenticate and auto-login that user
using the :class:`~django.contrib.auth.backends.RemoteUserBackend`.

Be aware that this particular setup disables authentication with the default
Expand Down Expand Up @@ -103,9 +104,7 @@ instead of :class:`django.contrib.auth.middleware.RemoteUserMiddleware`::
client can supply the header. You must be sure that your web server or
reverse proxy always sets or strips that header based on the appropriate
authentication checks, never permitting an end user to submit a fake (or
"spoofed") header value. In particular, ASGI deployments cannot be exposed
directly to the internet (that is, without a reverse proxy) when using this
middleware.
"spoofed") header value.

Since the HTTP headers ``X-Auth-User`` and ``X-Auth_User`` (for example)
both normalize to the ``HTTP_X_AUTH_USER`` key in ``request.META``, you
Expand All @@ -115,10 +114,12 @@ instead of :class:`django.contrib.auth.middleware.RemoteUserMiddleware`::
Under WSGI, this warning doesn't apply to ``RemoteUserMiddleware`` in its
default configuration with ``header = "REMOTE_USER"``, since a key that
doesn't start with ``HTTP_`` in ``request.META`` can only be set by your
WSGI server, not directly from an HTTP request header. This warning *does*
apply by default on ASGI, because in the async path, the middleware
prepends ``HTTP_`` to the defined header name before looking it up in
``request.META``.
WSGI server, not directly from an HTTP request header.

This warning applies under ASGI in all configurations, because there is
no equivalent for a WSGI server's ability to place a trusted value in the
environ. ASGI deployments *must* use a reverse proxy as described above
when using this middleware.

If you need more control, you can create your own authentication backend
that inherits from :class:`~django.contrib.auth.backends.RemoteUserBackend` and
Expand Down
9 changes: 9 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,15 @@ backends.
* The undocumented ``InclusionAdminNode.__init__()`` now takes the template tag
``name`` as the first positional argument.

:mod:`django.contrib.auth`
--------------------------

* Under ASGI, :class:`~django.contrib.auth.middleware.RemoteUserMiddleware` no
longer prefixes ``HTTP_`` when looking up custom values in ``request.META``.
For example, to send ``-H "AuthUser": ...``, the ``header`` attribute should
be ``HTTP_AUTHUSER``. This restores the behavior prior to Django 5.2. (The
default value of ``REMOTE_USER`` is not affected.)

:mod:`django.contrib.gis`
-------------------------

Expand Down
32 changes: 31 additions & 1 deletion scripts/pr_quality/check_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta, timezone
from http import HTTPStatus

from pr_quality.errors import (
CHECKS_FOOTER,
Expand Down Expand Up @@ -102,7 +103,8 @@ def github_request(method, path, token, repo, data=None, params=None):
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=URLOPEN_TIMEOUT_SECONDS) as response:
return json.loads(response.read())
if response.status != HTTPStatus.NO_CONTENT:
return json.loads(response.read())


def get_recent_commit_count(pr_author, repo, token, since_days, max_count):
Expand Down Expand Up @@ -144,6 +146,26 @@ def get_pr_total_changes(pr_number, repo, token):
return total_changes


def get_comment_ids_to_delete(pr_number, repo, token):
ids = []
page = 1
while True:
comments = github_request(
"GET",
f"/issues/{pr_number}/comments",
token,
repo,
{"per_page": GITHUB_PER_PAGE, "page": page},
)
for comment in comments:
if CHECKS_HEADER in comment["body"]:
ids.append(comment["id"])
if len(comments) < GITHUB_PER_PAGE:
break
page += 1
return ids


def strip_html_comments(text):
"""Return text with all HTML comments removed."""
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
Expand Down Expand Up @@ -506,6 +528,14 @@ def main(
logger.info("PR #%s passed all quality checks.", pr_number)
return

for id_to_delete in get_comment_ids_to_delete(pr_number, repo, token):
github_request(
"DELETE",
f"/issues/comments/{id_to_delete}",
token,
repo,
)

github_request(
"POST",
f"/issues/{pr_number}/comments",
Expand Down
51 changes: 42 additions & 9 deletions scripts/pr_quality/tests/test_check_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,17 +781,28 @@ def test_trusted_author_failures_no_close(self):
body = make_pr_body(ticket="", checked_items=0)
result, _, mock_gh = self.call_main(pr_body=body, commit_count=1)
self.assertEqual(result, 1)
mock_gh.assert_called_once_with(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
self.assertEqual(
mock_gh.call_args_list,
[
mock.call(
"GET", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
mock.call(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
],
)

def test_untrusted_author_failures_posts_comment_and_closes(self):
body = make_pr_body(ticket="", checked_items=0)
result, _, mock_gh = self.call_main(pr_body=body)
self.assertEqual(result, 1)
self.assertEqual(
mock_gh.mock_calls,
mock_gh.call_args_list,
[
mock.call(
"GET", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
mock.call(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
Expand All @@ -806,8 +817,11 @@ def test_missing_pr_author_treated_as_untrusted(self):
result, _, mock_gh = self.call_main(pr_body=body, pr_author="")
self.assertEqual(result, 1)
self.assertEqual(
mock_gh.mock_calls,
mock_gh.call_args_list,
[
mock.call(
"GET", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
mock.call(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
Expand All @@ -821,8 +835,16 @@ def test_autoclose_false_posts_comment_does_not_close(self):
body = make_pr_body(ticket="", checked_items=0)
result, _, mock_gh = self.call_main(pr_body=body, autoclose=False)
self.assertEqual(result, 1)
mock_gh.assert_called_once_with(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
self.assertEqual(
mock_gh.call_args_list,
[
mock.call(
"GET", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
mock.call(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
],
)

def test_warnings_only_posts_comment_does_not_close(self):
Expand All @@ -833,8 +855,16 @@ def test_warnings_only_posts_comment_does_not_close(self):
pr_body=VALID_PR_BODY, pr_title="", trac_data=trac_data
)
self.assertIsNone(result)
mock_gh.assert_called_once_with(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
self.assertEqual(
mock_gh.call_args_list,
[
mock.call(
"GET", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
mock.call(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
],
)

def test_warnings_alongside_failures_still_closes(self):
Expand All @@ -844,8 +874,11 @@ def test_warnings_alongside_failures_still_closes(self):
result, _, mock_gh = self.call_main(pr_body=body, trac_data=trac_data)
self.assertEqual(result, 1)
self.assertEqual(
mock_gh.mock_calls,
mock_gh.call_args_list,
[
mock.call(
"GET", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
mock.call(
"POST", "/issues/10/comments", "test-token", "test/repo", mock.ANY
),
Expand Down
16 changes: 16 additions & 0 deletions tests/auth_tests/test_auth_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,22 @@ async def test_aauthentication_timing(self):
await aauthenticate(username="no_such_user", password="test")
self.assertEqual(CountingMD5PasswordHasher.calls, 1)

async def test_aauthentication_timing_async_bridge(self):
with patch(
"django.contrib.auth.hashers.sync_to_async",
return_value=mock.AsyncMock(return_value=(True, False)),
) as mocked_adapter:
username = getattr(self.user, self.UserModel.USERNAME_FIELD)
await aauthenticate(username=username, password="test")
mocked_adapter.assert_called_once()

with patch(
"django.contrib.auth.sync_to_async", return_value=mock.AsyncMock()
) as mocked_adapter:
username = getattr(self.user, self.UserModel.USERNAME_FIELD)
await aauthenticate(username="no_such_user", password="test")
mocked_adapter.assert_called_once()

@override_settings(
PASSWORD_HASHERS=["auth_tests.test_auth_backends.CountingMD5PasswordHasher"]
)
Expand Down
Loading
Loading