Browse Source

Fixed #34565 -- Added support for async checking of user passwords.

HappyDingning 1 year ago
parent
commit
674c23999c

+ 12 - 0
django/contrib/auth/base_user.py

@@ -8,6 +8,7 @@ import warnings
 from django.conf import settings
 from django.contrib.auth import password_validation
 from django.contrib.auth.hashers import (
+    acheck_password,
     check_password,
     is_password_usable,
     make_password,
@@ -122,6 +123,17 @@ class AbstractBaseUser(models.Model):
 
         return check_password(raw_password, self.password, setter)
 
+    async def acheck_password(self, raw_password):
+        """See check_password()."""
+
+        async def setter(raw_password):
+            self.set_password(raw_password)
+            # Password hash upgrades shouldn't be considered password changes.
+            self._password = None
+            await self.asave(update_fields=["password"])
+
+        return await acheck_password(raw_password, self.password, setter)
+
     def set_unusable_password(self):
         # Set a value that will never be a valid hash
         self.password = make_password(None)

+ 26 - 8
django/contrib/auth/hashers.py

@@ -34,23 +34,21 @@ def is_password_usable(encoded):
     return encoded is None or not encoded.startswith(UNUSABLE_PASSWORD_PREFIX)
 
 
-def check_password(password, encoded, setter=None, preferred="default"):
+def verify_password(password, encoded, preferred="default"):
     """
-    Return a boolean of whether the raw password matches the three
-    part encoded digest.
-
-    If setter is specified, it'll be called when you need to
-    regenerate the password.
+    Return two booleans. The first is whether the raw password matches the
+    three part encoded digest, and the second whether to regenerate the
+    password.
     """
     if password is None or not is_password_usable(encoded):
-        return False
+        return False, False
 
     preferred = get_hasher(preferred)
     try:
         hasher = identify_hasher(encoded)
     except ValueError:
         # encoded is gibberish or uses a hasher that's no longer installed.
-        return False
+        return False, False
 
     hasher_changed = hasher.algorithm != preferred.algorithm
     must_update = hasher_changed or preferred.must_update(encoded)
@@ -63,11 +61,31 @@ def check_password(password, encoded, setter=None, preferred="default"):
     if not is_correct and not hasher_changed and must_update:
         hasher.harden_runtime(password, encoded)
 
+    return is_correct, must_update
+
+
+def check_password(password, encoded, setter=None, preferred="default"):
+    """
+    Return a boolean of whether the raw password matches the three part encoded
+    digest.
+
+    If setter is specified, it'll be called when you need to regenerate the
+    password.
+    """
+    is_correct, must_update = verify_password(password, encoded, preferred=preferred)
     if setter and is_correct and must_update:
         setter(password)
     return is_correct
 
 
+async def acheck_password(password, encoded, setter=None, preferred="default"):
+    """See check_password()."""
+    is_correct, must_update = verify_password(password, encoded, preferred=preferred)
+    if setter and is_correct and must_update:
+        await setter(password)
+    return is_correct
+
+
 def make_password(password, salt=None, hasher="default"):
     """
     Turn a plain-text password into a hash for database storage

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

@@ -166,11 +166,18 @@ Methods
         were used.
 
     .. method:: check_password(raw_password)
+    .. method:: acheck_password(raw_password)
+
+        *Asynchronous version*: ``acheck_password()``
 
         Returns ``True`` if the given raw string is the correct password for
         the user. (This takes care of the password hashing in making the
         comparison.)
 
+        .. versionchanged:: 5.0
+
+            ``acheck_password()`` method was added.
+
     .. method:: set_unusable_password()
 
         Marks the user as having no password set.  This isn't the same as

+ 4 - 0
docs/releases/5.0.txt

@@ -153,6 +153,10 @@ Minor features
 * ``AuthenticationMiddleware`` now adds an :meth:`.HttpRequest.auser`
   asynchronous method that returns the currently logged-in user.
 
+* The new :func:`django.contrib.auth.hashers.acheck_password` asynchronous
+  function and :meth:`.AbstractBaseUser.acheck_password` method allow
+  asynchronous checking of user passwords.
+
 :mod:`django.contrib.contenttypes`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 7 - 0
docs/topics/auth/customizing.txt

@@ -695,11 +695,18 @@ The following attributes and methods are available on any subclass of
         were used.
 
     .. method:: models.AbstractBaseUser.check_password(raw_password)
+    .. method:: models.AbstractBaseUser.acheck_password(raw_password)
+
+        *Asynchronous version*: ``acheck_password()``
 
         Returns ``True`` if the given raw string is the correct password for
         the user. (This takes care of the password hashing in making the
         comparison.)
 
+        .. versionchanged:: 5.0
+
+            ``acheck_password()`` method was added.
+
     .. method:: models.AbstractBaseUser.set_unusable_password()
 
         Marks the user as having no password set.  This isn't the same as

+ 7 - 0
docs/topics/auth/passwords.txt

@@ -478,6 +478,9 @@ to create and validate hashed passwords. You can use them independently
 from the ``User`` model.
 
 .. function:: check_password(password, encoded, setter=None, preferred="default")
+.. function:: acheck_password(password, encoded, asetter=None, preferred="default")
+
+    *Asynchronous version*: ``acheck_password()``
 
     If you'd like to manually authenticate a user by comparing a plain-text
     password to the hashed password in the database, use the convenience
@@ -490,6 +493,10 @@ from the ``User`` model.
     to use the default (first entry of ``PASSWORD_HASHERS`` setting). See
     :ref:`auth-included-hashers` for the algorithm name of each hasher.
 
+    .. versionchanged:: 5.0
+
+        ``acheck_password()`` method was added.
+
 .. function:: make_password(password, salt=None, hasher='default')
 
     Creates a hashed password in the format used by this application. It takes

+ 10 - 0
tests/auth_tests/test_hashers.py

@@ -11,6 +11,7 @@ from django.contrib.auth.hashers import (
     PBKDF2PasswordHasher,
     PBKDF2SHA1PasswordHasher,
     ScryptPasswordHasher,
+    acheck_password,
     check_password,
     get_hasher,
     identify_hasher,
@@ -59,6 +60,15 @@ class TestUtilsHashPass(SimpleTestCase):
         self.assertTrue(check_password("", blank_encoded))
         self.assertFalse(check_password(" ", blank_encoded))
 
+    async def test_acheck_password(self):
+        encoded = make_password("lètmein")
+        self.assertIs(await acheck_password("lètmein", encoded), True)
+        self.assertIs(await acheck_password("lètmeinz", encoded), False)
+        # Blank passwords.
+        blank_encoded = make_password("")
+        self.assertIs(await acheck_password("", blank_encoded), True)
+        self.assertIs(await acheck_password(" ", blank_encoded), False)
+
     def test_bytes(self):
         encoded = make_password(b"bytes_password")
         self.assertTrue(encoded.startswith("pbkdf2_sha256$"))

+ 25 - 0
tests/auth_tests/test_models.py

@@ -1,5 +1,7 @@
 from unittest import mock
 
+from asgiref.sync import sync_to_async
+
 from django.conf.global_settings import PASSWORD_HASHERS
 from django.contrib.auth import get_user_model
 from django.contrib.auth.backends import ModelBackend
@@ -312,6 +314,29 @@ class AbstractUserTestCase(TestCase):
         finally:
             hasher.iterations = old_iterations
 
+    @override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS)
+    async def test_acheck_password_upgrade(self):
+        user = await sync_to_async(User.objects.create_user)(
+            username="user", password="foo"
+        )
+        initial_password = user.password
+        self.assertIs(await user.acheck_password("foo"), True)
+        hasher = get_hasher("default")
+        self.assertEqual("pbkdf2_sha256", hasher.algorithm)
+
+        old_iterations = hasher.iterations
+        try:
+            # Upgrade the password iterations.
+            hasher.iterations = old_iterations + 1
+            with mock.patch(
+                "django.contrib.auth.password_validation.password_changed"
+            ) as pw_changed:
+                self.assertIs(await user.acheck_password("foo"), True)
+                self.assertEqual(pw_changed.call_count, 0)
+            self.assertNotEqual(initial_password, user.password)
+        finally:
+            hasher.iterations = old_iterations
+
 
 class CustomModelBackend(ModelBackend):
     def with_perm(