Browse Source

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

Jon Janzen 2 years ago
parent
commit
5e98959d92

+ 28 - 0
django/contrib/auth/__init__.py

@@ -1,6 +1,8 @@
 import inspect
 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
@@ -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):
     """
     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)
 
 
+async def alogin(request, user, backend=None):
+    """See login()."""
+    return await sync_to_async(login)(request, user, backend)
+
+
 def logout(request):
     """
     Remove the authenticated user's ID from the request and flush their session
@@ -162,6 +175,11 @@ def logout(request):
         request.user = AnonymousUser()
 
 
+async def alogout(request):
+    """See logout()."""
+    return await sync_to_async(logout)(request)
+
+
 def get_user_model():
     """
     Return the User model that is active in this project.
@@ -223,6 +241,11 @@ def get_user(request):
     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):
     """
     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()
     if hasattr(user, "get_session_auth_hash") and request.user == user:
         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)

+ 1 - 3
django/contrib/auth/middleware.py

@@ -1,7 +1,5 @@
 from functools import partial
 
-from asgiref.sync import sync_to_async
-
 from django.contrib import auth
 from django.contrib.auth import load_backend
 from django.contrib.auth.backends import RemoteUserBackend
@@ -18,7 +16,7 @@ def get_user(request):
 
 async def auser(request):
     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
 
 

+ 62 - 8
django/test/client.py

@@ -747,6 +747,9 @@ class ClientMixin:
         self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
         return session
 
+    async def asession(self):
+        return await sync_to_async(lambda: self.session)()
+
     def login(self, **credentials):
         """
         Set the Factory to appear as if it has successfully logged into a site.
@@ -762,20 +765,36 @@ class ClientMixin:
             return True
         return False
 
-    def force_login(self, user, backend=None):
-        def get_backend():
-            from django.contrib.auth import load_backend
+    async def alogin(self, **credentials):
+        """See login()."""
+        from django.contrib.auth import aauthenticate
 
-            for backend_path in settings.AUTHENTICATION_BACKENDS:
-                backend = load_backend(backend_path)
-                if hasattr(backend, "get_user"):
-                    return backend_path
+        user = await aauthenticate(**credentials)
+        if user:
+            await self._alogin(user)
+            return True
+        return False
 
+    def force_login(self, user, backend=None):
         if backend is None:
-            backend = get_backend()
+            backend = self._get_backend()
         user.backend = 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):
         from django.contrib.auth import login
 
@@ -789,6 +808,26 @@ class ClientMixin:
         login(request, user, backend)
         # Save the session values.
         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.
         session_cookie = settings.SESSION_COOKIE_NAME
         self.cookies[session_cookie] = request.session.session_key
@@ -815,6 +854,21 @@ class ClientMixin:
         logout(request)
         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):
         if not hasattr(response, "_json"):
             if not JSON_CONTENT_TYPE_RE.match(response.get("Content-Type")):

+ 7 - 0
docs/ref/contrib/auth.txt

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

+ 11 - 1
docs/releases/5.0.txt

@@ -150,6 +150,12 @@ Minor features
 * The default iteration count for the PBKDF2 password hasher is increased from
   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`
   asynchronous method that returns the currently logged-in user.
 
@@ -366,7 +372,11 @@ Templates
 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
 ~~~~

+ 28 - 0
docs/topics/auth/default.txt

@@ -120,6 +120,9 @@ Authenticating users
 --------------------
 
 .. function:: authenticate(request=None, **credentials)
+.. function:: aauthenticate(request=None, **credentials)
+
+    *Asynchronous version*: ``aauthenticate()``
 
     Use :func:`~django.contrib.auth.authenticate()` to verify a set of
     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
         :class:`~django.contrib.auth.views.LoginView`.
 
+    .. versionchanged:: 5.0
+
+        ``aauthenticate()`` function was added.
+
 .. _topic-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.
 
 .. 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
     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.
                 ...
 
+    .. versionchanged:: 5.0
+
+        ``alogin()`` function was added.
+
 Selecting the authentication backend
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -463,6 +477,9 @@ How to log a user out
 ---------------------
 
 .. function:: logout(request)
+.. function:: alogout(request)
+
+    *Asynchronous version*: ``alogout()``
 
     To log out a user who has been logged in via
     :func:`django.contrib.auth.login()`, use
@@ -488,6 +505,10 @@ How to log a user out
     immediately after logging out, do that *after* calling
     :func:`django.contrib.auth.logout()`.
 
+    .. versionchanged:: 5.0
+
+        ``alogout()`` function was added.
+
 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:: 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
     which the new session hash will be derived and updates the session hash
@@ -955,6 +979,10 @@ function.
             else:
                 ...
 
+    .. versionchanged:: 5.0
+
+        ``aupdate_session_auth_hash()`` function was added.
+
 .. note::
 
     Since

+ 28 - 0
docs/topics/testing/tools.txt

@@ -440,6 +440,9 @@ Use the ``django.test.Client`` class to make requests.
             The ``headers`` parameter was added.
 
     .. method:: Client.login(**credentials)
+    .. method:: Client.alogin(**credentials)
+
+        *Asynchronous version*: ``alogin()``
 
         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
@@ -485,7 +488,14 @@ Use the ``django.test.Client`` class to make requests.
         :meth:`~django.contrib.auth.models.UserManager.create_user` helper
         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.aforce_login(user, backend=None)
+
+        *Asynchronous version*: ``aforce_login()``
 
         If your site uses Django's :doc:`authentication
         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
         <speeding-up-tests-auth-hashers>`.
 
+        .. versionchanged:: 5.0
+
+            ``aforce_login()`` method was added.
+
     .. method:: Client.logout()
+    .. method:: Client.alogout()
+
+        *Asynchronous version*: ``alogout()``
 
         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
@@ -519,6 +536,10 @@ Use the ``django.test.Client`` class to make requests.
         and session data cleared to defaults. Subsequent requests will appear
         to come from an :class:`~django.contrib.auth.models.AnonymousUser`.
 
+        .. versionchanged:: 5.0
+
+            ``alogout()`` method was added.
+
 Testing responses
 -----------------
 
@@ -703,6 +724,13 @@ access these properties as part of a test condition.
             session["somekey"] = "test"
             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
 --------------------
 

+ 98 - 0
tests/async/test_async_auth.py

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

+ 23 - 0
tests/auth_tests/test_auth_backends.py

@@ -6,6 +6,7 @@ from django.contrib.auth import (
     BACKEND_SESSION_KEY,
     SESSION_KEY,
     _clean_credentials,
+    aauthenticate,
     authenticate,
     get_user,
     signals,
@@ -764,6 +765,28 @@ class AuthenticateTests(TestCase):
             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):
         try:
             # Passing in a list to cause an exception

+ 20 - 1
tests/auth_tests/test_basic.py

@@ -1,5 +1,7 @@
+from asgiref.sync import sync_to_async
+
 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.core.exceptions import ImproperlyConfigured
 from django.db import IntegrityError
@@ -129,6 +131,12 @@ class TestGetUser(TestCase):
         user = get_user(request)
         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):
         created_user = User.objects.create_user(
             "testuser", "test@example.com", "testpw"
@@ -162,3 +170,14 @@ class TestGetUser(TestCase):
             user = get_user(request)
             self.assertIsInstance(user, User)
             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)