1
0
mirror of https://github.com/django/django.git synced 2025-06-05 03:29:12 +00:00

Fixed #34391 -- Added async-compatible interface to auth functions and related methods test clients.

This commit is contained in:
Jon Janzen 2023-02-10 20:43:26 -05:00 committed by Mariusz Felisiak
parent 2360ba2274
commit 5e98959d92
10 changed files with 307 additions and 14 deletions

View File

@ -1,6 +1,8 @@
import inspect import inspect
import re import re
from asgiref.sync import sync_to_async
from django.apps import apps as django_apps from django.apps import apps as django_apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.exceptions import ImproperlyConfigured, PermissionDenied
@ -91,6 +93,12 @@ def authenticate(request=None, **credentials):
) )
@sensitive_variables("credentials")
async def aauthenticate(request=None, **credentials):
"""See authenticate()."""
return await sync_to_async(authenticate)(request, **credentials)
def login(request, user, backend=None): def login(request, user, backend=None):
""" """
Persist a user id and a backend in the request. This way a user doesn't Persist a user id and a backend in the request. This way a user doesn't
@ -144,6 +152,11 @@ def login(request, user, backend=None):
user_logged_in.send(sender=user.__class__, request=request, user=user) user_logged_in.send(sender=user.__class__, request=request, user=user)
async def alogin(request, user, backend=None):
"""See login()."""
return await sync_to_async(login)(request, user, backend)
def logout(request): def logout(request):
""" """
Remove the authenticated user's ID from the request and flush their session Remove the authenticated user's ID from the request and flush their session
@ -162,6 +175,11 @@ def logout(request):
request.user = AnonymousUser() request.user = AnonymousUser()
async def alogout(request):
"""See logout()."""
return await sync_to_async(logout)(request)
def get_user_model(): def get_user_model():
""" """
Return the User model that is active in this project. Return the User model that is active in this project.
@ -223,6 +241,11 @@ def get_user(request):
return user or AnonymousUser() return user or AnonymousUser()
async def aget_user(request):
"""See get_user()."""
return await sync_to_async(get_user)(request)
def get_permission_codename(action, opts): def get_permission_codename(action, opts):
""" """
Return the codename of the permission for the specified action. Return the codename of the permission for the specified action.
@ -242,3 +265,8 @@ def update_session_auth_hash(request, user):
request.session.cycle_key() request.session.cycle_key()
if hasattr(user, "get_session_auth_hash") and request.user == user: if hasattr(user, "get_session_auth_hash") and request.user == user:
request.session[HASH_SESSION_KEY] = user.get_session_auth_hash() request.session[HASH_SESSION_KEY] = user.get_session_auth_hash()
async def aupdate_session_auth_hash(request, user):
"""See update_session_auth_hash()."""
return await sync_to_async(update_session_auth_hash)(request, user)

View File

@ -1,7 +1,5 @@
from functools import partial from functools import partial
from asgiref.sync import sync_to_async
from django.contrib import auth from django.contrib import auth
from django.contrib.auth import load_backend from django.contrib.auth import load_backend
from django.contrib.auth.backends import RemoteUserBackend from django.contrib.auth.backends import RemoteUserBackend
@ -18,7 +16,7 @@ def get_user(request):
async def auser(request): async def auser(request):
if not hasattr(request, "_acached_user"): if not hasattr(request, "_acached_user"):
request._acached_user = await sync_to_async(auth.get_user)(request) request._acached_user = await auth.aget_user(request)
return request._acached_user return request._acached_user

View File

