Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Removed unsalted password hashers. #6082

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 0 additions & 2 deletions django/conf/global_settings.py
Expand Up @@ -504,8 +504,6 @@ def gettext_noop(s):
'django.contrib.auth.hashers.BCryptPasswordHasher',
'django.contrib.auth.hashers.SHA1PasswordHasher',
'django.contrib.auth.hashers.MD5PasswordHasher',
'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher',
'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
'django.contrib.auth.hashers.CryptPasswordHasher',
]

Expand Down
76 changes: 1 addition & 75 deletions django/contrib/auth/hashers.py
Expand Up @@ -133,16 +133,7 @@ def identify_hasher(encoded):
get_hasher() to return hasher. Raises ValueError if
algorithm cannot be identified, or if hasher is not loaded.
"""
# Ancient versions of Django created plain MD5 passwords and accepted
# MD5 passwords with an empty salt.
if ((len(encoded) == 32 and '$' not in encoded) or
(len(encoded) == 37 and encoded.startswith('md5$$'))):
algorithm = 'unsalted_md5'
# Ancient versions of Django accepted SHA1 passwords with an empty salt.
elif len(encoded) == 46 and encoded.startswith('sha1$$'):
algorithm = 'unsalted_sha1'
else:
algorithm = encoded.split('$', 1)[0]
algorithm = encoded.split('$', 1)[0]
return get_hasher(algorithm)


Expand Down Expand Up @@ -417,71 +408,6 @@ def safe_summary(self, encoded):
])


class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
"""
Very insecure algorithm that you should *never* use; stores SHA1 hashes
with an empty salt.

This class is implemented because Django used to accept such password
hashes. Some older Django installs still have these values lingering
around so we need to handle and upgrade them properly.
"""
algorithm = "unsalted_sha1"

def salt(self):
return ''

def encode(self, password, salt):
assert salt == ''
hash = hashlib.sha1(force_bytes(password)).hexdigest()
return 'sha1$$%s' % hash

def verify(self, password, encoded):
encoded_2 = self.encode(password, '')
return constant_time_compare(encoded, encoded_2)

def safe_summary(self, encoded):
assert encoded.startswith('sha1$$')
hash = encoded[6:]
return OrderedDict([
(_('algorithm'), self.algorithm),
(_('hash'), mask_hash(hash)),
])


class UnsaltedMD5PasswordHasher(BasePasswordHasher):
"""
Incredibly insecure algorithm that you should *never* use; stores unsalted
MD5 hashes without the algorithm prefix, also accepts MD5 hashes with an
empty salt.

This class is implemented because Django used to store passwords this way
and to accept such password hashes. Some older Django installs still have
these values lingering around so we need to handle and upgrade them
properly.
"""
algorithm = "unsalted_md5"

def salt(self):
return ''

def encode(self, password, salt):
assert salt == ''
return hashlib.md5(force_bytes(password)).hexdigest()

def verify(self, password, encoded):
if len(encoded) == 37 and encoded.startswith('md5$$'):
encoded = encoded[5:]
encoded_2 = self.encode(password, '')
return constant_time_compare(encoded, encoded_2)

def safe_summary(self, encoded):
return OrderedDict([
(_('algorithm'), self.algorithm),
(_('hash'), mask_hash(encoded, show=3)),
])


class CryptPasswordHasher(BasePasswordHasher):
"""
Password hashing using UNIX crypt (not recommended)
Expand Down
2 changes: 0 additions & 2 deletions docs/ref/settings.txt
Expand Up @@ -2688,8 +2688,6 @@ Default::
'django.contrib.auth.hashers.BCryptPasswordHasher',
'django.contrib.auth.hashers.SHA1PasswordHasher',
'django.contrib.auth.hashers.MD5PasswordHasher',
'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher',
'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
'django.contrib.auth.hashers.CryptPasswordHasher',
]

Expand Down
31 changes: 31 additions & 0 deletions docs/releases/1.10.txt
Expand Up @@ -448,6 +448,37 @@ output::
}
}

