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

This commit is contained in:
HappyDingning 2023-05-15 00:12:22 +08:00 committed by Mariusz Felisiak
parent 4e73d8c04d
commit 674c23999c
8 changed files with 98 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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