@ -747,6 +747,9 @@ class ClientMixin:
self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
return session return session
async def asession(self):
return await sync_to_async(lambda: self.session)()
def login(self, **credentials): def login(self, **credentials):
""" """
Set the Factory to appear as if it has successfully logged into a site. Set the Factory to appear as if it has successfully logged into a site.
@ -762,20 +765,36 @@ class ClientMixin:
return True return True
return False return False
async def alogin(self, **credentials):
"""See login()."""
from django.contrib.auth import aauthenticate
user = await aauthenticate(**credentials)
if user:
await self._alogin(user)
return True
return False
def force_login(self, user, backend=None): def force_login(self, user, backend=None):
def get_backend():
from django.contrib.auth import load_backend
for backend_path in settings.AUTHENTICATION_BACKENDS:
backend = load_backend(backend_path)
if hasattr(backend, "get_user"):
return backend_path
if backend is None: if backend is None:
backend = get_backend() backend = self._get_backend()
user.backend = backend user.backend = backend
self._login(user, backend) self._login(user, backend)
async def aforce_login(self, user, backend=None):
if backend is None:
backend = self._get_backend()
user.backend = backend
await self._alogin(user, backend)
def _get_backend(self):
from django.contrib.auth import load_backend
for backend_path in settings.AUTHENTICATION_BACKENDS:
backend = load_backend(backend_path)
if hasattr(backend, "get_user"):
return backend_path
def _login(self, user, backend=None): def _login(self, user, backend=None):
from django.contrib.auth import login from django.contrib.auth import login
@ -789,6 +808,26 @@ class ClientMixin:
login(request, user, backend) login(request, user, backend)
# Save the session values. # Save the session values.
request.session.save() request.session.save()
self._set_login_cookies(request)
async def _alogin(self, user, backend=None):
from django.contrib.auth import alogin
# Create a fake request to store login details.
request = HttpRequest()
session = await self.asession()
if session:
request.session = session
else:
engine = import_module(settings.SESSION_ENGINE)
request.session = engine.SessionStore()
await alogin(request, user, backend)
# Save the session values.
await sync_to_async(request.session.save)()
self._set_login_cookies(request)
def _set_login_cookies(self, request):
# Set the cookie to represent the session. # Set the cookie to represent the session.
session_cookie = settings.SESSION_COOKIE_NAME session_cookie = settings.SESSION_COOKIE_NAME
self.cookies[session_cookie] = request.session.session_key self.cookies[session_cookie] = request.session.session_key
@ -815,6 +854,21 @@ class ClientMixin:
logout(request) logout(request)
self.cookies = SimpleCookie() self.cookies = SimpleCookie()
async def alogout(self):
"""See logout()."""
from django.contrib.auth import aget_user, alogout
request = HttpRequest()
session = await self.asession()
if session:
request.session = session
request.user = await aget_user(request)
else:
engine = import_module(settings.SESSION_ENGINE)
request.session = engine.SessionStore()
await alogout(request)
self.cookies = SimpleCookie()
def _parse_json(self, response, **extra): def _parse_json(self, response, **extra):
if not hasattr(response, "_json"): if not hasattr(response, "_json"):
if not JSON_CONTENT_TYPE_RE.match(response.get("Content-Type")): if not JSON_CONTENT_TYPE_RE.match(response.get("Content-Type")):

View File

@ -693,6 +693,9 @@ Utility functions
.. currentmodule:: django.contrib.auth .. currentmodule:: django.contrib.auth
.. function:: get_user(request) .. function:: get_user(request)
.. function:: aget_user(request)
*Asynchronous version*: ``aget_user()``
Returns the user model instance associated with the given ``request``s Returns the user model instance associated with the given ``request``s
session. session.
@ -716,3 +719,7 @@ Utility functions
.. versionchanged:: 4.1.8 .. versionchanged:: 4.1.8
Fallback verification with :setting:`SECRET_KEY_FALLBACKS` was added. Fallback verification with :setting:`SECRET_KEY_FALLBACKS` was added.
.. versionchanged:: 5.0
``aget_user()`` function was added.

View File

@ -150,6 +150,12 @@ Minor features
* The default iteration count for the PBKDF2 password hasher is increased from * The default iteration count for the PBKDF2 password hasher is increased from
600,000 to 720,000. 600,000 to 720,000.
* The new asynchronous functions are now provided, using an
``a`` prefix: :func:`django.contrib.auth.aauthenticate`,
:func:`~.django.contrib.auth.aget_user`,
:func:`~.django.contrib.auth.alogin`, :func:`~.django.contrib.auth.alogout`,
and :func:`~.django.contrib.auth.aupdate_session_auth_hash`.
* ``AuthenticationMiddleware`` now adds an :meth:`.HttpRequest.auser` * ``AuthenticationMiddleware`` now adds an :meth:`.HttpRequest.auser`
asynchronous method that returns the currently logged-in user. asynchronous method that returns the currently logged-in user.
@ -366,7 +372,11 @@ Templates
Tests Tests
~~~~~ ~~~~~
* ... * :class:`~django.test.Client` and :class:`~django.test.AsyncClient` now
provide asynchronous methods, using an ``a`` prefix:
:meth:`~django.test.Client.asession`, :meth:`~django.test.Client.alogin`,
:meth:`~django.test.Client.aforce_login`, and
:meth:`~django.test.Client.alogout`.
URLs URLs
~~~~ ~~~~

