Fixed #34565 -- Added support for async checking of user passwords.
This commit is contained in:
parent
4e73d8c04d
commit
674c23999c
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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$"))
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user