From dce820ff70f00e974afd3e6e310aa825bc55319f Mon Sep 17 00:00:00 2001 From: Paul McMillan Date: Fri, 23 Dec 2011 03:46:06 +0000 Subject: [PATCH] Renovated password hashing. Many thanks to Justine Tunney for help with the initial patch. git-svn-id: http://code.djangoproject.com/svn/django/trunk@17253 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/conf/global_settings.py | 12 ++ django/contrib/auth/forms.py | 40 +++---- django/contrib/auth/models.py | 25 ++-- django/contrib/auth/tests/__init__.py | 8 +- django/contrib/auth/tests/basic.py | 28 ----- django/utils/crypto.py | 96 ++++++++++++++- docs/releases/1.4.txt | 16 +++ docs/topics/auth.txt | 165 +++++++++++++++++++++++--- tests/regressiontests/utils/tests.py | 2 +- 9 files changed, 304 insertions(+), 88 deletions(-) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 4f0f8337eb..806892d3ca 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -498,6 +498,18 @@ LOGIN_REDIRECT_URL = '/accounts/profile/' # 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 # ########### diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index 57ea108eb4..50b2e2b757 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -1,13 +1,14 @@ 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.models import User -from django.contrib.auth.utils import UNUSABLE_PASSWORD from django.contrib.auth import authenticate +from django.contrib.auth.models import User +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 @@ -18,27 +19,26 @@ mask_password = lambda p: "%s%s" % (p[:UNMASKED_DIGITS_TO_SHOW], "*" * max(len(p 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(""" - %(hash_type_label)s: %(hash_type)s - %(masked_label)s: %(masked)s - """ % { - "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 += "%(key)s: %(value)s " % {"key": key, "value": value} + + return mark_safe("%(summary)s" % {"attrs": flatatt(final_attrs), "summary": summary}) class ReadOnlyPasswordHashField(forms.Field): diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 8ac94c1e8a..36ac6d8605 100644 --- a/django/contrib/auth/models.py +++ b/django/contrib/auth/models.py @@ -9,11 +9,10 @@ from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.contrib import auth -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.auth.hashers import ( + check_password, make_password, is_password_usable, UNUSABLE_PASSWORD) +from django.contrib.auth.signals import user_logged_in from django.contrib.contenttypes.models import ContentType def update_last_login(sender, user, **kwargs): @@ -220,27 +219,21 @@ class User(models.Model): 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) diff --git a/django/contrib/auth/tests/__init__.py b/django/contrib/auth/tests/__init__.py index 7cb0dcb733..883e4c944b 100644 --- a/django/contrib/auth/tests/__init__.py +++ b/django/contrib/auth/tests/__init__.py @@ -1,7 +1,7 @@ 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, @@ -11,9 +11,11 @@ from django.contrib.auth.tests.remote_user import (RemoteUserTest, 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' diff --git a/django/contrib/auth/tests/basic.py b/django/contrib/auth/tests/basic.py index 9f94c2a769..512de163d9 100644 --- a/django/contrib/auth/tests/basic.py +++ b/django/contrib/auth/tests/basic.py @@ -1,7 +1,6 @@ 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 @@ -111,30 +110,3 @@ class BasicTestCase(TestCase): 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") diff --git a/django/utils/crypto.py b/django/utils/crypto.py index 95af6808fa..ff6096c6f9 100644 --- a/django/utils/crypto.py +++ b/django/utils/crypto.py @@ -2,10 +2,18 @@ Django's standard crypto functions and utilities. """ -import hashlib import hmac +import struct +import hashlib +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 @@ -27,6 +35,23 @@ def salted_hmac(key_salt, value, secret=None): # 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. @@ -39,3 +64,72 @@ def constant_time_compare(val1, val2): 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] diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 029eb3c354..255bdc6850 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -90,6 +90,22 @@ allows you to fix a very common performance problem in which your code ends up 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 ~~~~~~~~~~~~~ diff --git a/docs/topics/auth.txt b/docs/topics/auth.txt index 63fe70ff83..1cfccfc5e0 100644 --- a/docs/topics/auth.txt +++ b/docs/topics/auth.txt @@ -371,35 +371,162 @@ Don't set the :attr:`~django.contrib.auth.models.User.password` attribute 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 + algorithm$hash -That's hashtype, salt and hash, separated by the dollar-sign character. +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. -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. +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. -For example:: +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: - sha1$a1976$a36cc8cbf81742a8fb52e221aaeab48ed7f58ab4 +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 :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. +The default for :setting:`PASSWORD_HASHERS` is:: -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. + 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 --------------- diff --git a/tests/regressiontests/utils/tests.py b/tests/regressiontests/utils/tests.py index b80b110585..f48a4adfee 100644 --- a/tests/regressiontests/utils/tests.py +++ b/tests/regressiontests/utils/tests.py @@ -1,7 +1,6 @@ """ Tests for django.utils. """ - from __future__ import absolute_import from .dateformat import DateFormatTests @@ -24,4 +23,5 @@ from .baseconv import TestBaseConv from .jslex import JsTokensTest, JsToCForGettextTest from .ipv6 import TestUtilsIPv6 from .timezone import TimezoneTests +from .crypto import TestUtilsCryptoPBKDF2 from .archive import TestZip, TestTar, TestGzipTar, TestBzip2Tar