├── test ├── django13 │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_sha2.py │ │ └── test_bcrypt.py │ └── settings.py └── django14 │ ├── __init__.py │ ├── tests │ ├── __init__.py │ └── test_bcrypt.py │ └── settings.py ├── django_sha2 ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── strengthen_user_passwords.py ├── models.py ├── __init__.py ├── bcrypt_auth.py ├── auth.py └── hashers.py ├── MANIFEST.in ├── requirements.txt ├── .gitignore ├── tox.ini ├── LICENSE ├── setup.py └── README.md /test/django13/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/django14/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/django13/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/django14/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_sha2/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_sha2/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | py-bcrypt==0.2 2 | 3 | ## Tests 4 | tox 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.sw[po] 3 | pip-log.txt 4 | .DS_Store 5 | *.egg-info 6 | .tox 7 | test.db 8 | -------------------------------------------------------------------------------- /django_sha2/models.py: -------------------------------------------------------------------------------- 1 | """Make sure django.contrib.auth monkeypatching happens on load.""" 2 | from django.conf import settings 3 | 4 | # If we don't have password hashers, we need to monkey patch the auth module. 5 | if not hasattr(settings, 'PASSWORD_HASHERS'): 6 | from django_sha2 import auth 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27-13, py27-14 3 | 4 | [testenv:py27-13] 5 | commands = python setup.py test13 6 | basepython = python2.7 7 | deps = 8 | django==1.3 9 | django_nose 10 | py-bcrypt==0.2 11 | mock 12 | 13 | [testenv:py27-14] 14 | commands = python setup.py test14 15 | basepython = python2.7 16 | deps = 17 | django==1.4 18 | django_nose 19 | py-bcrypt==0.2 20 | mock 21 | -------------------------------------------------------------------------------- /test/django13/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | ## Generic settings 5 | TEST_RUNNER = 'django_nose.runner.NoseTestSuiteRunner' 6 | 7 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 8 | path = lambda *a: os.path.join(PROJECT_ROOT, *a) 9 | 10 | sys.path.insert(0, path('..', '..')) 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'NAME': 'test.db', 15 | 'ENGINE': 'django.db.backends.sqlite3', 16 | } 17 | } 18 | 19 | INSTALLED_APPS = ( 20 | 'django.contrib.auth', 21 | 'django_sha2', 22 | 'django.contrib.contenttypes', 23 | 'django_nose', 24 | ) 25 | 26 | ## django-sha2 settings 27 | PWD_ALGORITHM = 'bcrypt' 28 | HMAC_KEYS = { 29 | '2010-06-01': 'OldSharedKey', 30 | '2011-01-01': 'ThisisASharedKey', # This is the most recent key 31 | '2011-00-00': 'ThisKeyIsOldToo', 32 | '2010-01-01': 'EvenOlderSharedKey' 33 | } 34 | -------------------------------------------------------------------------------- /django_sha2/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 4) 2 | __version__ = '.'.join(map(str, VERSION)) 3 | 4 | def get_dynamic_hasher_names(HMAC_KEYS): 5 | """ 6 | Return base dynamic hasher names for each entry in HMAC_KEYS (we need to 7 | create one hasher class for each key). Names are sorted to make sure 8 | the HMAC_KEYS are tested in the correct order and the first one is always 9 | the first hasher name returned. 10 | """ 11 | algo_name = lambda hmac_id: 'bcrypt{0}'.format(hmac_id.replace('-', '_')) 12 | return [algo_name(key) for key in sorted(HMAC_KEYS.keys(), reverse=True)] 13 | 14 | def get_password_hashers(BASE_PASSWORD_HASHERS, HMAC_KEYS): 15 | """ 16 | Return the names of the dynamic and regular hashers 17 | created in our hashers file. 18 | """ 19 | # Where is the bcrypt hashers file located? 20 | hashers_base = 'django_sha2.hashers.{0}' 21 | 22 | dynamic_hasher_names = get_dynamic_hasher_names(HMAC_KEYS) 23 | dynamic_hashers = [hashers_base.format(k) for k in dynamic_hasher_names] 24 | 25 | return dynamic_hashers + list(BASE_PASSWORD_HASHERS) 26 | -------------------------------------------------------------------------------- /django_sha2/management/commands/strengthen_user_passwords.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import User 3 | from django.core.management.base import NoArgsCommand 4 | 5 | from django_sha2 import bcrypt_auth 6 | 7 | 8 | class Command(NoArgsCommand): 9 | 10 | requires_model_validation = False 11 | output_transaction = True 12 | 13 | def handle_noargs(self, **options): 14 | 15 | if not settings.PWD_ALGORITHM == 'bcrypt': 16 | return 17 | 18 | for user in User.objects.all(): 19 | pwd = user.password 20 | if pwd.startswith('hh$') or pwd.startswith('bcrypt$'): 21 | continue # Password has already been strengthened. 22 | 23 | try: 24 | alg, salt, hash = pwd.split('$') 25 | except ValueError: 26 | continue # Probably not a password we understand. 27 | 28 | bc_value = bcrypt_auth.create_hash(pwd) 29 | # 'hh' stands for 'hardened hash'. 30 | new_password = '$'.join(['hh', alg, salt, bc_value]) 31 | user.password = new_password 32 | user.save() 33 | -------------------------------------------------------------------------------- /test/django14/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | ## Generic settings 5 | TEST_RUNNER = 'django_nose.runner.NoseTestSuiteRunner' 6 | 7 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 8 | path = lambda *a: os.path.join(PROJECT_ROOT, *a) 9 | 10 | sys.path.insert(0, path('..', '..')) 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'NAME': 'test.db', 15 | 'ENGINE': 'django.db.backends.sqlite3', 16 | } 17 | } 18 | 19 | INSTALLED_APPS = ( 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django_nose', 23 | ) 24 | 25 | ## django-sha2 settings 26 | HMAC_KEYS = { 27 | '2010-06-01': 'OldSharedKey', 28 | '2011-01-01': 'ThisisASharedKey', # This is the most recent key 29 | '2011-00-00': 'ThisKeyIsOldToo', 30 | '2010-01-01': 'EvenOlderSharedKey' 31 | } 32 | 33 | BASE_PASSWORD_HASHERS = ( 34 | 'django_sha2.hashers.BcryptHMACCombinedPasswordVerifier', 35 | 'django_sha2.hashers.SHA512PasswordHasher', 36 | 'django_sha2.hashers.SHA256PasswordHasher', 37 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 38 | 'django.contrib.auth.hashers.MD5PasswordHasher', 39 | 'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', 40 | ) 41 | 42 | from django_sha2 import get_password_hashers 43 | PASSWORD_HASHERS = get_password_hashers(BASE_PASSWORD_HASHERS, HMAC_KEYS) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Frederic Wenzel . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Mozilla nor the names of its contributors may 15 | be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup, Command 4 | 5 | import django_sha2 6 | 7 | 8 | class RunTests(Command): 9 | user_options = [] 10 | 11 | def run(self): 12 | os.chdir(self.testproj_dir) 13 | sys.path.append(self.testproj_dir) 14 | os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' 15 | settings_file = os.environ['DJANGO_SETTINGS_MODULE'] 16 | settings_mod = __import__(settings_file, {}, {}, ['']) 17 | from django.core.management import execute_manager 18 | execute_manager(settings_mod, argv=[__file__, "test"]) 19 | 20 | def initialize_options(self): 21 | pass 22 | 23 | def finalize_options(self): 24 | pass 25 | 26 | 27 | class RunTests_django13(RunTests): 28 | description = "Run the test suit for the django 1.3 tests." 29 | testproj_dir = os.path.join(os.getcwd(), 'test/django13') 30 | 31 | 32 | class RunTests_django14(RunTests): 33 | description = "Run the test suit for the django 1.4 tests." 34 | testproj_dir = os.path.join(os.getcwd(), 'test/django14') 35 | 36 | 37 | setup( 38 | name='django-sha2', 39 | version=django_sha2.__version__, 40 | description='Enable strong password hashes (bcrypt+hmac or SHA-2) in Django by default.', 41 | long_description=open('README.md').read(), 42 | author='Fred Wenzel', 43 | author_email='fwenzel@mozilla.com', 44 | url='http://github.com/fwenzel/django-sha2', 45 | license='BSD', 46 | packages=['django_sha2'], 47 | include_package_data=True, 48 | zip_safe=False, 49 | install_requires=['Django>=1.2'], 50 | cmdclass=dict(test13=RunTests_django13, test14=RunTests_django14), 51 | classifiers=[ 52 | 'Development Status :: 4 - Beta', 53 | 'Environment :: Web Environment', 54 | 'Environment :: Web Environment :: Mozilla', 55 | 'Framework :: Django', 56 | 'Intended Audience :: Developers', 57 | 'License :: OSI Approved :: BSD License', 58 | 'Operating System :: OS Independent', 59 | 'Programming Language :: Python', 60 | 'Topic :: Software Development :: Libraries :: Python Modules', 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /test/django13/tests/test_sha2.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from django import test 3 | 4 | from nose.tools import eq_ 5 | 6 | 7 | class Sha2Tests(test.TestCase): 8 | """Tests for sha256 and sha512.""" 9 | SALT = '1234567890' 10 | HASHES = { 11 | 'sha256': { 12 | '123456': ('7a51d064a1a216a692f753fcdab276e4ff201a01d8b66f56d50d' 13 | '4d719fd0dc87'), 14 | 'abc': ('343c791deda10905e9c03bccaeb75413c9ee960af7b1f2291f4acc9' 15 | '925e2065a'), 16 | u'abcéäêëôøà': ('c69c2fba36f26b3fcb39a0ed1fec005271c93725' 17 | 'bcac10521333259179cc2a7f'), 18 | }, 19 | 'sha512': { 20 | '123456': ('1f52ed515871c913164398ec24c47088cdf957e81af28c899a8a' 21 | '0195d3620e083968a6d4d86cb8f9bd7f909b23f75a1c044ec8e6' 22 | '75c6efbcb0e4bf0eb445525d'), 23 | 'abc': ('a559db3d96b76dee0c3cdaa9e9ee1f87bbc6c9c521636fd840e96fe' 24 | '78959d4e8ebf99a13eab3fd2df4ec76aac733cc5e2e5a7f641e2b41' 25 | '98b4a7e634f11b48f3'), 26 | u'abcéäêëôøà': ('016e02ae147cd23abfb94f3c97cb90e4e68aabd4c36a950' 27 | 'aed76fd74bdea966d7b57fd57979b8ae55ae8c6a2c25250' 28 | '02ae243127f9dc57a672caf0dfe508c74d'), 29 | }, 30 | 'sha512b64': { 31 | '123456': ("H1LtUVhxyRMWQ5jsJMRwiM35V+ga8oyJmooBldNiDgg5aKbU2Gy4" 32 | "+b1/kJsj91ocBE7I5nXG77yw\n5L8OtEVSXQ==\n"), 33 | 'abc': ("pVnbPZa3be4MPNqp6e4fh7vGycUhY2/YQOlv54lZ1Ojr+ZoT6rP9LfT" 34 | "sdqrHM8xeLlp/ZB4rQZi0\np+Y08RtI8w==\n"), 35 | u'abcéäêëôøà': ("AW4CrhR80jq/uU88l8uQ5OaKq9TDapUK7Xb9dL3qlm17V/1" 36 | "Xl5uK5VroxqLCUlACriQxJ/ncV6Zy\nyvDf5QjHTQ==\n"), 37 | } 38 | } 39 | 40 | def test_hexdigest(self): 41 | """Test various password hashes.""" 42 | 43 | # The following import need to stay inside the function to make sure 44 | # monkeypatching has happened. If moved to the top the test would fail 45 | # because the function has been imported too early before monkeypatch. 46 | from django.contrib.auth.models import get_hexdigest 47 | 48 | for algo, pws in self.HASHES.items(): 49 | for pw, hashed in pws.items(): 50 | eq_(get_hexdigest(algo, self.SALT, pw), hashed) 51 | -------------------------------------------------------------------------------- /django_sha2/bcrypt_auth.py: -------------------------------------------------------------------------------- 1 | """bcrypt and hmac implementation for Django.""" 2 | import base64 3 | import hashlib 4 | import logging 5 | 6 | import bcrypt 7 | import hmac 8 | 9 | from django.conf import settings 10 | from django.contrib.auth.models import get_hexdigest 11 | from django.utils.encoding import smart_str 12 | 13 | 14 | log = logging.getLogger('django_sha2') 15 | 16 | 17 | def create_hash(userpwd): 18 | """Given a password, create a key to be stored in the DB.""" 19 | if not settings.HMAC_KEYS: 20 | raise ImportError('settings.HMAC_KEYS must not be empty. Read the ' 21 | 'django_sha2 docs!') 22 | latest_key_id = max(settings.HMAC_KEYS.keys()) 23 | shared_key = settings.HMAC_KEYS[latest_key_id] 24 | 25 | return ''.join(( 26 | 'bcrypt', _bcrypt_create(_hmac_create(userpwd, shared_key)), 27 | '$', latest_key_id)) 28 | 29 | 30 | def check_password(user, raw_password): 31 | """Given a DB entry and a raw password, check its validity.""" 32 | 33 | # Check if the user's password is a "hardened hash". 34 | if user.password.startswith('hh$'): 35 | alg, salt, bc_pwd = user.password.split('$', 3)[1:] 36 | hash = get_hexdigest(alg, salt, raw_password) 37 | algo_and_hash, key_ver = bc_pwd.rsplit('$', 1) 38 | try: 39 | shared_key = settings.HMAC_KEYS[key_ver] 40 | except KeyError: 41 | log.info('Invalid shared key version "{0}"'.format(key_ver)) 42 | return False 43 | bc_value = algo_and_hash[6:] 44 | hmac_value = _hmac_create('$'.join([alg, salt, hash]), shared_key) 45 | 46 | if _bcrypt_verify(hmac_value, bc_value): 47 | # Password is a match, convert to bcrypt format. 48 | user.set_password(raw_password) 49 | user.save() 50 | return True 51 | 52 | return False 53 | 54 | # Normal bcrypt password checking. 55 | algo_and_hash, key_ver = user.password.rsplit('$', 1) 56 | try: 57 | shared_key = settings.HMAC_KEYS[key_ver] 58 | except KeyError: 59 | log.info('Invalid shared key version "{0}"'.format(key_ver)) 60 | return False 61 | bc_value = algo_and_hash[algo_and_hash.find('$'):] # Yes, bcrypt <3s the leading $. 62 | hmac_value = _hmac_create(raw_password, shared_key) 63 | matched = _bcrypt_verify(hmac_value, bc_value) 64 | 65 | # Update password hash if HMAC key has since changed. 66 | if matched and getattr(settings, 'PWD_HMAC_REKEY', True): 67 | latest_key_id = max(settings.HMAC_KEYS.keys()) 68 | if key_ver != latest_key_id: 69 | user.set_password(raw_password) 70 | user.save() 71 | 72 | return matched 73 | 74 | 75 | def _hmac_create(userpwd, shared_key): 76 | """Create HMAC value based on pwd and system-local and per-user salt.""" 77 | hmac_value = base64.b64encode(hmac.new( 78 | smart_str(shared_key), smart_str(userpwd), hashlib.sha512).digest()) 79 | return hmac_value 80 | 81 | 82 | def _bcrypt_create(hmac_value): 83 | """Create bcrypt hash.""" 84 | rounds = getattr(settings, 'BCRYPT_ROUNDS', 12) 85 | # No need for us to create a user salt, bcrypt creates its own. 86 | bcrypt_value = bcrypt.hashpw(hmac_value, bcrypt.gensalt(int(rounds))) 87 | return bcrypt_value 88 | 89 | 90 | def _bcrypt_verify(hmac_value, bcrypt_value): 91 | """Verify an hmac hash against a bcrypt value.""" 92 | return bcrypt.hashpw(hmac_value, bcrypt_value) == bcrypt_value 93 | -------------------------------------------------------------------------------- /django_sha2/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | from future import django_sha2_support 3 | 4 | Monkey-patch SHA-2 support into Django's auth system. If Django ticket #5600 5 | ever gets fixed, this can be removed. 6 | 7 | 8 | """ 9 | import base64 10 | import hashlib 11 | import os 12 | 13 | from django.conf import settings 14 | from django.contrib.auth import models as auth_models 15 | 16 | from django_sha2 import get_dynamic_hasher_names 17 | 18 | 19 | ALGOS = ( 20 | 'bcrypt', 21 | 'sha256', 22 | 'sha512', 23 | 'sha512b64', 24 | ) 25 | 26 | 27 | def monkeypatch(): 28 | """ 29 | Monkeypatch authentication backend if one of our backends was selected. 30 | """ 31 | 32 | algo = getattr(settings, 'PWD_ALGORITHM', 'bcrypt') 33 | if not algo in ALGOS: 34 | return # TODO: log a warning? 35 | 36 | # max_length for SHA512 must be at least 156 characters. NB: The DB needs 37 | # to be fixed separately. 38 | if algo == 'sha512': 39 | pwfield = auth_models.User._meta.get_field('password') 40 | pwfield.max_length = max(pwfield.max_length, 255) # Need at least 156. 41 | 42 | # Do not import bcrypt stuff unless needed 43 | if algo == 'bcrypt': 44 | from django_sha2 import bcrypt_auth 45 | 46 | def set_password(self, raw_password): 47 | """Wrapper to set strongly hashed password for Django.""" 48 | if raw_password is None: 49 | self.set_unusable_password() 50 | return 51 | if algo != 'bcrypt': 52 | salt = os.urandom(10).encode('hex') # Random, 20-digit (hex) salt. 53 | hsh = get_hexdigest(algo, salt, raw_password) 54 | self.password = '$'.join((algo, salt, hsh)) 55 | else: 56 | self.password = bcrypt_auth.create_hash(raw_password) 57 | set_password_old = auth_models.User.set_password 58 | auth_models.User.set_password = set_password 59 | 60 | def check_password(self, raw_password): 61 | """ 62 | Check a raw PW against the DB. 63 | 64 | Checks strong hashes, but falls back to built-in hashes as needed. 65 | Supports automatic upgrading to stronger hashes. 66 | """ 67 | hashed_with = self.password.split('$', 1)[0] 68 | if hashed_with in ['bcrypt', 'hh'] or \ 69 | hashed_with in get_dynamic_hasher_names(settings.HMAC_KEYS): 70 | matched = bcrypt_auth.check_password(self, raw_password) 71 | else: 72 | matched = check_password_old(self, raw_password) 73 | 74 | # Update password hash in DB if out-of-date hash algorithm is used and 75 | # auto-upgrading is enabled. 76 | if (matched and getattr(settings, 'PWD_REHASH', True) and 77 | hashed_with != algo): 78 | self.set_password(raw_password) 79 | self.save() 80 | 81 | return matched 82 | check_password_old = auth_models.User.check_password 83 | auth_models.User.check_password = check_password 84 | 85 | def get_hexdigest(algorithm, salt, raw_password): 86 | """Generate SHA-256 or SHA-512 hash (not used for bcrypt).""" 87 | salt, raw_password = map(lambda s: unicode(s).encode('utf-8'), 88 | (salt, raw_password)) 89 | if algorithm in ('sha256', 'sha512'): 90 | return getattr(hashlib, algorithm)(salt + raw_password).hexdigest() 91 | elif algorithm == 'sha512b64': 92 | return base64.encodestring(hashlib.sha512( 93 | salt + raw_password).digest()) 94 | else: 95 | return get_hexdigest_old(algorithm, salt, raw_password) 96 | get_hexdigest_old = auth_models.get_hexdigest 97 | auth_models.get_hexdigest = get_hexdigest 98 | 99 | monkeypatch() 100 | -------------------------------------------------------------------------------- /test/django14/tests/test_bcrypt.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from django import test 3 | from django.conf import settings 4 | from django.contrib.auth import authenticate 5 | from django.contrib.auth.models import User 6 | 7 | from mock import patch 8 | from nose.tools import eq_ 9 | 10 | 11 | class BcryptTests(test.TestCase): 12 | def setUp(self): 13 | super(BcryptTests, self).setUp() 14 | User.objects.create_user('john', 'johndoe@example.com', 15 | password='123456') 16 | User.objects.create_user('jane', 'janedoe@example.com', 17 | password='abc') 18 | User.objects.create_user('jude', 'jeromedoe@example.com', 19 | password=u'abcéäêëôøà') 20 | 21 | def test_newest_hmac_key_used(self): 22 | """ 23 | Make sure the first hasher (the one used for encoding) has the right 24 | hmac key. 25 | """ 26 | eq_(settings.PASSWORD_HASHERS[0][-10:].replace('_', '-'), 27 | max(settings.HMAC_KEYS.keys())) 28 | 29 | def test_bcrypt_used(self): 30 | """Make sure bcrypt was used as the hash.""" 31 | eq_(User.objects.get(username='john').password[:6], 'bcrypt') 32 | eq_(User.objects.get(username='jane').password[:6], 'bcrypt') 33 | eq_(User.objects.get(username='jude').password[:6], 'bcrypt') 34 | 35 | def test_bcrypt_auth(self): 36 | """Try authenticating.""" 37 | assert authenticate(username='john', password='123456') 38 | assert authenticate(username='jane', password='abc') 39 | assert not authenticate(username='jane', password='123456') 40 | assert authenticate(username='jude', password=u'abcéäêëôøà') 41 | assert not authenticate(username='jude', password=u'çççbbbààà') 42 | 43 | @patch.object(settings._wrapped, 'HMAC_KEYS', dict()) 44 | def test_nokey(self): 45 | """With no HMAC key, no dice.""" 46 | assert not authenticate(username='john', password='123456') 47 | assert not authenticate(username='jane', password='abc') 48 | assert not authenticate(username='jane', password='123456') 49 | assert not authenticate(username='jude', password=u'abcéäêëôøà') 50 | assert not authenticate(username='jude', password=u'çççbbbààà') 51 | 52 | def test_hmac_autoupdate(self): 53 | """Auto-update HMAC key if hash in DB is outdated.""" 54 | # Get an old password hasher to encode John's password with. 55 | from django_sha2.hashers import bcrypt2010_01_01 56 | old_hasher = bcrypt2010_01_01() 57 | 58 | john = User.objects.get(username='john') 59 | john.password = old_hasher.encode('123456', old_hasher.salt()) 60 | john.save() 61 | 62 | # Log in. 63 | assert authenticate(username='john', password='123456') 64 | 65 | # Make sure the DB now has a new password hash. 66 | john = User.objects.get(username='john') 67 | eq_(john.password.rsplit('$', 1)[1], max(settings.HMAC_KEYS.keys())) 68 | 69 | def test_rehash(self): 70 | """Auto-upgrade to stronger hash if needed.""" 71 | # Set a sha256 hash for a user. This one is "123". 72 | john = User.objects.get(username='john') 73 | john.password = ('sha256$7a49025f024ad3dcacad$aaff1abe5377ffeab6ccc68' 74 | '709d94c1950edf11f02d8acb83c75d8fcac1ebeb1') 75 | john.save() 76 | 77 | # The hash should be sha256 now. 78 | john = User.objects.get(username='john') 79 | eq_(john.password.split('$', 1)[0], 'sha256') 80 | 81 | # Log in (should rehash transparently). 82 | assert authenticate(username='john', password='123') 83 | 84 | # Make sure the DB now has a bcrypt hash. 85 | john = User.objects.get(username='john') 86 | eq_(john.password[:6], 'bcrypt') 87 | 88 | # Log in again with the new hash. 89 | assert authenticate(username='john', password='123') 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Strong password hashes for Django 2 | ================================= 3 | 4 | This is a monkey-patch for Django, adding strong password hashing to be used 5 | by default. 6 | 7 | Getting started 8 | --------------- 9 | 10 | Install this app using ``easy_install`` or ``pip``, and enable it by adding 11 | the following to your ``settings.py`` file: 12 | 13 | INSTALLED_APPS = ( 14 | # ... 15 | 'django.contrib.auth', 16 | 'django_sha2', # Load after auth to monkey-patch it. 17 | # ... 18 | ) 19 | PWD_ALGORITHM = 'bcrypt' # one of: bcrypt, sha512, sha512b64, sha256 20 | BCRYPT_ROUNDS = 12 # optional. 12 is the default. Only needed for bcrypt. 21 | 22 | Add something like the following to your ``settings_local.py`` file, and keep 23 | it secret: 24 | 25 | HMAC_KEYS = { 26 | '2011-01-01': 'ThisisASharedKey', 27 | '2010-06-01': 'OldSharedKey', 28 | '2010-01-01': 'EvenOlderSharedKey' 29 | } 30 | 31 | ``HMAC_KEYS`` is a dictionary ``{key-id: shared-secret}``. You only need one 32 | key to start. The dictionary key can be an ISO date, or almost anything else, 33 | but the latest key will be determined by sorting. 34 | 35 | **Note:** If you don't have a ``settings_local.py`` file or similar, make sure 36 | to use ``from settings_local import *`` at the end of ``settings.py`` and add 37 | it to the ignore file for your version control system, so it becomes part of 38 | your Django settings, but is not committed to the repository. 39 | 40 | This change is backwards-compatible (i.e., existing SHA-1 hashes in the 41 | database keep on working), and does not require database changes\*. 42 | 43 | \*: unless you're using SHA-512 (see below). 44 | 45 | 46 | The default: Bcrypt and HMAC 47 | ---------------------------- 48 | 49 | A quick overview over the default hash algorithm: It uses a combination of 50 | Bcrypt and HMAC with SHA-512. [HMAC][hmac] is a hash function that involves 51 | the use of a secret key -- the ``HMAC_KEYS`` you entered above will be used 52 | for the calculation. 53 | 54 | The reason a machine-local secret is involved in the calculation is so that 55 | if an attacker gains access to a database, the data will be useless without 56 | _also_ having gained file-system access to steal the local secret. 57 | 58 | ``HMAC_KEYS`` is a dictionary so that you can change the key periodically 59 | and deprecate old keys, or revoke keys altogether that are too old or you 60 | fear might have leaked. 61 | 62 | Second, the hash is hashed again using [bcrypt][bcrypt], which is 63 | computationally hard and therefore protects better against brute-force offline 64 | attacks. 65 | 66 | [hmac]: http://en.wikipedia.org/wiki/HMAC 67 | [bcrypt]: http://bcrypt.sourceforge.net/ 68 | 69 | 70 | Transparent password rehashing 71 | ------------------------------ 72 | In case you have existing users with weaker password hashes (like SHA-1) in 73 | the database, django\_sha2 will **automatically rehash** their password in the 74 | database with a your currently chosen hash algorithm during their next login. 75 | 76 | This is enabled by default. If you don't like it, set this in your settings 77 | file: 78 | 79 | PWD_REHASH = False 80 | 81 | Similarly, django\_sha2 automatically updates users' password hashes to the 82 | **latest HMAC key** on login, which is usually what you want, so it is enabled 83 | by default. To disable, set this setting: 84 | 85 | PWD_HMAC_REKEY = False 86 | 87 | 88 | A note on SHA-512 89 | ----------------- 90 | Django's default password field is limited to 128 characters, which does not 91 | fit a hex-encoded SHA512 hash. In order to not require a database migration 92 | for every project that uses this, we encode the SHA512 hash in Base 64 as 93 | opposed to hex. To use this, set your hash backend as follows: 94 | 95 | PWD_ALGORITHM = 'sha512b64' 96 | 97 | If you want to use hex-encoded SHA512 instead, use the following: 98 | 99 | PWD_ALGORITHM = 'sha512' 100 | 101 | Be advised, however, that you need to ensure your database's password field can 102 | hold at least 156 characters. 103 | 104 | When starting a new project, it is safe to use the Sha512 backend straight away: 105 | django\_sha2 will create the password field with a ``max_length`` of 255 when 106 | running ``syncdb`` for the first time. 107 | 108 | 109 | History 110 | ------- 111 | This started off as a monkey-patch for SHA-256 in Django and, over SHA-512, 112 | turned into a strong hash library featuring bcrypt and hmac support. 113 | 114 | For the initial idea, read the [blog post][blog] about it. 115 | 116 | [blog]: http://fredericiana.com/2010/10/12/adding-support-for-stronger-password-hashes-to-django/ 117 | 118 | Using django 1.4 119 | ------- 120 | 121 | Django 1.4 allows us to create our own password hashers. Because of some of the 122 | design choices of django's model, we have to generate a hasher class for each 123 | of our HMAC_KEYS. Lucky for you, we have code to help you! Define 124 | BASE_PASSWORD_HASHERS for all hashers you might use to decrypt something in 125 | your database (i.e. if in the past you used SHA256, make sure its in this 126 | setting). Form there, if you follow the code below, all your passwords will 127 | automatically stay up to date with the latest algorthim/hmac_key. 128 | 129 | This is an example settings file snippet: 130 | 131 | ```python 132 | HMAC_KEYS = { 133 | '2010-06-01': 'OldSharedKey', 134 | '2011-01-01': 'ThisisASharedKey', 135 | '2010-01-01': 'EvenOlderSharedKey' 136 | } 137 | 138 | BASE_PASSWORD_HASHERS = ( 139 | 'django_sha2.hashers.BcryptHMACCombinedPasswordVerifier', 140 | 'django_sha2.hashers.SHA512PasswordHasher', 141 | 'django_sha2.hashers.SHA256PasswordHasher', 142 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 143 | 'django.contrib.auth.hashers.MD5PasswordHasher', 144 | 'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', 145 | ) 146 | 147 | from django_sha2 import get_password_hashers 148 | PASSWORD_HASHERS = get_password_hashers(BASE_PASSWORD_HASHERS, HMAC_KEYS) 149 | ``` 150 | -------------------------------------------------------------------------------- /django_sha2/hashers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hmac 3 | import hashlib 4 | import logging 5 | 6 | import bcrypt 7 | 8 | from django.conf import settings 9 | from django.contrib.auth.hashers import (BCryptPasswordHasher, 10 | BasePasswordHasher, mask_hash) 11 | from django.utils.crypto import constant_time_compare 12 | from django.utils.encoding import smart_str 13 | from django.utils.datastructures import SortedDict 14 | 15 | log = logging.getLogger('common.hashers') 16 | 17 | algo_name = lambda hmac_id: 'bcrypt{0}'.format(hmac_id.replace('-', '_')) 18 | 19 | 20 | def get_hasher(hmac_id): 21 | """ 22 | Dynamically create password hashers based on hmac_id. 23 | 24 | This class takes the hmac_id corresponding to an HMAC_KEY and creates a 25 | password hasher class based off of it. This allows us to use djangos 26 | built-in updating mechanisms to automatically update the HMAC KEYS. 27 | """ 28 | dash_hmac_id = hmac_id.replace('_', '-') 29 | 30 | class BcryptHMACPasswordHasher(BCryptPasswordHasher): 31 | algorithm = algo_name(hmac_id) 32 | rounds = getattr(settings, 'BCRYPT_ROUNDS', 12) 33 | 34 | def encode(self, password, salt): 35 | 36 | shared_key = settings.HMAC_KEYS[dash_hmac_id] 37 | 38 | hmac_value = self._hmac_create(password, shared_key) 39 | bcrypt_value = bcrypt.hashpw(hmac_value, salt) 40 | return '{0}{1}${2}'.format( 41 | self.algorithm, 42 | bcrypt_value, 43 | dash_hmac_id) 44 | 45 | def verify(self, password, encoded): 46 | algo_and_hash, key_ver = encoded.rsplit('$', 1) 47 | try: 48 | shared_key = settings.HMAC_KEYS[key_ver] 49 | except KeyError: 50 | log.info('Invalid shared key version "{0}"'.format(key_ver)) 51 | return False 52 | 53 | bc_value = '${0}'.format(algo_and_hash.split('$', 1)[1]) # Yes, bcrypt <3s the leading $. 54 | hmac_value = self._hmac_create(password, shared_key) 55 | return bcrypt.hashpw(hmac_value, bc_value) == bc_value 56 | 57 | def _hmac_create(self, password, shared_key): 58 | """Create HMAC value based on pwd""" 59 | hmac_value = base64.b64encode(hmac.new( 60 | smart_str(shared_key), 61 | smart_str(password), 62 | hashlib.sha512).digest()) 63 | return hmac_value 64 | 65 | return BcryptHMACPasswordHasher 66 | 67 | # We must have HMAC_KEYS. If not, let's raise an import error. 68 | if not settings.HMAC_KEYS: 69 | raise ImportError('settings.HMAC_KEYS must not be empty.') 70 | 71 | # For each HMAC_KEY, dynamically create a hasher to be imported. 72 | for hmac_key in settings.HMAC_KEYS.keys(): 73 | hmac_id = hmac_key.replace('-', '_') 74 | globals()[algo_name(hmac_id)] = get_hasher(hmac_id) 75 | 76 | 77 | class BcryptHMACCombinedPasswordVerifier(BCryptPasswordHasher): 78 | """ 79 | This reads anything with 'bcrypt' as the algo. This should be used 80 | to read bcypt values (with or without HMAC) in order to re-encode them 81 | as something else. 82 | """ 83 | algorithm = 'bcrypt' 84 | rounds = getattr(settings, 'BCRYPT_ROUNDS', 12) 85 | 86 | def encode(self, password, salt): 87 | """This hasher is not meant to be used for encoding""" 88 | raise NotImplementedError() 89 | 90 | def verify(self, password, encoded): 91 | algo_and_hash, key_ver = encoded.rsplit('$', 1) 92 | try: 93 | shared_key = settings.HMAC_KEYS[key_ver] 94 | except KeyError: 95 | log.info('Invalid shared key version "{0}"'.format(key_ver)) 96 | # Fall back to normal bcrypt 97 | algorithm, data = encoded.split('$', 1) 98 | return constant_time_compare(data, bcrypt.hashpw(password, data)) 99 | 100 | bc_value = '${0}'.format(algo_and_hash.split('$', 1)[1]) # Yes, bcrypt <3s the leading $. 101 | hmac_value = self._hmac_create(password, shared_key) 102 | return bcrypt.hashpw(hmac_value, bc_value) == bc_value 103 | 104 | def _hmac_create(self, password, shared_key): 105 | """Create HMAC value based on pwd""" 106 | hmac_value = base64.b64encode(hmac.new( 107 | smart_str(shared_key), 108 | smart_str(password), 109 | hashlib.sha512).digest()) 110 | return hmac_value 111 | 112 | 113 | class SHA256PasswordHasher(BasePasswordHasher): 114 | """The SHA256 password hashing algorithm.""" 115 | algorithm = 'sha256' 116 | 117 | def encode(self, password, salt): 118 | assert password 119 | assert salt and '$' not in salt 120 | hash = getattr(hashlib, self.algorithm)(salt + password).hexdigest() 121 | return '%s$%s$%s' % (self.algorithm, salt, hash) 122 | 123 | def verify(self, password, encoded): 124 | algorithm, salt, hash = encoded.split('$', 2) 125 | assert algorithm == self.algorithm 126 | encoded_2 = self.encode(password, salt) 127 | return constant_time_compare(encoded, encoded_2) 128 | 129 | def safe_summary(self, encoded): 130 | algorithm, salt, hash = encoded.split('$', 2) 131 | assert algorithm == self.algorithm 132 | return SortedDict([ 133 | ('algorithm', algorithm), 134 | ('salt', mask_hash(salt, show=2)), 135 | ('hash', mask_hash(hash)), 136 | ]) 137 | 138 | 139 | class SHA1PasswordHasher(SHA256PasswordHasher): 140 | """The SHA1 password hashing algorithm.""" 141 | algorithm = 'sha1' 142 | 143 | 144 | class SHA512PasswordHasher(SHA256PasswordHasher): 145 | """The SHA512 password hashing algorithm.""" 146 | algorithm = 'sha512' 147 | 148 | 149 | class SHA512b64PasswordHasher(SHA512PasswordHasher): 150 | """The SHA512 password hashing algorithm with base64 encoding.""" 151 | algorithm = 'sha512b64' 152 | 153 | def encode(self, password, salt): 154 | assert password 155 | assert salt and '$' not in salt 156 | hash = base64.encodestring(hashlib.sha512(salt + password).digest()) 157 | return '%s$%s$%s' % (self.algorithm, salt, hash) 158 | -------------------------------------------------------------------------------- /test/django13/tests/test_bcrypt.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from django import test 3 | from django.conf import settings 4 | from django.contrib.auth import authenticate 5 | from django.contrib.auth.models import User 6 | from django.core.management import call_command 7 | 8 | from mock import patch 9 | from nose.tools import eq_ 10 | 11 | 12 | class BcryptTests(test.TestCase): 13 | def setUp(self): 14 | super(BcryptTests, self).setUp() 15 | User.objects.create_user('john', 'johndoe@example.com', 16 | password='123456') 17 | User.objects.create_user('jane', 'janedoe@example.com', 18 | password='abc') 19 | User.objects.create_user('jude', 'jeromedoe@example.com', 20 | password=u'abcéäêëôøà') 21 | 22 | def test_bcrypt_used(self): 23 | """Make sure bcrypt was used as the hash.""" 24 | eq_(User.objects.get(username='john').password[:7], 'bcrypt$') 25 | eq_(User.objects.get(username='jane').password[:7], 'bcrypt$') 26 | eq_(User.objects.get(username='jude').password[:7], 'bcrypt$') 27 | 28 | def test_bcrypt_auth(self): 29 | """Try authenticating.""" 30 | assert authenticate(username='john', password='123456') 31 | assert authenticate(username='jane', password='abc') 32 | assert not authenticate(username='jane', password='123456') 33 | assert authenticate(username='jude', password=u'abcéäêëôøà') 34 | assert not authenticate(username='jude', password=u'çççbbbààà') 35 | 36 | @patch.object(settings._wrapped, 'HMAC_KEYS', dict()) 37 | def test_nokey(self): 38 | """With no HMAC key, no dice.""" 39 | assert not authenticate(username='john', password='123456') 40 | assert not authenticate(username='jane', password='abc') 41 | assert not authenticate(username='jane', password='123456') 42 | assert not authenticate(username='jude', password=u'abcéäêëôøà') 43 | assert not authenticate(username='jude', password=u'çççbbbààà') 44 | 45 | def test_password_from_django14(self): 46 | """Test that a password generated by django_sha2 with django 1.4 is 47 | recognized and changed to a 1.3 version""" 48 | # We can't easily call 1.4's hashers so we hardcode the passwords as 49 | # returned with the specific salts and hmac_key in 1.4. 50 | prefix = 'bcrypt2011_01_01$2a$12$' 51 | suffix = '$2011-01-01' 52 | raw_hashes = { 53 | 'john': '02CfJWdVwLK80jlRe/Xx1u8sTHAR0JUmKV9YB4BS.Os4LK6nsoLie', 54 | 'jane': '.ipDt6gRL3CPkVH7FEyR6.8YXeQFXAMyiX3mXpDh4YDBonrdofrcG', 55 | 'jude': '6Ol.vgIFxMQw0LBhCLtv7OkV.oyJjen2GVMoiNcLnbsljSfYUkQqe', 56 | } 57 | 58 | u = User.objects.get(username="john") 59 | django14_style_password = "%s%s%s" % (prefix, raw_hashes['john'], 60 | suffix) 61 | u.password = django14_style_password 62 | assert u.check_password('123456') 63 | eq_(u.password[:7], 'bcrypt$') 64 | 65 | u = User.objects.get(username="jane") 66 | django14_style_password = "%s%s%s" % (prefix, raw_hashes['jane'], 67 | suffix) 68 | u.password = django14_style_password 69 | assert u.check_password('abc') 70 | eq_(u.password[:7], 'bcrypt$') 71 | 72 | u = User.objects.get(username="jude") 73 | django14_style_password = "%s%s%s" % (prefix, raw_hashes['jude'], 74 | suffix) 75 | u.password = django14_style_password 76 | assert u.check_password(u'abcéäêëôøà') 77 | eq_(u.password[:7], 'bcrypt$') 78 | 79 | def test_hmac_autoupdate(self): 80 | """Auto-update HMAC key if hash in DB is outdated.""" 81 | # Get HMAC key IDs to compare 82 | old_key_id = max(settings.HMAC_KEYS.keys()) 83 | new_key_id = '2020-01-01' 84 | 85 | # Add a new HMAC key 86 | new_keys = settings.HMAC_KEYS.copy() 87 | new_keys[new_key_id] = 'a_new_key' 88 | with patch.object(settings._wrapped, 'HMAC_KEYS', new_keys): 89 | # Make sure the database has the old key ID. 90 | john = User.objects.get(username='john') 91 | eq_(john.password.rsplit('$', 1)[1], old_key_id) 92 | 93 | # Log in. 94 | assert authenticate(username='john', password='123456') 95 | 96 | # Make sure the DB now has a new password hash. 97 | john = User.objects.get(username='john') 98 | eq_(john.password.rsplit('$', 1)[1], new_key_id) 99 | 100 | def test_rehash(self): 101 | """Auto-upgrade to stronger hash if needed.""" 102 | # Set a sha256 hash for a user. This one is "123". 103 | john = User.objects.get(username='john') 104 | john.password = ('sha256$7a49025f024ad3dcacad$aaff1abe5377ffeab6ccc68' 105 | '709d94c1950edf11f02d8acb83c75d8fcac1ebeb1') 106 | john.save() 107 | 108 | # The hash should be sha256 now. 109 | john = User.objects.get(username='john') 110 | eq_(john.password.split('$', 1)[0], 'sha256') 111 | 112 | # Log in (should rehash transparently). 113 | assert authenticate(username='john', password='123') 114 | 115 | # Make sure the DB now has a bcrypt hash. 116 | john = User.objects.get(username='john') 117 | eq_(john.password.split('$', 1)[0], 'bcrypt') 118 | 119 | # Log in again with the new hash. 120 | assert authenticate(username='john', password='123') 121 | 122 | def test_management_command(self): 123 | """Test password update flow via management command, from default 124 | Django hashes, to hardened hashes, to bcrypt on log in.""" 125 | 126 | john = User.objects.get(username='john') 127 | john.password = 'sha1$3356f$9fd40318e1de9ecd3ab3a5fe944ceaf6a2897eef' 128 | john.save() 129 | 130 | # The hash should be sha1 now. 131 | john = User.objects.get(username='john') 132 | eq_(john.password.split('$', 1)[0], 'sha1') 133 | 134 | # Simulate calling management command 135 | call_command('strengthen_user_passwords') 136 | 137 | # The hash should be 'hh' now. 138 | john = User.objects.get(username='john') 139 | eq_(john.password.split('$', 1)[0], 'hh') 140 | 141 | # Logging in will convert the hardened hash to bcrypt. 142 | assert authenticate(username='john', password='123') 143 | 144 | # Make sure the DB now has a bcrypt hash. 145 | john = User.objects.get(username='john') 146 | eq_(john.password.split('$', 1)[0], 'bcrypt') 147 | 148 | # Log in again with the new hash. 149 | assert authenticate(username='john', password='123') 150 | --------------------------------------------------------------------------------