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.conf import settings
from django.contrib.auth import password_validation from django.contrib.auth import password_validation
from django.contrib.auth.hashers import ( from django.contrib.auth.hashers import (
acheck_password,
check_password, check_password,
is_password_usable, is_password_usable,
make_password, make_password,
@ -122,6 +123,17 @@ class AbstractBaseUser(models.Model):
return check_password(raw_password, self.password, setter) 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): def set_unusable_password(self):
# Set a value that will never be a valid hash # Set a value that will never be a valid hash
self.password = make_password(None) 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) 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 Return two booleans. The first is whether the raw password matches the
part encoded digest. three part encoded digest, and the second whether to regenerate the
password.
If setter is specified, it'll be called when you need to
regenerate the password.
""" """
if password is None or not is_password_usable(encoded): if password is None or not is_password_usable(encoded):
return False return False, False
preferred = get_hasher(preferred) preferred = get_hasher(preferred)
try: try:
hasher = identify_hasher(encoded) hasher = identify_hasher(encoded)
except ValueError: except ValueError:
# encoded is gibberish or uses a hasher that's no longer installed. # encoded is gibberish or uses a hasher that's no longer installed.
return False return False, False
hasher_changed = hasher.algorithm != preferred.algorithm hasher_changed = hasher.algorithm != preferred.algorithm
must_update = hasher_changed or preferred.must_update(encoded) 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: if not is_correct and not hasher_changed and must_update:
hasher.harden_runtime(password, encoded) 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: if setter and is_correct and must_update:
setter(password) setter(password)
return is_correct 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"): def make_password(password, salt=None, hasher="default"):
""" """
Turn a plain-text password into a hash for database storage Turn a plain-text password into a hash for database storage

View File

@ -166,11 +166,18 @@ Methods
were used. were used.
.. method:: check_password(raw_password) .. 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 Returns ``True`` if the given raw string is the correct password for
the user. (This takes care of the password hashing in making the the user. (This takes care of the password hashing in making the
comparison.) comparison.)
.. versionchanged:: 5.0
``acheck_password()`` method was added.
.. method:: set_unusable_password() .. method:: set_unusable_password()
Marks the user as having no password set. This isn't the same as 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` * ``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.
* 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` :mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -695,11 +695,18 @@ The following attributes and methods are available on any subclass of
were used. were used.
.. method:: models.AbstractBaseUser.check_password(raw_password) .. 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 Returns ``True`` if the given raw string is the correct password for
the user. (This takes care of the password hashing in making the the user. (This takes care of the password hashing in making the
comparison.) comparison.)
.. versionchanged:: 5.0
``acheck_password()`` method was added.
.. method:: models.AbstractBaseUser.set_unusable_password() .. method:: models.AbstractBaseUser.set_unusable_password()
Marks the user as having no password set. This isn't the same as 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. from the ``User`` model.
.. function:: check_password(password, encoded, setter=None, preferred="default") .. 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 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 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 to use the default (first entry of ``PASSWORD_HASHERS`` setting). See
:ref:`auth-included-hashers` for the algorithm name of each hasher. :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') .. function:: make_password(password, salt=None, hasher='default')
Creates a hashed password in the format used by this application. It takes 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, PBKDF2PasswordHasher,
PBKDF2SHA1PasswordHasher, PBKDF2SHA1PasswordHasher,
ScryptPasswordHasher, ScryptPasswordHasher,
acheck_password,
check_password, check_password,
get_hasher, get_hasher,
identify_hasher, identify_hasher,
@ -59,6 +60,15 @@ class TestUtilsHashPass(SimpleTestCase):
self.assertTrue(check_password("", blank_encoded)) self.assertTrue(check_password("", blank_encoded))
self.assertFalse(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): def test_bytes(self):
encoded = make_password(b"bytes_password") encoded = make_password(b"bytes_password")
self.assertTrue(encoded.startswith("pbkdf2_sha256$")) self.assertTrue(encoded.startswith("pbkdf2_sha256$"))

View File

@ -1,5 +1,7 @@
from unittest import mock from unittest import mock
from asgiref.sync import sync_to_async
from django.conf.global_settings import PASSWORD_HASHERS from django.conf.global_settings import PASSWORD_HASHERS
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
@ -312,6 +314,29 @@ class AbstractUserTestCase(TestCase):
finally: finally:
hasher.iterations = old_iterations 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): class CustomModelBackend(ModelBackend):
def with_perm( def with_perm(