View File

@ -120,6 +120,9 @@ Authenticating users
-------------------- --------------------
.. function:: authenticate(request=None, **credentials) .. function:: authenticate(request=None, **credentials)
.. function:: aauthenticate(request=None, **credentials)
*Asynchronous version*: ``aauthenticate()``
Use :func:`~django.contrib.auth.authenticate()` to verify a set of Use :func:`~django.contrib.auth.authenticate()` to verify a set of
credentials. It takes credentials as keyword arguments, ``username`` and credentials. It takes credentials as keyword arguments, ``username`` and
@ -152,6 +155,10 @@ Authenticating users
this. Rather if you're looking for a way to login a user, use the this. Rather if you're looking for a way to login a user, use the
:class:`~django.contrib.auth.views.LoginView`. :class:`~django.contrib.auth.views.LoginView`.
.. versionchanged:: 5.0
``aauthenticate()`` function was added.
.. _topic-authorization: .. _topic-authorization:
Permissions and Authorization Permissions and Authorization
@ -407,6 +414,9 @@ If you have an authenticated user you want to attach to the current session
- this is done with a :func:`~django.contrib.auth.login` function. - this is done with a :func:`~django.contrib.auth.login` function.
.. function:: login(request, user, backend=None) .. function:: login(request, user, backend=None)
.. function:: alogin(request, user, backend=None)
*Asynchronous version*: ``alogin()``
To log a user in, from a view, use :func:`~django.contrib.auth.login()`. It To log a user in, from a view, use :func:`~django.contrib.auth.login()`. It
takes an :class:`~django.http.HttpRequest` object and a takes an :class:`~django.http.HttpRequest` object and a
@ -436,6 +446,10 @@ If you have an authenticated user you want to attach to the current session
# Return an 'invalid login' error message. # Return an 'invalid login' error message.
... ...
.. versionchanged:: 5.0
``alogin()`` function was added.
Selecting the authentication backend Selecting the authentication backend
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -463,6 +477,9 @@ How to log a user out
--------------------- ---------------------
.. function:: logout(request) .. function:: logout(request)
.. function:: alogout(request)
*Asynchronous version*: ``alogout()``
To log out a user who has been logged in via To log out a user who has been logged in via
:func:`django.contrib.auth.login()`, use :func:`django.contrib.auth.login()`, use
@ -488,6 +505,10 @@ How to log a user out
immediately after logging out, do that *after* calling immediately after logging out, do that *after* calling
:func:`django.contrib.auth.logout()`. :func:`django.contrib.auth.logout()`.
.. versionchanged:: 5.0
``alogout()`` function was added.
Limiting access to logged-in users Limiting access to logged-in users
---------------------------------- ----------------------------------
@ -935,6 +956,9 @@ and wish to have similar behavior, use the :func:`update_session_auth_hash`
function. function.
.. function:: update_session_auth_hash(request, user) .. function:: update_session_auth_hash(request, user)
.. function:: aupdate_session_auth_hash(request, user)
*Asynchronous version*: ``aupdate_session_auth_hash()``
This function takes the current request and the updated user object from This function takes the current request and the updated user object from
which the new session hash will be derived and updates the session hash which the new session hash will be derived and updates the session hash
@ -955,6 +979,10 @@ function.
else: else:
... ...
.. versionchanged:: 5.0
``aupdate_session_auth_hash()`` function was added.
.. note:: .. note::
Since Since

View File

