浏览代码

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

Jon Janzen 2 年之前
父节点
当前提交
5e98959d92

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

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

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

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

+ 62 - 8
django/test/client.py

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

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

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

+ 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
 * 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
 ~~~~
 ~~~~

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

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

+ 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.
             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
 --------------------
 --------------------
 
 

+ 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,
     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

+ 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.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)