summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHappyDingning <ssdyny@foxmail.com>2023-05-15 00:12:22 +0800
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2023-05-18 09:39:04 +0200
commit674c23999cb6982a9d447fedec4d72e135201fee (patch)
treeee9a368e355adca083a2a3583b06ba8a07c935fb
parent4e73d8c04d15f9cbae067249c7ff39dec9d66eb1 (diff)
Fixed #34565 -- Added support for async checking of user passwords.
-rw-r--r--django/contrib/auth/base_user.py12
-rw-r--r--django/contrib/auth/hashers.py34
-rw-r--r--docs/ref/contrib/auth.txt7
-rw-r--r--docs/releases/5.0.txt4
-rw-r--r--docs/topics/auth/customizing.txt7
-rw-r--r--docs/topics/auth/passwords.txt7
-rw-r--r--tests/auth_tests/test_hashers.py10
-rw-r--r--tests/auth_tests/test_models.py25
8 files changed, 98 insertions, 8 deletions
diff --git a/django/contrib/auth/base_user.py b/django/contrib/auth/base_user.py
index e205ccccf2..da0eac731f 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 e6544c7b55..b63904cd75 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 90ae5904a8..61fd74a38f 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 d40cd6a4f0..0c093720b9 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 6cc48cacb1..78bee37a0f 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 695c8d2571..0876ac4f6e 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 d1c26cfaa8..e10992b25c 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 41f822fdc7..19454ade65 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(