From 674c23999cb6982a9d447fedec4d72e135201fee Mon Sep 17 00:00:00 2001 From: HappyDingning Date: Mon, 15 May 2023 00:12:22 +0800 Subject: [PATCH] Fixed #34565 -- Added support for async checking of user passwords. --- django/contrib/auth/base_user.py | 12 +++++++++++ django/contrib/auth/hashers.py | 34 ++++++++++++++++++++++++-------- docs/ref/contrib/auth.txt | 7 +++++++ docs/releases/5.0.txt | 4 ++++ docs/topics/auth/customizing.txt | 7 +++++++ docs/topics/auth/passwords.txt | 7 +++++++ tests/auth_tests/test_hashers.py | 10 ++++++++++ tests/auth_tests/test_models.py | 25 +++++++++++++++++++++++ 8 files changed, 98 insertions(+), 8 deletions(-) diff --git a/django/contrib/auth/base_user.py b/django/contrib/auth/base_user.py index e205ccccf28..da0eac731fb 100644 --- a/django/contrib/auth/base_user.py +++ b/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) diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index e6544c7b556..b63904cd75d 100644 --- a/django/contrib/auth/hashers.py +++ b/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 diff --git a/docs/ref/contrib/auth.txt b/docs/ref/contrib/auth.txt index 90ae5904a84..61fd74a38f2 100644 --- a/docs/ref/contrib/auth.txt +++ b/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 diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index d40cd6a4f0c..0c093720b9b 100644 --- a/docs/releases/5.0.txt +++ b/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` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/auth/customizing.txt b/docs/topics/auth/customizing.txt index 6cc48cacb17..78bee37a0f3 100644 --- a/docs/topics/auth/customizing.txt +++ b/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 diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt index 695c8d25711..0876ac4f6ea 100644 --- a/docs/topics/auth/passwords.txt +++ b/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 diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py index d1c26cfaa83..e10992b25c6 100644 --- a/tests/auth_tests/test_hashers.py +++ b/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$")) diff --git a/tests/auth_tests/test_models.py b/tests/auth_tests/test_models.py index 41f822fdc74..19454ade65f 100644 --- a/tests/auth_tests/test_models.py +++ b/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(