From 3b79dab19a2300a4884a3d81baa6c7c1f2dee059 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 23 Jul 2022 12:45:24 +0200 Subject: [PATCH] Refs #33691 -- Deprecated insecure password hashers. SHA1PasswordHasher, UnsaltedSHA1PasswordHasher, and UnsaltedMD5PasswordHasher are now deprecated. --- django/contrib/auth/hashers.py | 29 +++++++++++++++++++- docs/internals/deprecation.txt | 4 +++ docs/releases/4.2.txt | 4 +++ docs/topics/auth/passwords.txt | 45 ++++++++++++++------------------ tests/auth_tests/test_hashers.py | 42 +++++++++++++++++++++++------ 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 10c9079200..432c624483 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -17,7 +17,7 @@ from django.utils.crypto import ( md5, pbkdf2, ) -from django.utils.deprecation import RemovedInDjango50Warning +from django.utils.deprecation import RemovedInDjango50Warning, RemovedInDjango51Warning from django.utils.module_loading import import_string from django.utils.translation import gettext_noop as _ @@ -624,6 +624,7 @@ class ScryptPasswordHasher(BasePasswordHasher): pass +# RemovedInDjango51Warning. class SHA1PasswordHasher(BasePasswordHasher): """ The SHA1 password hashing algorithm (not recommended) @@ -631,6 +632,14 @@ class SHA1PasswordHasher(BasePasswordHasher): algorithm = "sha1" + def __init__(self, *args, **kwargs): + warnings.warn( + "django.contrib.auth.hashers.SHA1PasswordHasher is deprecated.", + RemovedInDjango51Warning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + def encode(self, password, salt): self._check_encode_args(password, salt) hash = hashlib.sha1((salt + password).encode()).hexdigest() @@ -708,6 +717,7 @@ class MD5PasswordHasher(BasePasswordHasher): pass +# RemovedInDjango51Warning. class UnsaltedSHA1PasswordHasher(BasePasswordHasher): """ Very insecure algorithm that you should *never* use; store SHA1 hashes @@ -720,6 +730,14 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher): algorithm = "unsalted_sha1" + def __init__(self, *args, **kwargs): + warnings.warn( + "django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher is deprecated.", + RemovedInDjango51Warning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + def salt(self): return "" @@ -752,6 +770,7 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher): pass +# RemovedInDjango51Warning. class UnsaltedMD5PasswordHasher(BasePasswordHasher): """ Incredibly insecure algorithm that you should *never* use; stores unsalted @@ -766,6 +785,14 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher): algorithm = "unsalted_md5" + def __init__(self, *args, **kwargs): + warnings.warn( + "django.contrib.auth.hashers.UnsaltedMD5PasswordHasher is deprecated.", + RemovedInDjango51Warning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + def salt(self): return "" diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 43c7a0fee9..80597eb95b 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -24,6 +24,10 @@ details on these changes. * The ``length_is`` template filter will be removed. +* The ``django.contrib.auth.hashers.SHA1PasswordHasher``, + ``django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher``, and + ``django.contrib.auth.hashers.UnsaltedMD5PasswordHasher`` will be removed. + .. _deprecation-removed-in-5.0: 5.0 diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt index 0846f05e2c..1d55ea2f68 100644 --- a/docs/releases/4.2.txt +++ b/docs/releases/4.2.txt @@ -332,3 +332,7 @@ Miscellaneous {% if value|length_is:4 %}…{% endif %} {{ value|length_is:4 }} + +* ``django.contrib.auth.hashers.SHA1PasswordHasher``, + ``django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher``, and + ``django.contrib.auth.hashers.UnsaltedMD5PasswordHasher`` are deprecated. diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt index 3836ab4006..25c98bf786 100644 --- a/docs/topics/auth/passwords.txt +++ b/docs/topics/auth/passwords.txt @@ -329,12 +329,12 @@ to mitigate this by :ref:`upgrading older password hashes Password upgrading without requiring a login -------------------------------------------- -If you have an existing database with an older, weak hash such as MD5 or SHA1, -you might want to upgrade those hashes yourself instead of waiting for the -upgrade to happen when a user logs in (which may never happen if a user doesn't -return to your site). In this case, you can use a "wrapped" password hasher. +If you have an existing database with an older, weak hash such as MD5, you +might want to upgrade those hashes yourself instead of waiting for the upgrade +to happen when a user logs in (which may never happen if a user doesn't return +to your site). In this case, you can use a "wrapped" password hasher. -For this example, we'll migrate a collection of SHA1 hashes to use +For this example, we'll migrate a collection of MD5 hashes to use PBKDF2(SHA1(password)) and add the corresponding password hasher for checking if a user entered the correct password on login. We assume we're using the built-in ``User`` model and that our project has an ``accounts`` app. You can @@ -346,37 +346,37 @@ First, we'll add the custom hasher: :caption: ``accounts/hashers.py`` from django.contrib.auth.hashers import ( - PBKDF2PasswordHasher, SHA1PasswordHasher, + PBKDF2PasswordHasher, MD5PasswordHasher, ) - class PBKDF2WrappedSHA1PasswordHasher(PBKDF2PasswordHasher): - algorithm = 'pbkdf2_wrapped_sha1' + class PBKDF2WrappedMD5PasswordHasher(PBKDF2PasswordHasher): + algorithm = 'pbkdf2_wrapped_md5' - def encode_sha1_hash(self, sha1_hash, salt, iterations=None): - return super().encode(sha1_hash, salt, iterations) + def encode_md5_hash(self, md5_hash, salt, iterations=None): + return super().encode(md5_hash, salt, iterations) def encode(self, password, salt, iterations=None): - _, _, sha1_hash = SHA1PasswordHasher().encode(password, salt).split('$', 2) - return self.encode_sha1_hash(sha1_hash, salt, iterations) + _, _, md5_hash = MD5PasswordHasher().encode(password, salt).split('$', 2) + return self.encode_md5_hash(md5_hash, salt, iterations) The data migration might look something like: .. code-block:: python - :caption: ``accounts/migrations/0002_migrate_sha1_passwords.py`` + :caption: ``accounts/migrations/0002_migrate_md5_passwords.py`` from django.db import migrations - from ..hashers import PBKDF2WrappedSHA1PasswordHasher + from ..hashers import PBKDF2WrappedMD5PasswordHasher def forwards_func(apps, schema_editor): User = apps.get_model('auth', 'User') - users = User.objects.filter(password__startswith='sha1$') - hasher = PBKDF2WrappedSHA1PasswordHasher() + users = User.objects.filter(password__startswith='md5$') + hasher = PBKDF2WrappedMD5PasswordHasher() for user in users: - algorithm, salt, sha1_hash = user.password.split('$', 2) - user.password = hasher.encode_sha1_hash(sha1_hash, salt) + algorithm, salt, md5_hash = user.password.split('$', 2) + user.password = hasher.encode_md5_hash(md5_hash, salt) user.save(update_fields=['password']) @@ -402,12 +402,11 @@ Finally, we'll add a :setting:`PASSWORD_HASHERS` setting: PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.PBKDF2PasswordHasher', - 'accounts.hashers.PBKDF2WrappedSHA1PasswordHasher', + 'accounts.hashers.PBKDF2WrappedMD5PasswordHasher', ] Include any other hashers that your site uses in this list. -.. _sha1: https://en.wikipedia.org/wiki/SHA1 .. _pbkdf2: https://en.wikipedia.org/wiki/PBKDF2 .. _nist: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf .. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt @@ -431,10 +430,7 @@ The full list of hashers included in Django is:: 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', 'django.contrib.auth.hashers.ScryptPasswordHasher', - 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', - 'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher', - 'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', ] The corresponding algorithm names are: @@ -445,10 +441,7 @@ The corresponding algorithm names are: * ``bcrypt_sha256`` * ``bcrypt`` * ``scrypt`` -* ``sha1`` * ``md5`` -* ``unsalted_sha1`` -* ``unsalted_md5`` .. _write-your-own-password-hasher: diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py index d0965fb6c1..a5dfd51a6b 100644 --- a/tests/auth_tests/test_hashers.py +++ b/tests/auth_tests/test_hashers.py @@ -11,7 +11,6 @@ from django.contrib.auth.hashers import ( PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, ScryptPasswordHasher, - SHA1PasswordHasher, check_password, get_hasher, identify_hasher, @@ -20,7 +19,7 @@ from django.contrib.auth.hashers import ( ) from django.test import SimpleTestCase, ignore_warnings from django.test.utils import override_settings -from django.utils.deprecation import RemovedInDjango50Warning +from django.utils.deprecation import RemovedInDjango50Warning, RemovedInDjango51Warning # RemovedInDjango50Warning. try: @@ -96,6 +95,7 @@ class TestUtilsHashPass(SimpleTestCase): self.assertIs(hasher.must_update(encoded_weak_salt), True) self.assertIs(hasher.must_update(encoded_strong_salt), False) + @ignore_warnings(category=RemovedInDjango51Warning) @override_settings( PASSWORD_HASHERS=["django.contrib.auth.hashers.SHA1PasswordHasher"] ) @@ -121,6 +121,14 @@ class TestUtilsHashPass(SimpleTestCase): self.assertIs(hasher.must_update(encoded_weak_salt), True) self.assertIs(hasher.must_update(encoded_strong_salt), False) + @override_settings( + PASSWORD_HASHERS=["django.contrib.auth.hashers.SHA1PasswordHasher"] + ) + def test_sha1_deprecation_warning(self): + msg = "django.contrib.auth.hashers.SHA1PasswordHasher is deprecated." + with self.assertRaisesMessage(RemovedInDjango51Warning, msg): + get_hasher("sha1") + @override_settings( PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"] ) @@ -144,6 +152,7 @@ class TestUtilsHashPass(SimpleTestCase): self.assertIs(hasher.must_update(encoded_weak_salt), True) self.assertIs(hasher.must_update(encoded_strong_salt), False) + @ignore_warnings(category=RemovedInDjango51Warning) @override_settings( PASSWORD_HASHERS=["django.contrib.auth.hashers.UnsaltedMD5PasswordHasher"] ) @@ -165,6 +174,7 @@ class TestUtilsHashPass(SimpleTestCase): self.assertTrue(check_password("", blank_encoded)) self.assertFalse(check_password(" ", blank_encoded)) + @ignore_warnings(category=RemovedInDjango51Warning) @override_settings( PASSWORD_HASHERS=["django.contrib.auth.hashers.UnsaltedMD5PasswordHasher"] ) @@ -174,6 +184,15 @@ class TestUtilsHashPass(SimpleTestCase): with self.assertRaisesMessage(ValueError, msg): hasher.encode("password", salt="salt") + @override_settings( + PASSWORD_HASHERS=["django.contrib.auth.hashers.UnsaltedMD5PasswordHasher"] + ) + def test_unsalted_md5_deprecation_warning(self): + msg = "django.contrib.auth.hashers.UnsaltedMD5PasswordHasher is deprecated." + with self.assertRaisesMessage(RemovedInDjango51Warning, msg): + get_hasher("unsalted_md5") + + @ignore_warnings(category=RemovedInDjango51Warning) @override_settings( PASSWORD_HASHERS=["django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher"] ) @@ -194,6 +213,7 @@ class TestUtilsHashPass(SimpleTestCase): self.assertTrue(check_password("", blank_encoded)) self.assertFalse(check_password(" ", blank_encoded)) + @ignore_warnings(category=RemovedInDjango51Warning) @override_settings( PASSWORD_HASHERS=["django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher"] ) @@ -203,6 +223,14 @@ class TestUtilsHashPass(SimpleTestCase): with self.assertRaisesMessage(ValueError, msg): hasher.encode("password", salt="salt") + @override_settings( + PASSWORD_HASHERS=["django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher"] + ) + def test_unsalted_sha1_deprecation_warning(self): + msg = "django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher is deprecated." + with self.assertRaisesMessage(RemovedInDjango51Warning, msg): + get_hasher("unsalted_sha1") + @ignore_warnings(category=RemovedInDjango50Warning) @skipUnless(crypt, "no crypt module to generate password.") @override_settings( @@ -432,13 +460,13 @@ class TestUtilsHashPass(SimpleTestCase): @override_settings( PASSWORD_HASHERS=[ "django.contrib.auth.hashers.PBKDF2PasswordHasher", - "django.contrib.auth.hashers.SHA1PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", "django.contrib.auth.hashers.MD5PasswordHasher", ], ) def test_upgrade(self): self.assertEqual("pbkdf2_sha256", get_hasher("default").algorithm) - for algo in ("sha1", "md5"): + for algo in ("pbkdf2_sha1", "md5"): with self.subTest(algo=algo): encoded = make_password("lètmein", hasher=algo) state = {"upgraded": False} @@ -462,13 +490,13 @@ class TestUtilsHashPass(SimpleTestCase): @override_settings( PASSWORD_HASHERS=[ "django.contrib.auth.hashers.PBKDF2PasswordHasher", - "django.contrib.auth.hashers.SHA1PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", "django.contrib.auth.hashers.MD5PasswordHasher", ], ) def test_no_upgrade_on_incorrect_pass(self): self.assertEqual("pbkdf2_sha256", get_hasher("default").algorithm) - for algo in ("sha1", "md5"): + for algo in ("pbkdf2_sha1", "md5"): with self.subTest(algo=algo): encoded = make_password("lètmein", hasher=algo) state = {"upgraded": False} @@ -583,7 +611,6 @@ class TestUtilsHashPass(SimpleTestCase): PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, ScryptPasswordHasher, - SHA1PasswordHasher, ] msg = "salt must be provided and cannot contain $." for hasher_class in hasher_classes: @@ -599,7 +626,6 @@ class TestUtilsHashPass(SimpleTestCase): PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, ScryptPasswordHasher, - SHA1PasswordHasher, ] msg = "password must be provided." for hasher_class in hasher_classes: