Commits

Pa...@bcc190cf-cafb-0310-a4f2-bffc1f526a37  committed d888614

Renovated password hashing. Many thanks to Justine Tunney for help with the initial patch.

  • Participants
  • Parent commits e1ae990

Comments (0)

Files changed (9)

File django/conf/global_settings.py

 # The number of days a password reset link is valid for
 PASSWORD_RESET_TIMEOUT_DAYS = 3
 
+# the first hasher in this list is the preferred algorithm.  any
+# password using different algorithms will be converted automatically
+# upon login
+PASSWORD_HASHERS = (
+    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
+    'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
+    'django.contrib.auth.hashers.BCryptPasswordHasher',
+    'django.contrib.auth.hashers.SHA1PasswordHasher',
+    'django.contrib.auth.hashers.MD5PasswordHasher',
+    'django.contrib.auth.hashers.CryptPasswordHasher',
+)
+
 ###########
 # SIGNING #
 ###########

File django/contrib/auth/forms.py

 from django import forms
 from django.forms.util import flatatt
 from django.template import loader
+from django.utils.encoding import smart_str
 from django.utils.http import int_to_base36
 from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
 
+from django.contrib.auth import authenticate
 from django.contrib.auth.models import User
-from django.contrib.auth.utils import UNUSABLE_PASSWORD
-from django.contrib.auth import authenticate
+from django.contrib.auth.hashers import UNUSABLE_PASSWORD, is_password_usable, get_hasher
 from django.contrib.auth.tokens import default_token_generator
 from django.contrib.sites.models import get_current_site
 
 
 class ReadOnlyPasswordHashWidget(forms.Widget):
     def render(self, name, value, attrs):
-        if not value:
+        encoded = value
+
+        if not is_password_usable(encoded):
             return "None"
+
         final_attrs = self.build_attrs(attrs)
-        parts = value.split("$")
-        if len(parts) != 3:
-            # Legacy passwords didn't specify a hash type and were md5.
-            hash_type = "md5"
-            masked = mask_password(value)
+
+        encoded = smart_str(encoded)
+
+        if len(encoded) == 32 and '$' not in encoded:
+            hasher = get_hasher('md5')
         else:
-            hash_type = parts[0]
-            masked = mask_password(parts[2])
-        return mark_safe("""<div%(attrs)s>
-                    <strong>%(hash_type_label)s</strong>: %(hash_type)s
-                    <strong>%(masked_label)s</strong>: %(masked)s
-                </div>""" % {
-                    "attrs": flatatt(final_attrs),
-                    "hash_type_label": _("Hash type"),
-                    "hash_type": hash_type,
-                    "masked_label": _("Masked hash"),
-                    "masked": masked,
-                })
+            algorithm = encoded.split('$', 1)[0]
+            hasher = get_hasher(algorithm)
+
+        summary = ""
+        for key, value in hasher.safe_summary(encoded).iteritems():
+            summary += "<strong>%(key)s</strong>: %(value)s " % {"key": key, "value": value}
+
+        return mark_safe("<div%(attrs)s>%(summary)s</div>" % {"attrs": flatatt(final_attrs), "summary": summary})
 
 
 class ReadOnlyPasswordHashField(forms.Field):

File django/contrib/auth/models.py

 from django.utils import timezone
 
 from django.contrib import auth
+# UNUSABLE_PASSWORD is still imported here for backwards compatibility
+from django.contrib.auth.hashers import (
+    check_password, make_password, is_password_usable, UNUSABLE_PASSWORD)
 from django.contrib.auth.signals import user_logged_in
-# UNUSABLE_PASSWORD is still imported here for backwards compatibility
-from django.contrib.auth.utils import (get_hexdigest, make_password,
-        check_password, is_password_usable, get_random_string,
-        UNUSABLE_PASSWORD)
 from django.contrib.contenttypes.models import ContentType
 
 def update_last_login(sender, user, **kwargs):
         return full_name.strip()
 
     def set_password(self, raw_password):
-        self.password = make_password('sha1', raw_password)
+        self.password = make_password(raw_password)
 
     def check_password(self, raw_password):
         """
         Returns a boolean of whether the raw_password was correct. Handles
         hashing formats behind the scenes.
         """
-        # Backwards-compatibility check. Older passwords won't include the
-        # algorithm or salt.
-        if '$' not in self.password:
-            is_correct = (self.password == get_hexdigest('md5', '', raw_password))
-            if is_correct:
-                # Convert the password to the new, more secure format.
-                self.set_password(raw_password)
-                self.save()
-            return is_correct
-        return check_password(raw_password, self.password)
+        def setter(raw_password):
+            self.set_password(raw_password)
+            self.save()
+        return check_password(raw_password, self.password, setter)
 
     def set_unusable_password(self):
         # Sets a value that will never be a valid hash
-        self.password = make_password('sha1', None)
+        self.password = make_password(None)
 
     def has_usable_password(self):
         return is_password_usable(self.password)

File django/contrib/auth/tests/__init__.py

 from django.contrib.auth.tests.auth_backends import (BackendTest,
     RowlevelBackendTest, AnonymousUserBackendTest, NoBackendsTest,
     InActiveUserBackendTest, NoInActiveUserBackendTest)
-from django.contrib.auth.tests.basic import BasicTestCase, PasswordUtilsTestCase
+from django.contrib.auth.tests.basic import BasicTestCase
 from django.contrib.auth.tests.context_processors import AuthContextProcessorTests
 from django.contrib.auth.tests.decorators import LoginRequiredTestCase
 from django.contrib.auth.tests.forms import (UserCreationFormTest,
     RemoteUserNoCreateTest, RemoteUserCustomTest)
 from django.contrib.auth.tests.management import GetDefaultUsernameTestCase
 from django.contrib.auth.tests.models import ProfileTestCase
+from django.contrib.auth.tests.hashers import TestUtilsHashPass
 from django.contrib.auth.tests.signals import SignalTestCase
 from django.contrib.auth.tests.tokens import TokenGeneratorTest
-from django.contrib.auth.tests.views import (AuthViewNamedURLTests, PasswordResetTest,
-    ChangePasswordTest, LoginTest, LogoutTest, LoginURLSettings)
+from django.contrib.auth.tests.views import (AuthViewNamedURLTests, 
+    PasswordResetTest, ChangePasswordTest, LoginTest, LogoutTest, 
+    LoginURLSettings)
 
 # The password for the fixture data users is 'password'

File django/contrib/auth/tests/basic.py

 from django.test import TestCase
 from django.utils.unittest import skipUnless
 from django.contrib.auth.models import User, AnonymousUser
-from django.contrib.auth import utils
 from django.core.management import call_command
 from StringIO import StringIO
 
         u = User.objects.get(username="joe+admin@somewhere.org")
         self.assertEqual(u.email, 'joe@somewhere.org')
         self.assertFalse(u.has_usable_password())
-
-
-class PasswordUtilsTestCase(TestCase):
-
-    def _test_make_password(self, algo):
-        password = utils.make_password(algo, "foobar")
-        self.assertTrue(utils.is_password_usable(password))
-        self.assertTrue(utils.check_password("foobar", password))
-
-    def test_make_unusable(self):
-        "Check that you can create an unusable password."
-        password = utils.make_password("any", None)
-        self.assertFalse(utils.is_password_usable(password))
-        self.assertFalse(utils.check_password("foobar", password))
-
-    def test_make_password_sha1(self):
-        "Check creating passwords with SHA1 algorithm."
-        self._test_make_password("sha1")
-
-    def test_make_password_md5(self):
-        "Check creating passwords with MD5 algorithm."
-        self._test_make_password("md5")
-
-    @skipUnless(crypt_module, "no crypt module to generate password.")
-    def test_make_password_crypt(self):
-        "Check creating passwords with CRYPT algorithm."
-        self._test_make_password("crypt")

File django/utils/crypto.py

 Django's standard crypto functions and utilities.
 """
 
+import hmac
+import struct
 import hashlib
-import hmac
+import binascii
+import operator
 from django.conf import settings
 
+
+trans_5c = "".join([chr(x ^ 0x5C) for x in xrange(256)])
+trans_36 = "".join([chr(x ^ 0x36) for x in xrange(256)])
+
+
 def salted_hmac(key_salt, value, secret=None):
     """
     Returns the HMAC-SHA1 of 'value', using a key generated from key_salt and a
     # However, we need to ensure that we *always* do this.
     return hmac.new(key, msg=value, digestmod=hashlib.sha1)
 
