diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 7e9b7c090a51..f561897d9bb4 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -330,7 +330,6 @@ class PBKDF2PasswordHasher(BasePasswordHasher): def encode(self, password, salt, iterations=None): self._check_encode_args(password, salt) iterations = iterations or self.iterations - password = force_str(password) salt = force_str(salt) hash = pbkdf2(password, salt, iterations, digest=self.digest) hash = base64.b64encode(hash).decode("ascii").strip() @@ -664,10 +663,8 @@ class MD5PasswordHasher(BasePasswordHasher): def encode(self, password, salt): self._check_encode_args(password, salt) - password = force_str(password) - salt = force_str(salt) - hash = hashlib.md5((salt + password).encode()).hexdigest() - return "%s$%s$%s" % (self.algorithm, salt, hash) + hash = hashlib.md5(force_bytes(salt) + force_bytes(password)).hexdigest() + return "%s$%s$%s" % (self.algorithm, force_str(salt), hash) def decode(self, encoded): algorithm, salt, hash = encoded.split("$", 2) diff --git a/django/core/cache/backends/filebased.py b/django/core/cache/backends/filebased.py index 9f2ad48ac886..a07563acd8ab 100644 --- a/django/core/cache/backends/filebased.py +++ b/django/core/cache/backends/filebased.py @@ -73,7 +73,9 @@ def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None): self._write_content(f, timeout, previous_value) return True finally: - locks.unlock(f) + # Expired files are closed & unlocked by _is_expired(). + if not f.closed: + locks.unlock(f) except FileNotFoundError: return False diff --git a/docs/releases/6.0.7.txt b/docs/releases/6.0.7.txt index 9addb28b406a..9dd21b23d6af 100644 --- a/docs/releases/6.0.7.txt +++ b/docs/releases/6.0.7.txt @@ -9,4 +9,7 @@ Django 6.0.7 fixes several bugs in 6.0.6. Bugfixes ======== -* ... +* Fixed a regression in Django 6.0 where the PBKDF2 and MD5 password hashers + raised :exc:`UnicodeDecodeError` for :class:`bytes` passwords that were not + valid UTF-8. Passwords supplied as :class:`str` or as UTF-8 :class:`bytes` + are unaffected (:ticket:`37184`). diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py index 8815bb02d92c..cc678f681f0c 100644 --- a/tests/auth_tests/test_hashers.py +++ b/tests/auth_tests/test_hashers.py @@ -522,6 +522,30 @@ def test_encode_invalid_salt(self): with self.assertRaisesMessage(ValueError, msg): hasher.encode("password", salt) + def assertHasherVerifiesStrAndBytes(self, hasher, salts): + """ + If a string representation exists for a bytes password, then both + representations will verify against whichever form was stored. + Otherwise, only the bytes representation will verify. + """ + for pass_str, pass_bytes in [ + ("password", b"password"), + # Valid UTF-8, but not ASCII. + ("Lösenord", "Lösenord".encode()), + # Not UTF-8. + ("", b"non-utf-8-\xc0"), + ]: + with self.subTest(hasher=hasher.__class__.__name__, password=pass_bytes): + for password in (pass_str, pass_bytes): + if not password: + continue + for salt in salts: + encoded = hasher.encode(password, salt) + self.assertIs(hasher.verify(pass_bytes, encoded), True) + if not pass_str: + continue + self.assertIs(hasher.verify(pass_str, encoded), True) + def test_password_and_salt_in_str_and_bytes(self): hasher_classes = [ MD5PasswordHasher, @@ -531,25 +555,16 @@ def test_password_and_salt_in_str_and_bytes(self): ] for hasher_class in hasher_classes: hasher = hasher_class() - with self.subTest(hasher_class.__name__): - passwords = ["password", b"password"] - for password in passwords: - for salt in [hasher.salt(), hasher.salt().encode()]: - encoded = hasher.encode(password, salt) - for password_to_verify in passwords: - self.assertIs( - hasher.verify(password_to_verify, encoded), True - ) + self.assertHasherVerifiesStrAndBytes( + hasher, [hasher.salt(), hasher.salt().encode()] + ) @skipUnless(argon2, "argon2-cffi not installed") def test_password_and_salt_in_str_and_bytes_argon2(self): hasher = Argon2PasswordHasher() - passwords = ["password", b"password"] - for password in passwords: - for salt in [hasher.salt(), hasher.salt().encode()]: - encoded = hasher.encode(password, salt) - for password_to_verify in passwords: - self.assertIs(hasher.verify(password_to_verify, encoded), True) + self.assertHasherVerifiesStrAndBytes( + hasher, [hasher.salt(), hasher.salt().encode()] + ) @skipUnless(bcrypt, "bcrypt not installed") def test_password_and_salt_in_str_and_bytes_bcrypt(self): @@ -559,16 +574,9 @@ def test_password_and_salt_in_str_and_bytes_bcrypt(self): ] for hasher_class in hasher_classes: hasher = hasher_class() - with self.subTest(hasher_class.__name__): - passwords = ["password", b"password"] - for password in passwords: - salts = [hasher.salt().decode(), hasher.salt()] - for salt in salts: - encoded = hasher.encode(password, salt) - for password_to_verify in passwords: - self.assertIs( - hasher.verify(password_to_verify, encoded), True - ) + self.assertHasherVerifiesStrAndBytes( + hasher, [hasher.salt().decode(), hasher.salt()] + ) def test_encode_password_required(self): hasher_classes = [ diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 45e47854ab48..4b5d18fec114 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -1971,6 +1971,12 @@ def sleep(self, seconds): ): super().test_touch() + def test_touch_expired_key_does_not_crash(self): + cache.set("expired_touch_key", "value", timeout=0.01) + time.sleep(0.05) + result = cache.touch("expired_touch_key", 60) + self.assertIs(result, False) + @unittest.skipUnless(RedisCache_params, "Redis backend not configured") @override_settings(