Support for unsalted password hashers is removed
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Django 0.90 stored passwords as unsalted MD5. In Django 1.4, the password
hashing infrastructure was improved and the ``UnsaltedSHA1PasswordHasher`` and
``UnsaltedMD5PasswordHasher`` hashers were added to allow those passwords to
continue to be accepted. If a user has logged in since then, their password
hash will be upgraded. You can check if you have any hashes with the removed
algorithms like this::

>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> # Unsalted MD5/SHA1 have the following prefixes:
>>> User.objects.filter(password__startswith='md5$$')
>>> User.objects.filter(password__startswith='sha1$$')

>>> from django.db.models import CharField
>>> from django.db.models.functions import Length
>>> CharField.register_lookup(Length)
>>> # Some MD5 passwords don't have an MD5 prefix
>>> User.objects.filter(password__length=32)

If you still have these passwords in your database, we recommend them setting
them as unusable and forcing those users to reset their password::

>>> for user in users:
... user.set_unusable_password()
... user.save(update_fields=['password'])

Chances are probably low that these users will return to your site anyway.

Miscellaneous
~~~~~~~~~~~~~

Expand Down
7 changes: 3 additions & 4 deletions docs/topics/auth/passwords.txt
Expand Up @@ -231,10 +231,9 @@ from the ``User`` model.
defaults (first entry of ``PASSWORD_HASHERS`` setting).
Currently supported algorithms are: ``'pbkdf2_sha256'``, ``'pbkdf2_sha1'``,
``'bcrypt_sha256'`` (see :ref:`bcrypt_usage`), ``'bcrypt'``, ``'sha1'``,
``'md5'``, ``'unsalted_md5'`` (only for backward compatibility) and ``'crypt'``
if you have the ``crypt`` library installed. If the password argument is
``None``, an unusable password is returned (a one that will be never
accepted by :func:`check_password`).
``'md5'``, and ``'crypt'`` if you have the ``crypt`` library installed. If
the password argument is ``None``, an unusable password is returned (a one
that will be never accepted by :func:`check_password`).

.. function:: is_password_usable(encoded_password)

Expand Down
35 changes: 0 additions & 35 deletions tests/auth_tests/test_hashers.py
Expand Up @@ -90,41 +90,6 @@ def test_md5(self):
self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded))

def test_unsalted_md5(self):
encoded = make_password('lètmein', '', 'unsalted_md5')
self.assertEqual(encoded, '88a434c88cca4e900f7874cd98123f43')
self.assertTrue(is_password_usable(encoded))
self.assertTrue(check_password('lètmein', encoded))
self.assertFalse(check_password('lètmeinz', encoded))
self.assertEqual(identify_hasher(encoded).algorithm, "unsalted_md5")
# Alternate unsalted syntax
alt_encoded = "md5$$%s" % encoded
self.assertTrue(is_password_usable(alt_encoded))
self.assertTrue(check_password('lètmein', alt_encoded))
self.assertFalse(check_password('lètmeinz', alt_encoded))
# Blank passwords
blank_encoded = make_password('', '', 'unsalted_md5')
self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded))

def test_unsalted_sha1(self):
encoded = make_password('lètmein', '', 'unsalted_sha1')
self.assertEqual(encoded, 'sha1$$6d138ca3ae545631b3abd71a4f076ce759c5700b')
self.assertTrue(is_password_usable(encoded))
self.assertTrue(check_password('lètmein', encoded))
self.assertFalse(check_password('lètmeinz', encoded))
self.assertEqual(identify_hasher(encoded).algorithm, "unsalted_sha1")
# Raw SHA1 isn't acceptable
alt_encoded = encoded[6:]
self.assertFalse(check_password('lètmein', alt_encoded))
# Blank passwords
blank_encoded = make_password('', '', 'unsalted_sha1')
self.assertTrue(blank_encoded.startswith('sha1$'))
self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded))

@skipUnless(crypt, "no crypt module to generate password.")
def test_crypt(self):
encoded = make_password('lètmei', 'ab', 'crypt')
Expand Down