@ -440,6 +440,9 @@ Use the ``django.test.Client`` class to make requests.
The ``headers`` parameter was added. The ``headers`` parameter was added.
.. method:: Client.login(**credentials) .. method:: Client.login(**credentials)
.. method:: Client.alogin(**credentials)
*Asynchronous version*: ``alogin()``
If your site uses Django's :doc:`authentication system</topics/auth/index>` If your site uses Django's :doc:`authentication system</topics/auth/index>`
and you deal with logging in users, you can use the test client's and you deal with logging in users, you can use the test client's
@ -485,7 +488,14 @@ Use the ``django.test.Client`` class to make requests.
:meth:`~django.contrib.auth.models.UserManager.create_user` helper :meth:`~django.contrib.auth.models.UserManager.create_user` helper
method to create a new user with a correctly hashed password. method to create a new user with a correctly hashed password.
.. versionchanged:: 5.0
``alogin()`` method was added.
.. method:: Client.force_login(user, backend=None) .. method:: Client.force_login(user, backend=None)
.. method:: Client.aforce_login(user, backend=None)
*Asynchronous version*: ``aforce_login()``
If your site uses Django's :doc:`authentication If your site uses Django's :doc:`authentication
system</topics/auth/index>`, you can use the ``force_login()`` method system</topics/auth/index>`, you can use the ``force_login()`` method
@ -509,7 +519,14 @@ Use the ``django.test.Client`` class to make requests.
``login()`` by :ref:`using a weaker hasher while testing ``login()`` by :ref:`using a weaker hasher while testing
<speeding-up-tests-auth-hashers>`. <speeding-up-tests-auth-hashers>`.
.. versionchanged:: 5.0
``aforce_login()`` method was added.
.. method:: Client.logout() .. method:: Client.logout()
.. method:: Client.alogout()
*Asynchronous version*: ``alogout()``
If your site uses Django's :doc:`authentication system</topics/auth/index>`, If your site uses Django's :doc:`authentication system</topics/auth/index>`,
the ``logout()`` method can be used to simulate the effect of a user the ``logout()`` method can be used to simulate the effect of a user
@ -519,6 +536,10 @@ Use the ``django.test.Client`` class to make requests.
and session data cleared to defaults. Subsequent requests will appear and session data cleared to defaults. Subsequent requests will appear
to come from an :class:`~django.contrib.auth.models.AnonymousUser`. to come from an :class:`~django.contrib.auth.models.AnonymousUser`.
.. versionchanged:: 5.0
``alogout()`` method was added.
Testing responses Testing responses
----------------- -----------------
@ -703,6 +724,13 @@ access these properties as part of a test condition.
session["somekey"] = "test" session["somekey"] = "test"
session.save() session.save()
.. method:: Client.asession()
.. versionadded:: 5.0
This is similar to the :attr:`session` attribute but it works in async
contexts.
Setting the language Setting the language
-------------------- --------------------

View File

@ -0,0 +1,98 @@
from django.contrib.auth import (
aauthenticate,
aget_user,
alogin,
alogout,
aupdate_session_auth_hash,
)
from django.contrib.auth.models import AnonymousUser, User
from django.http import HttpRequest
from django.test import TestCase, override_settings
class AsyncAuthTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.test_user = User.objects.create_user(
"testuser", "test@example.com", "testpw"
)
async def test_aauthenticate(self):
user = await aauthenticate(username="testuser", password="testpw")
self.assertIsInstance(user, User)
self.assertEqual(user.username, self.test_user.username)
user.is_active = False
await user.asave()
self.assertIsNone(await aauthenticate(username="testuser", password="testpw"))
async def test_alogin(self):
request = HttpRequest()
request.session = await self.client.asession()
await alogin(request, self.test_user)
user = await aget_user(request)
self.assertIsInstance(user, User)
self.assertEqual(user.username, self.test_user.username)
async def test_alogin_without_user(self):
request = HttpRequest()
request.user = self.test_user
request.session = await self.client.asession()
await alogin(request, None)
user = await aget_user(request)
self.assertIsInstance(user, User)
self.assertEqual(user.username, self.test_user.username)
async def test_alogout(self):
await self.client.alogin(username="testuser", password="testpw")
request = HttpRequest()
request.session = await self.client.asession()
await alogout(request)
user = await aget_user(request)
self.assertIsInstance(user, AnonymousUser)
async def test_client_alogout(self):
await self.client.alogin(username="testuser", password="testpw")
request = HttpRequest()
request.session = await self.client.asession()
await self.client.alogout()
user = await aget_user(request)
self.assertIsInstance(user, AnonymousUser)
async def test_change_password(self):
await self.client.alogin(username="testuser", password="testpw")
request = HttpRequest()
request.session = await self.client.asession()
request.user = self.test_user
await aupdate_session_auth_hash(request, self.test_user)
user = await aget_user(request)
self.assertIsInstance(user, User)
async def test_invalid_login(self):
self.assertEqual(
await self.client.alogin(username="testuser", password=""), False
)
async def test_client_aforce_login(self):
await self.client.aforce_login(self.test_user)
request = HttpRequest()
request.session = await self.client.asession()
user = await aget_user(request)
self.assertEqual(user.username, self.test_user.username)
@override_settings(
AUTHENTICATION_BACKENDS=[
"django.contrib.auth.backends.ModelBackend",
"django.contrib.auth.backends.AllowAllUsersModelBackend",
]
)
async def test_client_aforce_login_backend(self):
self.test_user.is_active = False
await self.test_user.asave()
await self.client.aforce_login(
self.test_user,
backend="django.contrib.auth.backends.AllowAllUsersModelBackend",
)
request = HttpRequest()
request.session = await self.client.asession()
user = await aget_user(request)
self.assertEqual(user.username, self.test_user.username)

View File

@ -6,6 +6,7 @@ from django.contrib.auth import (
BACKEND_SESSION_KEY, BACKEND_SESSION_KEY,
SESSION_KEY, SESSION_KEY,
_clean_credentials, _clean_credentials,
aauthenticate,
authenticate, authenticate,
get_user, get_user,
signals, signals,
@ -764,6 +765,28 @@ class AuthenticateTests(TestCase):
status_code=500, status_code=500,
) )
@override_settings(
AUTHENTICATION_BACKENDS=["auth_tests.test_auth_backends.TypeErrorBackend"]
)
async def test_aauthenticate_sensitive_variables(self):
try:
await aauthenticate(
username="testusername", password=self.sensitive_password
)
except TypeError:
exc_info = sys.exc_info()
rf = RequestFactory()
response = technical_500_response(rf.get("/"), *exc_info)
self.assertNotContains(response, self.sensitive_password, status_code=500)
self.assertContains(response, "TypeErrorBackend", status_code=500)
self.assertContains(
response,
'<tr><td>credentials</td><td class="code">'
"<pre>&#39;********************&#39;</pre></td></tr>",
html=True,
status_code=500,
)
def test_clean_credentials_sensitive_variables(self): def test_clean_credentials_sensitive_variables(self):
try: try:
# Passing in a list to cause an exception # Passing in a list to cause an exception

View File

@ -1,5 +1,7 @@
from asgiref.sync import sync_to_async
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user, get_user_model from django.contrib.auth import aget_user, get_user, get_user_model
from django.contrib.auth.models import AnonymousUser, User from django.contrib.auth.models import AnonymousUser, User
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import IntegrityError from django.db import IntegrityError
@ -129,6 +131,12 @@ class TestGetUser(TestCase):
user = get_user(request) user = get_user(request)
self.assertIsInstance(user, AnonymousUser) self.assertIsInstance(user, AnonymousUser)
async def test_aget_user_anonymous(self):
request = HttpRequest()
request.session = await self.client.asession()
user = await aget_user(request)
self.assertIsInstance(user, AnonymousUser)
def test_get_user(self): def test_get_user(self):
created_user = User.objects.create_user( created_user = User.objects.create_user(
"testuser", "test@example.com", "testpw" "testuser", "test@example.com", "testpw"
@ -162,3 +170,14 @@ class TestGetUser(TestCase):
user = get_user(request) user = get_user(request)
self.assertIsInstance(user, User) self.assertIsInstance(user, User)
self.assertEqual(user.username, created_user.username) self.assertEqual(user.username, created_user.username)
async def test_aget_user(self):
created_user = await sync_to_async(User.objects.create_user)(
"testuser", "test@example.com", "testpw"
)
await self.client.alogin(username="testuser", password="testpw")
request = HttpRequest()
request.session = await self.client.asession()
user = await aget_user(request)
self.assertIsInstance(user, User)
self.assertEqual(user.username, created_user.username)