+
+def get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'):
+    """
+    Returns a random string of length characters from the set of a-z, A-Z, 0-9
+    for use as a salt.
+
+    The default length of 12 with the a-z, A-Z, 0-9 character set returns
+    a 71-bit salt. log_2((26+26+10)^12) =~ 71 bits
+    """
+    import random
+    try:
+        random = random.SystemRandom()
+    except NotImplementedError:
+        pass
+    return ''.join([random.choice(allowed_chars) for i in range(length)])
+
+
 def constant_time_compare(val1, val2):
     """
     Returns True if the two strings are equal, False otherwise.
     for x, y in zip(val1, val2):
         result |= ord(x) ^ ord(y)
     return result == 0
+
+
+def bin_to_long(x):
+    """
+    Convert a binary string into a long integer
+
+    This is a clever optimization for fast xor vector math
+    """
+    return long(x.encode('hex'), 16)
+
+
+def long_to_bin(x):
+    """
+    Convert a long integer into a binary string
+    """
+    hex = "%x" % (x)
+    if len(hex) % 2 == 1:
+        hex = '0' + hex
+    return binascii.unhexlify(hex)
+
+
+def fast_hmac(key, msg, digest):
+    """
+    A trimmed down version of Python's HMAC implementation
+    """
+    dig1, dig2 = digest(), digest()
+    if len(key) > dig1.block_size:
+        key = digest(key).digest()
+    key += chr(0) * (dig1.block_size - len(key))
+    dig1.update(key.translate(trans_36))
+    dig1.update(msg)
+    dig2.update(key.translate(trans_5c))
+    dig2.update(dig1.digest())
+    return dig2
+
+
+def pbkdf2(password, salt, iterations, dklen=0, digest=None):
+    """
+    Implements PBKDF2 as defined in RFC 2898, section 5.2
+
+    HMAC+SHA256 is used as the default pseudo random function.
+
+    Right now 10,000 iterations is the recommended default which takes
+    100ms on a 2.2Ghz Core 2 Duo.  This is probably the bare minimum
+    for security given 1000 iterations was recommended in 2001. This
+    code is very well optimized for CPython and is only four times
+    slower than openssl's implementation.
+    """
+    assert iterations > 0
+    if not digest:
+        digest = hashlib.sha256
+    hlen = digest().digest_size
+    if not dklen:
+        dklen = hlen
+    if dklen > (2 ** 32 - 1) * hlen:
+        raise OverflowError('dklen too big')
+    l = -(-dklen // hlen)
+    r = dklen - (l - 1) * hlen
+
+    def F(i):
+        def U():
+            u = salt + struct.pack('>I', i)
+            for j in xrange(int(iterations)):
+                u = fast_hmac(password, u, digest).digest()
+                yield bin_to_long(u)
+        return long_to_bin(reduce(operator.xor, U()))
+
+    T = [F(x) for x in range(1, l + 1)]
+    return ''.join(T[:-1]) + T[-1][:r]

File docs/releases/1.4.txt

 doing O(n) database queries (or worse) if objects on your primary ``QuerySet``
 each have many related objects that you also need.
 
+Improved password hashing
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Django's auth system (``django.contrib.auth``) stores passwords using a one-way
+algorithm. Django 1.3 uses the SHA1_ algorithm, but increasing processor speeds
+and theoretical attacks have revealed that SHA1 isn't as secure as we'd like.
+Thus, Django 1.4 introduces a new password storage system: by default Django now
+uses the PBKDF2_ algorithm (as recommended by NIST_). You can also easily choose
+a different algorithm (including the popular bcrypt_ algorithm). For more
+details, see :ref:`auth_password_storage`.
+
+.. _sha1: http://en.wikipedia.org/wiki/SHA1
+.. _pbkdf2: http://en.wikipedia.org/wiki/PBKDF2
+.. _nist: http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf
+.. _bcrypt: http://en.wikipedia.org/wiki/Bcrypt
+
 HTML5 Doctype
 ~~~~~~~~~~~~~
 

File docs/topics/auth.txt

 directly unless you know what you're doing. This is explained in the next
 section.
 
-Passwords
----------
+.. _auth_password_storage:
+
+How Django stores passwords
+---------------------------
+
+.. versionadded:: 1.4
+   Django 1.4 introduces a new flexible password storage system and uses
+   PBKDF2 by default. Previous versions of Django used SHA1, and other
+   algorithms couldn't be chosen.
 
 The :attr:`~django.contrib.auth.models.User.password` attribute of a
 :class:`~django.contrib.auth.models.User` object is a string in this format::
 
-    hashtype$salt$hash
-
-That's hashtype, salt and hash, separated by the dollar-sign character.
-
-Hashtype is either ``sha1`` (default), ``md5`` or ``crypt`` -- the algorithm
-used to perform a one-way hash of the password. Salt is a random string used
-to salt the raw password to create the hash. Note that the ``crypt`` method is
-only supported on platforms that have the standard Python ``crypt`` module
-available.
-
-For example::
-
-    sha1$a1976$a36cc8cbf81742a8fb52e221aaeab48ed7f58ab4
-
-The :meth:`~django.contrib.auth.models.User.set_password` and
-:meth:`~django.contrib.auth.models.User.check_password` functions handle the
-setting and checking of these values behind the scenes.
-
-Previous Django versions, such as 0.90, used simple MD5 hashes without password
-salts. For backwards compatibility, those are still supported; they'll be
-converted automatically to the new style the first time
-:meth:`~django.contrib.auth.models.User.check_password()` works correctly for
-a given user.
+    algorithm$hash
+
+That's a storage algorithm, and hash, separated by the dollar-sign
+character. The algorithm is one of a number of one way hashing or password
+storage algorithms Django can use; see below. The hash is the result of the one-
+way function.
+
+By default, Django uses the PBKDF2_ algorithm with a SHA256 hash, a
+password stretching mechanism recommended by NIST_. This should be
+sufficient for most users: it's quite secure, requiring massive
+amounts of computing time to break.
+
+However, depending on your requirements, you may choose a different
+algorithm, or even use a custom algorithm to match your specific
+security situation. Again, most users shouldn't need to do this -- if
+you're not sure, you probably don't.  If you do, please read on:
+
+Django chooses the an algorithm by consulting the :setting:`PASSWORD_HASHERS`
+setting. This is a list of hashing algorithm classes that this Django
+installation supports. The first entry in this list (that is,
+``settings.PASSWORD_HASHERS[0]``) will be used to store passwords, and all the
+other entries are valid hashers that can be used to check existing passwords.
+This means that if you want to use a different algorithm, you'll need to modify
+:setting:`PASSWORD_HASHERS` to list your prefered algorithm first in the list.
+
+The default for :setting:`PASSWORD_HASHERS` is::
+
+    PASSWORD_HASHERS = (
+        'django.contrib.auth.hashers.PBKDF2PasswordHasher',
+        'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
+        'django.contrib.auth.hashers.BCryptPasswordHasher',
+        'django.contrib.auth.hashers.SHA1PasswordHasher',
+        'django.contrib.auth.hashers.MD5PasswordHasher',
+        'django.contrib.auth.hashers.CryptPasswordHasher',
+    )
+
+This means that Django will use PBKDF2_ to store all passwords, but will support
+checking passwords stored with PBKDF2SHA1, bcrypt_, SHA1_, etc. The next few
+sections describe a couple of common ways advanced users may want to modify this
+setting.
+
+Using bcrypt with Django
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Bcrypt_ is a popular password storage algorithm that's specifically designed
+for long-term password storage. It's not the default used by Django since it
+requires the use of third-party libraries, but since many people may want to
+use it Django supports bcrypt with minimal effort.
+
+To use Bcrypt as your default storage algorithm, do the following:
+
+    1. Install the `py-bcrypt`_ library (probably by running ``pip install py-bcrypt``,
+       ``easy_install py-bcrypt``, or downloading the library and installing
+       it with ``python setup.py install``).
+
+    2. Modify :setting:`PASSWORD_HASHERS` to list ``BCryptPasswordHasher``
+       first. That is, in your settings file, you'd put::
+
+            PASSWORD_HASHERS = (
+                'django.contrib.auth.hashers.BCryptPasswordHasher',
+                'django.contrib.auth.hashers.PBKDF2PasswordHasher',
+                'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
+                'django.contrib.auth.hashers.SHA1PasswordHasher',
+                'django.contrib.auth.hashers.MD5PasswordHasher',
+                'django.contrib.auth.hashers.CryptPasswordHasher',
+            )
+
+       (You need to keep the other entries in this list, or else Django won't
+       be able to upgrade passwords; see below).
+
+That's it -- now your Django install will use Bcrypt as the default storage
+algorithm.
+
+.. admonition:: Other bcrypt implementations
+
+   There are several other implementations that allow bcrypt to be
+   used with Django. Django's bcrypt support is NOT directly
+   compatible with these. To upgrade, you will need to modify the
+   hashes in your database to be in the form `bcrypt$(raw bcrypt
+   output)`. For example:
+   `bcrypt$$2a$12$NT0I31Sa7ihGEWpka9ASYrEFkhuTNeBQ2xfZskIiiJeyFXhRgS.Sy`.
+
+Increasing the work factor
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The PDKDF2 and bcrypt algorithms use a number of iterations or rounds of
+hashing. This deliberately slows down attackers, making attacks against hashed
+passwords harder. However, as computing power increases, the number of
+iterations needs to be increased. We've chosen a reasonable default (and will
+increase it with each release of Django), but you may wish to tune it up or
+down, depending on your security needs and available processing power. To do so,
+you'll subclass the appropriate algorithm and override the ``iterations``
+parameters. For example, to increase the number of iterations used by the
+default PDKDF2 algorithm:
+
+    1. Create a subclass of ``django.contrib.auth.hashers.PBKDF2PasswordHasher``::
+
+            from django.contrib.auth.hashers import PBKDF2PasswordHasher
+
+            class MyPBKDF2PasswordHasher(PBKDF2PasswordHasher):
+                """
+                A subclass of PBKDF2PasswordHasher that uses 100 times more iterations.
+                """
+                iterations = PBKDF2PasswordHasher.iterations * 100
+
+       Save this somewhere in your project. For example, you might put this in
+       a file like ``myproject/hashers.py``.
+
+    2. Add your new hasher as the first entry in :setting:`PASSWORD_HASHERS`::
+
+            PASSWORD_HASHERS = (
+                'myproject.hashers.MyPBKDF2PasswordHasher',
+                'django.contrib.auth.hashers.PBKDF2PasswordHasher',
+                'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
+                'django.contrib.auth.hashers.BCryptPasswordHasher',
+                'django.contrib.auth.hashers.SHA1PasswordHasher',
+                'django.contrib.auth.hashers.MD5PasswordHasher',
+                'django.contrib.auth.hashers.CryptPasswordHasher',
+            )
+
+
+That's it -- now your Django install will use more iterations when it
+stores passwords using PBKDF2.
+
+Password upgrading
+~~~~~~~~~~~~~~~~~~
+
+When users log in, if their passwords are stored with anything other than
+the preferred algorithm, Django will automatically upgrade the algorithm
+to the preferred one. This means that old installs of Django will get
+automatically more secure as users log in, and it also means that you
+can switch to new (and better) storage algorithms as they get invented.
+
+However, Django can only upgrade passwords that use algorithms mentioned in
+:setting:`PASSWORD_HASHERS`, so as you upgrade to new systems you should make
+sure never to *remove* entries from this list. If you do, users using un-
+mentioned algorithms won't be able to upgrade.
+
+.. _sha1: http://en.wikipedia.org/wiki/SHA1
+.. _pbkdf2: http://en.wikipedia.org/wiki/PBKDF2
+.. _nist: http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf
+.. _bcrypt: http://en.wikipedia.org/wiki/Bcrypt
+.. _py-bcrypt: http://pypi.python.org/pypi/py-bcrypt/
 
 Anonymous users
 ---------------

File tests/regressiontests/utils/tests.py

 """
 Tests for django.utils.
 """
-
 from __future__ import absolute_import
 
 from .dateformat import DateFormatTests
 from .jslex import JsTokensTest, JsToCForGettextTest
 from .ipv6 import TestUtilsIPv6
 from .timezone import TimezoneTests
+from .crypto import TestUtilsCryptoPBKDF2
 from .archive import TestZip, TestTar, TestGzipTar, TestBzip2Tar