From bffea42e623aa7229311f9b59144f600a8093815 Mon Sep 17 00:00:00 2001 From: Eli Collins Date: Sun, 22 Dec 2013 15:25:02 -0500 Subject: django compatibility part 2 * added implementation of django 1.6's bcrypt_sha256 hasher, and UTs * added django16 premade context to passlib.apps, made it default django_context * test_ext_django now makes use of django16_context * passlib.ext.django.utils.get_preset_config() now uses django16_context * tox 'django' and 'django-py3' now test bcrypt integration --- docs/lib/passlib.apps.rst | 14 ++++- docs/lib/passlib.hash.django_std.rst | 2 + passlib/apps.py | 14 ++++- passlib/ext/django/utils.py | 42 +++++++++----- passlib/handlers/django.py | 72 +++++++++++++++++++++++- passlib/registry.py | 1 + passlib/tests/test_ext_django.py | 38 ++++++++----- passlib/tests/test_handlers.py | 2 +- passlib/tests/test_handlers_django.py | 103 ++++++++++++++++++++++++++++------ passlib/tests/utils.py | 3 +- tox.ini | 30 ++++++---- 11 files changed, 253 insertions(+), 68 deletions(-) diff --git a/docs/lib/passlib.apps.rst b/docs/lib/passlib.apps.rst index dee0125..c10496f 100644 --- a/docs/lib/passlib.apps.rst +++ b/docs/lib/passlib.apps.rst @@ -79,15 +79,23 @@ supported by the particular Django version. .. versionadded:: 1.6 +.. data:: django16_context + + The object replicates the stock password hashing policy for Django 1.6. + It supports all the Django 1.0-1.6 hashes, and defaults to + :class:`~passlib.hash.django_pbkdf2_sha256`. It treats all + Django 1.0 hashes as deprecated. + + .. versionadded:: 1.6.2 + .. data:: django_context This alias will always point to the latest preconfigured Django context supported by Passlib, and as such should support all historical hashes built into Django. - .. versionchanged:: 1.6 - This previously was an alias for :data:`django10_context`, - and now points to :data:`django14_context`. + .. versionchanged:: 1.6.2 + This now points to :data:`django16_context`. .. _ldap-contexts: diff --git a/docs/lib/passlib.hash.django_std.rst b/docs/lib/passlib.hash.django_std.rst index c92e5d6..d8a8dee 100644 --- a/docs/lib/passlib.hash.django_std.rst +++ b/docs/lib/passlib.hash.django_std.rst @@ -73,6 +73,8 @@ Interface .. versionadded:: 1.6 +.. autoclass:: django_bcrypt_sha256() + Format ------ An example :class:`!django_pbkdf2_sha256` hash (of ``password``) is: diff --git a/passlib/apps.py b/passlib/apps.py index 0afb73a..96308a4 100644 --- a/passlib/apps.py +++ b/passlib/apps.py @@ -99,14 +99,22 @@ django10_context = LazyCryptContext( deprecated=["hex_md5"], ) +_django14_schemes = ["django_pbkdf2_sha256", "django_pbkdf2_sha1", + "django_bcrypt"] + _django10_schemes django14_context = LazyCryptContext( - schemes=["django_pbkdf2_sha256", "django_pbkdf2_sha1", "django_bcrypt"] \ - + _django10_schemes, + schemes=_django14_schemes, + deprecated=_django10_schemes, +) + +_django16_schemes = _django14_schemes[:] +_django16_schemes.insert(1, "django_bcrypt_sha256") +django16_context = LazyCryptContext( + schemes=_django16_schemes, deprecated=_django10_schemes, ) # this will always point to latest version -django_context = django14_context +django_context = django16_context #============================================================================= # ldap diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index ab10b6f..161212b 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -28,6 +28,15 @@ __all__ = [ #============================================================================= # default policies #============================================================================= + +# map preset names -> passlib.app attrs +_preset_map = { + "django-1.0": "django10_context", + "django-1.4": "django14_context", + "django-1.6": "django16_context", + "django-latest": "django_context", +} + def get_preset_config(name): """Returns configuration string for one of the preset strings supported by the ``PASSLIB_CONFIG`` setting. @@ -35,36 +44,41 @@ def get_preset_config(name): * ``"passlib-default"`` - default config used by this release of passlib. * ``"django-default"`` - config matching currently installed django version. - * ``"django-latest"`` - config matching newest django version (currently same as ``"django-1.4"``). + * ``"django-latest"`` - config matching newest django version (currently same as ``"django-1.6"``). * ``"django-1.0"`` - config used by stock Django 1.0 - 1.3 installs - * ``"django-1.4"`` -config used by stock Django 1.4 installs + * ``"django-1.4"`` - config used by stock Django 1.4 installs + * ``"django-1.6"`` - config used by stock Django 1.6 installs """ # TODO: add preset which includes HASHERS + PREFERRED_HASHERS, - # after having imported any custom hashers. "django-current" + # after having imported any custom hashers. e.g. "django-current" if name == "django-default": - if (0,0) < DJANGO_VERSION < (1,4): + if not DJANGO_VERSION: + raise ValueError("can't resolve django-default preset, " + "django not installed") + if DJANGO_VERSION < (1,4): name = "django-1.0" - else: + elif DJANGO_VERSION < (1,6): name = "django-1.4" - if name == "django-1.0": - from passlib.apps import django10_context - return django10_context.to_string() - if name == "django-1.4" or name == "django-latest": - from passlib.apps import django14_context - return django14_context.to_string() + else: + name = "django-1.6" if name == "passlib-default": return PASSLIB_DEFAULT - raise ValueError("unknown preset config name: %r" % name) + try: + attr = _preset_map[name] + except KeyError: + raise ValueError("unknown preset config name: %r" % name) + import passlib.apps + return getattr(passlib.apps, attr).to_string() # default context used by passlib 1.6 PASSLIB_DEFAULT = """ [passlib] ; list of schemes supported by configuration -; currently all django 1.4 hashes, django 1.0 hashes, +; currently all django 1.6, 1.4, and 1.0 hashes, ; and three common modular crypt format hashes. schemes = - django_pbkdf2_sha256, django_pbkdf2_sha1, django_bcrypt, + django_pbkdf2_sha256, django_pbkdf2_sha1, django_bcrypt, django_bcrypt_sha256, django_salted_sha1, django_salted_md5, django_des_crypt, hex_md5, sha512_crypt, bcrypt, phpass diff --git a/passlib/handlers/django.py b/passlib/handlers/django.py index b643a66..83e8860 100644 --- a/passlib/handlers/django.py +++ b/passlib/handlers/django.py @@ -4,12 +4,14 @@ #============================================================================= # core from base64 import b64encode -from hashlib import md5, sha1 +from binascii import hexlify +from hashlib import md5, sha1, sha256 import re import logging; log = logging.getLogger(__name__) from warnings import warn # site # pkg +from passlib.hash import bcrypt from passlib.utils import to_unicode, classproperty from passlib.utils.compat import b, bytes, str_to_uascii, uascii_to_str, unicode, u from passlib.utils.pbkdf2 import pbkdf2 @@ -28,6 +30,8 @@ __all__ = [ #============================================================================= # lazy imports & constants #============================================================================= + +# imported by django_des_crypt._calc_checksum() des_crypt = None def _import_des_crypt(): @@ -162,7 +166,7 @@ class django_salted_md5(DjangoSaltedHash): secret = secret.encode("utf-8") return str_to_uascii(md5(self.salt.encode("ascii") + secret).hexdigest()) -django_bcrypt = uh.PrefixWrapper("django_bcrypt", "bcrypt", +django_bcrypt = uh.PrefixWrapper("django_bcrypt", bcrypt, prefix=u('bcrypt$'), ident=u("bcrypt$"), # NOTE: this docstring is duplicated in the docs, since sphinx # seems to be having trouble reading it via autodata:: @@ -181,6 +185,70 @@ django_bcrypt = uh.PrefixWrapper("django_bcrypt", "bcrypt", """) django_bcrypt.django_name = "bcrypt" +class django_bcrypt_sha256(bcrypt): + """This class implements Django 1.6's Bcrypt+SHA256 hash, and follows the :ref:`password-hash-api`. + + It supports a variable-length salt, and a variable number of rounds. + + While the algorithm and format is somewhat different, + the api and options for this hash are identical to :class:`!bcrypt` itself, + see :doc:`/lib/passlib.hash.bcrypt` for more details. + + .. versionadded:: 1.6.2 + """ + name = "django_bcrypt_sha256" + django_name = "bcrypt_sha256" + _digest = sha256 + + # NOTE: django bcrypt ident locked at "$2a$", so omitting 'ident' support. + setting_kwds = ("salt", "rounds") + + # sample hash: + # bcrypt_sha256$$2a$06$/3OeRpbOf8/l6nPPRdZPp.nRiyYqPobEZGdNRBWihQhiFDh1ws1tu + + # XXX: we can't use .ident attr due to bcrypt code using it. + # working around that via django_prefix + django_prefix = u('bcrypt_sha256$') + + @classmethod + def identify(cls, hash): + hash = uh.to_unicode_for_identify(hash) + if not hash: + return False + return hash.startswith(cls.django_prefix) + + @classmethod + def from_string(cls, hash): + hash = to_unicode(hash, "ascii", "hash") + if not hash.startswith(cls.django_prefix): + raise uh.exc.InvalidHashError(cls) + bhash = hash[len(cls.django_prefix):] + if not bhash.startswith("$2"): + raise uh.exc.MalformedHashError(cls) + return super(django_bcrypt_sha256, cls).from_string(bhash) + + def __init__(self, **kwds): + if 'ident' in kwds and kwds.get("use_defaults"): + raise TypeError("%s does not support the ident keyword" % + self.__class__.__name__) + return super(django_bcrypt_sha256, self).__init__(**kwds) + + def to_string(self): + bhash = super(django_bcrypt_sha256, self).to_string() + return uascii_to_str(self.django_prefix) + bhash + + def _calc_checksum(self, secret): + if isinstance(secret, unicode): + secret = secret.encode("utf-8") + secret = hexlify(self._digest(secret).digest()) + return super(django_bcrypt_sha256, self)._calc_checksum(secret) + + # patch set_backend so it modifies bcrypt class, not this one... + # else it would clobber our _calc_checksum() wrapper above. + @classmethod + def set_backend(cls, *args, **kwds): + return bcrypt.set_backend(*args, **kwds) + class django_pbkdf2_sha256(DjangoVariableHash): """This class implements Django's PBKDF2-HMAC-SHA256 hash, and follows the :ref:`password-hash-api`. diff --git a/passlib/registry.py b/passlib/registry.py index 2616b8c..5a8055c 100644 --- a/passlib/registry.py +++ b/passlib/registry.py @@ -91,6 +91,7 @@ _locations = dict( crypt16 = "passlib.handlers.des_crypt", des_crypt = "passlib.handlers.des_crypt", django_bcrypt = "passlib.handlers.django", + django_bcrypt_sha256 = "passlib.handlers.django", django_pbkdf2_sha256 = "passlib.handlers.django", django_pbkdf2_sha1 = "passlib.handlers.django", django_salted_sha1 = "passlib.handlers.django", diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index d522386..54a6a78 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -9,7 +9,7 @@ import sys import warnings # site # pkg -from passlib.apps import django10_context, django14_context +from passlib.apps import django10_context, django14_context, django16_context from passlib.context import CryptContext import passlib.exc as exc from passlib.utils.compat import iteritems, unicode, get_method_function, u, PY3 @@ -109,22 +109,28 @@ def create_mock_setter(): # work up stock django config #============================================================================= sample_hashes = {} # override sample hashes used in test cases -if has_django14: - # have to modify this a little - - # all but pbkdf2_sha256 will be deprecated here, - # whereas preconfigured passlib policy is more permissive +if DJANGO_VERSION >= (1,6): + stock_config = django16_context.to_dict() + stock_config.update( + deprecated="auto" + ) + sample_hashes.update( + django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$12000$rpUPFQOVetrY$cEcWG4DjjDpLrDyXnduM+XJUz25U63RcM3//xaFnBnw="), + ) +elif DJANGO_VERSION >= (1,4): stock_config = django14_context.to_dict() - stock_config['deprecated'] = ["django_pbkdf2_sha1", "django_bcrypt"] + stock_config['deprecated'] - if DJANGO_VERSION >= (1,6): - sample_hashes.update( - django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$12000$rpUPFQOVetrY$cEcWG4DjjDpLrDyXnduM+XJUz25U63RcM3//xaFnBnw="), - ) -elif has_django1: + stock_config.update( + deprecated="auto", + django_pbkdf2_sha256__default_rounds=10000, + ) +elif DJANGO_VERSION >= (1,0): stock_config = django10_context.to_dict() else: # 0.9.6 config - stock_config = dict(schemes=["django_salted_sha1", "django_salted_md5", "hex_md5"], - deprecated=["hex_md5"]) + stock_config = dict( + schemes=["django_salted_sha1", "django_salted_md5", "hex_md5"], + deprecated=["hex_md5"] + ) #============================================================================= # test utils @@ -618,8 +624,10 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertTrue(user.check_password(secret)) # check if it upgraded the hash + # NOTE: needs_update kept separate in case we need to test rounds. needs_update = deprecated if needs_update: + self.assertNotEqual(user.password, hash) self.assertFalse(handler.identify(user.password)) self.assertTrue(ctx.handler().verify(secret, user.password)) self.assert_valid_password(user, saved=user.password) @@ -798,7 +806,9 @@ class DjangoExtensionTest(_ExtensionTest): "test PASSLIB_CONFIG=''" # test django presets self.load_extension(PASSLIB_CONTEXT="django-default", check=False) - if has_django14: + if DJANGO_VERSION >= (1,6): + ctx = django16_context + elif DJANGO_VERSION >= (1,4): ctx = django14_context else: ctx = django10_context diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 1a772ca..d300a84 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -33,7 +33,7 @@ def get_handler_case(scheme): "return HandlerCase instance for scheme, used by other tests" from passlib.registry import get_crypt_handler handler = get_crypt_handler(scheme) - if hasattr(handler, "backends") and not hasattr(handler, "wrapped"): + if hasattr(handler, "backends") and not hasattr(handler, "wrapped") and handler.name != "django_bcrypt_sha256": backend = handler.get_backend() name = "%s_%s_test" % (scheme, backend) else: diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py index ac5d1f9..00a2f9b 100644 --- a/passlib/tests/test_handlers_django.py +++ b/passlib/tests/test_handlers_django.py @@ -21,23 +21,30 @@ from passlib.tests.test_handlers import UPASS_WAV, UPASS_USD, UPASS_TABLE #============================================================================= # django #============================================================================= + +# standard string django uses +UPASS_LETMEIN = u('l\xe8tmein') + +def vstr(version): + return ".".join(str(e) for e in version) + class _DjangoHelper(object): # NOTE: not testing against Django < 1.0 since it doesn't support # most of these hash formats. - # flag if hash wasn't added until Django 1.4 - requires14 = False + # flag that hash wasn't added until specified version + min_django_version = () def fuzz_verifier_django(self): from passlib.tests.test_ext_django import DJANGO_VERSION - if DJANGO_VERSION < (1,0): - return None - if self.requires14 and DJANGO_VERSION < (1,4): + # check_password() not added until 1.0 + min_django_version = max(self.min_django_version, (1,0)) + if DJANGO_VERSION < min_django_version: return None from django.contrib.auth.models import check_password def verify_django(secret, hash): "django/check_password" - if DJANGO_VERSION >= (1,4) and not secret: + if (1,4) <= DJANGO_VERSION < (1,6) and not secret: return "skip" if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"): hash = hash.replace("$$2y$", "$$2a$") @@ -51,16 +58,16 @@ class _DjangoHelper(object): def test_90_django_reference(self): "run known correct hashes through Django's check_password()" - from passlib.tests.test_ext_django import has_django1, has_django14 - if self.requires14 and not has_django14: - raise self.skipTest("Django >= 1.4 not installed") - if not has_django1: - raise self.skipTest("Django >= 1.0 not installed") + from passlib.tests.test_ext_django import DJANGO_VERSION + # check_password() not added until 1.0 + min_django_version = max(self.min_django_version, (1,0)) + if DJANGO_VERSION < min_django_version: + raise self.skipTest("Django >= %s not installed" % vstr(min_django_version)) from django.contrib.auth.models import check_password assert self.known_correct_hashes for secret, hash in self.iter_known_hashes(): - if has_django14 and not secret: - # django 1.4 rejects empty passwords + if (1,4) <= DJANGO_VERSION < (1,6) and not secret: + # django 1.4-1.5 rejects empty passwords self.assertFalse(check_password(secret, hash), "empty string should not have verified") continue @@ -76,8 +83,10 @@ class _DjangoHelper(object): def test_91_django_generation(self): "test against output of Django's make_password()" from passlib.tests.test_ext_django import DJANGO_VERSION - if DJANGO_VERSION < (1,4): - raise self.skipTest("Django >= 1.4 not installed") + # make_password() not added until 1.4 + min_django_version = max(self.min_django_version, (1,4)) + if DJANGO_VERSION < min_django_version: + raise self.skipTest("Django >= %s not installed" % vstr(min_django_version)) from passlib.utils import tick from django.contrib.auth.hashers import make_password name = self.handler.django_name # set for all the django_* handlers @@ -229,7 +238,7 @@ class django_salted_sha1_test(HandlerCase, _DjangoHelper): class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper): "test django_pbkdf2_sha256" handler = hash.django_pbkdf2_sha256 - requires14 = True + min_django_version = (1,4) known_correct_hashes = [ # @@ -244,7 +253,7 @@ class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper): class django_pbkdf2_sha1_test(HandlerCase, _DjangoHelper): "test django_pbkdf2_sha1" handler = hash.django_pbkdf2_sha1 - requires14 = True + min_django_version = (1,4) known_correct_hashes = [ # @@ -260,7 +269,7 @@ class django_bcrypt_test(HandlerCase, _DjangoHelper): "test django_bcrypt" handler = hash.django_bcrypt secret_size = 72 - requires14 = True + min_django_version = (1,4) known_correct_hashes = [ # @@ -292,6 +301,64 @@ class django_bcrypt_test(HandlerCase, _DjangoHelper): django_bcrypt_test = skipUnless(hash.bcrypt.has_backend(), "no bcrypt backends available")(django_bcrypt_test) +class django_bcrypt_sha256_test(HandlerCase, _DjangoHelper): + "test django_bcrypt_sha256" + handler = hash.django_bcrypt_sha256 + min_django_version = (1,6) + forbidden_characters = None + + known_correct_hashes = [ + # + # custom - generated via django 1.6 hasher + # + ('', + 'bcrypt_sha256$$2a$06$/3OeRpbOf8/l6nPPRdZPp.nRiyYqPobEZGdNRBWihQhiFDh1ws1tu'), + (UPASS_LETMEIN, + 'bcrypt_sha256$$2a$08$NDjSAIcas.EcoxCRiArvT.MkNiPYVhrsrnJsRkLueZOoV1bsQqlmC'), + (UPASS_TABLE, + 'bcrypt_sha256$$2a$06$kCXUnRFQptGg491siDKNTu8RxjBGSjALHRuvhPYNFsa4Ea5d9M48u'), + + # test >72 chars is hashed correctly -- under bcrypt these hash the same. + (repeat_string("abc123",72), + 'bcrypt_sha256$$2a$06$Tg/oYyZTyAf.Nb3qSgN61OySmyXA8FoY4PjGizjE1QSDfuL5MXNni'), + (repeat_string("abc123",72)+"qwr", + 'bcrypt_sha256$$2a$06$Tg/oYyZTyAf.Nb3qSgN61Ocy0BEz1RK6xslSNi8PlaLX2pe7x/KQG'), + (repeat_string("abc123",72)+"xyz", + 'bcrypt_sha256$$2a$06$Tg/oYyZTyAf.Nb3qSgN61OvY2zoRVUa2Pugv2ExVOUT2YmhvxUFUa'), + ] + + known_malformed_hashers = [ + # data in django salt field + 'bcrypt_sha256$xyz$2a$06$/3OeRpbOf8/l6nPPRdZPp.nRiyYqPobEZGdNRBWihQhiFDh1ws1tu', + ] + + def test_30_HasManyIdents(self): + raise self.skipTest("multiple idents not supported") + + def test_30_HasOneIdent(self): + # forbidding ident keyword, django doesn't support configuring this + handler = self.handler + handler(use_defaults=True) + self.assertRaises(TypeError, handler, ident="$2a$", use_defaults=True) + + # NOTE: the following have been cloned from _bcrypt_test() + + def populate_settings(self, kwds): + # speed up test w/ lower rounds + kwds.setdefault("rounds", 4) + super(django_bcrypt_sha256_test, self).populate_settings(kwds) + + def fuzz_setting_rounds(self): + # decrease default rounds for fuzz testing to speed up volume. + return randintgauss(5, 8, 6, 1) + + def fuzz_setting_ident(self): + # omit multi-ident tests, only $2a$ counts for this class + return None + +django_bcrypt_sha256_test = skipUnless(hash.bcrypt.has_backend(), + "no bcrypt backends available")(django_bcrypt_sha256_test) + #============================================================================= # eof #============================================================================= diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 2965689..f4dc811 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -1152,9 +1152,10 @@ class HandlerCase(TestCase): c3 = self.do_genconfig(salt=s1[:-1]) self.assertNotEqual(c3, c1) + # XXX: make this a class-level flag def prepare_salt(self, salt): "prepare generated salt" - if self.handler.name in ["bcrypt", "django_bcrypt"]: + if self.handler.name in ["bcrypt", "django_bcrypt", "django_bcrypt_sha256"]: from passlib.utils import bcrypt64 salt = bcrypt64.repair_unused(salt) return salt diff --git a/tox.ini b/tox.ini index 10e254e..e9b8ea1 100644 --- a/tox.ini +++ b/tox.ini @@ -115,50 +115,50 @@ commands = #=========================================================================== [testenv:django12] deps = - nose - unittest2 django<1.3 + {[testenv]deps} commands = nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} [testenv:django13] deps = - nose - unittest2 django<1.4 + {[testenv]deps} commands = nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} [testenv:django14] deps = - nose - unittest2 django<1.5 + bcrypt + {[testenv]deps} commands = nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} [testenv:django15] deps = - nose - unittest2 django<1.6 + bcrypt + {[testenv]deps} commands = nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} [testenv:django] +# NOTE: including bcrypt so django bcrypt hasher is included deps = - nose - unittest2 django + bcrypt + {[testenv]deps} commands = nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} [testenv:django-py3] +# NOTE: including bcrypt so django bcrypt hasher is included basepython = python3 deps = - nose - unittest2py3k django + bcrypt + {[testenv:py32]deps} commands = nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} @@ -168,10 +168,16 @@ commands = [testenv:pypy] # pypy (as of v1.6 - v2.2) targets Python 2.7 basepython = pypy +deps = + bcrypt + {[testenv]deps} [testenv:pypy3] # pypy3 (as of v2.1b1) targets Python 3.2 basepython = pypy3 +deps = + bcrypt + {[testenv:py32]deps} #=========================================================================== # Jython - no special directives, currently same as py25 -- cgit v1.2.1