diff options
Diffstat (limited to 'passlib')
-rw-r--r-- | passlib/apps.py | 64 | ||||
-rw-r--r-- | passlib/ext/django/utils.py | 70 | ||||
-rw-r--r-- | passlib/tests/test_ext_django.py | 141 |
3 files changed, 214 insertions, 61 deletions
diff --git a/passlib/apps.py b/passlib/apps.py index 30cd6af..682bbff 100644 --- a/passlib/apps.py +++ b/passlib/apps.py @@ -87,9 +87,17 @@ custom_app_context = LazyCryptContext( #============================================================================= # django #============================================================================= + +#----------------------------------------------------------------------- +# 1.0 +#----------------------------------------------------------------------- + _django10_schemes = [ - "django_salted_sha1", "django_salted_md5", "django_des_crypt", - "hex_md5", "django_disabled", + "django_salted_sha1", + "django_salted_md5", + "django_des_crypt", + "hex_md5", + "django_disabled", ] django10_context = LazyCryptContext( @@ -98,28 +106,60 @@ django10_context = LazyCryptContext( deprecated=["hex_md5"], ) -_django14_schemes = ["django_pbkdf2_sha256", "django_pbkdf2_sha1", - "django_bcrypt"] + _django10_schemes +#----------------------------------------------------------------------- +# 1.4 +#----------------------------------------------------------------------- + +_django14_schemes = [ + "django_pbkdf2_sha256", + "django_pbkdf2_sha1", + "django_bcrypt" +] + _django10_schemes + django14_context = LazyCryptContext( schemes=_django14_schemes, deprecated=_django10_schemes, ) -_django16_schemes = _django14_schemes[:] +#----------------------------------------------------------------------- +# 1.6 +#----------------------------------------------------------------------- + +_django16_schemes = list(_django14_schemes) _django16_schemes.insert(1, "django_bcrypt_sha256") django16_context = LazyCryptContext( schemes=_django16_schemes, deprecated=_django10_schemes, ) -django110_context = LazyCryptContext( - schemes=["django_pbkdf2_sha256", "django_pbkdf2_sha1", - "django_argon2", "django_bcrypt", "django_bcrypt_sha256", - "django_disabled"], -) +#----------------------------------------------------------------------- +# 1.10 +#----------------------------------------------------------------------- + +_django_110_schemes = [ + "django_pbkdf2_sha256", + "django_pbkdf2_sha1", + "django_argon2", + "django_bcrypt", + "django_bcrypt_sha256", + "django_disabled", +] +django110_context = LazyCryptContext(schemes=_django_110_schemes) + +#----------------------------------------------------------------------- +# 2.1 +#----------------------------------------------------------------------- + +_django21_schemes = list(_django_110_schemes) +_django21_schemes.remove("django_bcrypt") +django21_context = LazyCryptContext(schemes=_django21_schemes) + +#----------------------------------------------------------------------- +# latest +#----------------------------------------------------------------------- -# this will always point to latest version -django_context = django110_context +# this will always point to latest version in passlib +django_context = django21_context #============================================================================= # ldap diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index 1d1bd67..2f8a2ef 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -26,13 +26,29 @@ __all__ = [ "DJANGO_VERSION", "MIN_DJANGO_VERSION", "get_preset_config", - "get_django_hasher", + "quirks", ] #: minimum version supported by passlib.ext.django MIN_DJANGO_VERSION = (1, 8) #============================================================================= +# quirk detection +#============================================================================= + +class quirks: + + #: django check_password() started throwing error on encoded=None + #: (really identify_hasher did) + none_causes_check_password_error = DJANGO_VERSION >= (2, 1) + + #: django is_usable_password() started returning True for password = {None, ""} values. + empty_is_usable_password = DJANGO_VERSION >= (2, 1) + + #: django is_usable_password() started returning True for non-hash strings in 2.1 + invalid_is_usable_password = DJANGO_VERSION >= (2, 1) + +#============================================================================= # default policies #============================================================================= @@ -235,6 +251,13 @@ class DjangoTranslator(object): md5="MD5PasswordHasher", ) + if DJANGO_VERSION > (2, 1): + # present but disabled by default as of django 2.1; not sure when added, + # so not listing it by default. + _builtin_django_hashers.update( + bcrypt="BCryptPasswordHasher", + ) + def _create_django_hasher(self, django_name): """ helper to create new django hasher by name. @@ -244,17 +267,22 @@ class DjangoTranslator(object): module = sys.modules.get("passlib.ext.django.models") if module is None or not module.adapter.patched: from django.contrib.auth.hashers import get_hasher - return get_hasher(django_name) - - # We've patched django's get_hashers(), so calling django's get_hasher() - # or get_hashers_by_algorithm() would only land us back here. - # As non-ideal workaround, have to use original get_hashers(), - get_hashers = module.adapter._manager.getorig("django.contrib.auth.hashers:get_hashers").__wrapped__ - for hasher in get_hashers(): - if hasher.algorithm == django_name: - return hasher - - # hardcode a few for cases where get_hashers() look won't work. + try: + return get_hasher(django_name) + except ValueError as err: + if not str(err).startswith("Unknown password hashing algorithm"): + raise + else: + # We've patched django's get_hashers(), so calling django's get_hasher() + # or get_hashers_by_algorithm() would only land us back here. + # As non-ideal workaround, have to use original get_hashers(), + get_hashers = module.adapter._manager.getorig("django.contrib.auth.hashers:get_hashers").__wrapped__ + for hasher in get_hashers(): + if hasher.algorithm == django_name: + return hasher + + # hardcode a few for cases where get_hashers() lookup won't work + # (mainly, hashers that are present in django, but disabled by their default config) path = self._builtin_django_hashers.get(django_name) if path: if "." not in path: @@ -545,12 +573,20 @@ class DjangoContextAdapter(DjangoTranslator): """ # XXX: this currently ignores "preferred" keyword, since its purpose # was for hash migration, and that's handled by the context. + # XXX: honor "none_causes_check_password_error" quirk for django 2.2+? + # seems safer to return False. if password is None or not self.is_password_usable(encoded): return False # verify password context = self.context - correct = context.verify(password, encoded) + try: + correct = context.verify(password, encoded) + except exc.UnknownHashError: + # As of django 1.5, unidentifiable hashes returns False + # (side-effect of django issue 18453) + return False + if not (correct and setter): return correct @@ -591,8 +627,12 @@ class DjangoContextAdapter(DjangoTranslator): if not self.is_password_usable(hash): return False cat = self.get_user_category(user) - ok, new_hash = self.context.verify_and_update(password, hash, - category=cat) + try: + ok, new_hash = self.context.verify_and_update(password, hash, category=cat) + except exc.UnknownHashError: + # As of django 1.5, unidentifiable hashes returns False + # (side-effect of django issue 18453) + return False if ok and new_hash is not None: # migrate to new hash if needed. user.password = new_hash diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index f827a36..8464fa0 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -13,7 +13,7 @@ from passlib import apps as _apps, exc, registry from passlib.apps import django10_context, django14_context, django16_context from passlib.context import CryptContext from passlib.ext.django.utils import ( - DJANGO_VERSION, MIN_DJANGO_VERSION, DjangoTranslator, + DJANGO_VERSION, MIN_DJANGO_VERSION, DjangoTranslator, quirks, ) from passlib.utils.compat import iteritems, get_method_function, u from passlib.utils.decor import memoized_property @@ -60,6 +60,11 @@ if has_min_django: from django.apps import apps apps.populate(["django.contrib.contenttypes", "django.contrib.auth"]) +# log a warning if tested w/ newer version. +# NOTE: this is mainly here as place to mark what version it was run against before release. +if DJANGO_VERSION >= (3, 2): + log.info("this release hasn't been tested against Django %r", DJANGO_VERSION) + #============================================================================= # support funcs #============================================================================= @@ -133,28 +138,58 @@ def check_django_hasher_has_backend(name): # work up stock django config #============================================================================= +def _modify_django_config(kwds, sha_rounds=None): + """ + helper to build django CryptContext config matching expected setup for stock django deploy. + :param kwds: + :param sha_rounds: + :return: + """ + # make sure we have dict + if hasattr(kwds, "to_dict"): + # type: CryptContext + kwds = kwds.to_dict() + + # update defaults + kwds.update( + # TODO: push this to passlib.apps django contexts + deprecated="auto", + ) + + # fill in default rounds for current django version, so our sample hashes come back + # unchanged, instead of being upgraded in-place by check_password(). + if sha_rounds is None and has_min_django: + from django.contrib.auth.hashers import PBKDF2PasswordHasher + sha_rounds = PBKDF2PasswordHasher.iterations + + # modify rounds + if sha_rounds: + kwds.update( + django_pbkdf2_sha1__default_rounds=sha_rounds, + django_pbkdf2_sha256__default_rounds=sha_rounds, + ) + + return kwds + +#---------------------------------------------------- # build config dict that matches stock django -# XXX: move these to passlib.apps? -if DJANGO_VERSION >= (1, 11): - stock_config = _apps.django110_context.to_dict() - stock_rounds = 36000 +#---------------------------------------------------- + +# XXX: replace this with code that interrogates default django config directly? +# could then separate out "validation of djangoXX_context objects" +# and "validation that individual hashers match django". +# or maybe add a "get_django_context(django_version)" helper to passlib.apps? +if DJANGO_VERSION >= (2, 1): + stock_config = _modify_django_config(_apps.django21_context) elif DJANGO_VERSION >= (1, 10): - stock_config = _apps.django110_context.to_dict() - stock_rounds = 30000 -elif DJANGO_VERSION >= (1, 9): - stock_config = _apps.django16_context.to_dict() - stock_rounds = 24000 -else: # 1.8 - stock_config = _apps.django16_context.to_dict() - stock_rounds = 20000 - -stock_config.update( - deprecated="auto", - django_pbkdf2_sha1__default_rounds=stock_rounds, - django_pbkdf2_sha256__default_rounds=stock_rounds, -) + stock_config = _modify_django_config(_apps.django110_context) +else: + # assert DJANGO_VERSION >= (1, 8) + stock_config = _modify_django_config(_apps.django16_context) +#---------------------------------------------------- # override sample hashes used in test cases +#---------------------------------------------------- from passlib.hash import django_pbkdf2_sha256 sample_hashes = dict( django_pbkdf2_sha256=("not a password", django_pbkdf2_sha256 @@ -459,34 +494,65 @@ class DjangoBehaviorTest(_ExtensionTest): # User.set_password() - n/a # User.check_password() - returns False - # FIXME: at some point past 1.8, some of these django started handler None differently; - # and/or throwing TypeError. need to investigate when that change occurred; - # update these tests, and maybe passlib.ext.django as well. user = FakeUser() user.password = None - self.assertFalse(user.check_password(PASS1)) - self.assertFalse(user.has_usable_password()) + if quirks.none_causes_check_password_error and not patched: + # django 2.1+ + self.assertRaises(TypeError, user.check_password, PASS1) + else: + self.assertFalse(user.check_password(PASS1)) + + self.assertEqual(user.has_usable_password(), + quirks.empty_is_usable_password) # make_password() - n/a # check_password() - error - self.assertFalse(check_password(PASS1, None)) + if quirks.none_causes_check_password_error and not patched: + self.assertRaises(TypeError, check_password, PASS1, None) + else: + self.assertFalse(check_password(PASS1, None)) # identify_hasher() - error self.assertRaises(TypeError, identify_hasher, None) #======================================================= - # empty & invalid hash values - # NOTE: django 1.5 behavior change due to django ticket 18453 - # NOTE: passlib integration tries to match current django version + # empty hash values + #======================================================= + + # User.set_password() - n/a + + # User.check_password() + # As of django 1.5, blank hash returns False (django issue 18453) + user = FakeUser() + user.password = "" + self.assertFalse(user.check_password(PASS1)) + + # verify hash wasn't changed/upgraded during check_password() call + self.assertEqual(user.password, "") + self.assertEqual(user.pop_saved_passwords(), []) + + # User.has_usable_password() + self.assertEqual(user.has_usable_password(), quirks.empty_is_usable_password) + + # make_password() - n/a + + # check_password() + self.assertFalse(check_password(PASS1, "")) + + # identify_hasher() - throws error + self.assertRaises(ValueError, identify_hasher, "") + + #======================================================= + # invalid hash values #======================================================= - for hash in ("", # empty hash - "$789$foo", # empty identifier - ): + for hash in [ + "$789$foo", # empty identifier + ]: # User.set_password() - n/a # User.check_password() - # As of django 1.5, blank OR invalid hash returns False + # As of django 1.5, invalid hash returns False (side effect of django issue 18453) user = FakeUser() user.password = hash self.assertFalse(user.check_password(PASS1)) @@ -496,7 +562,7 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertEqual(user.pop_saved_passwords(), []) # User.has_usable_password() - self.assertFalse(user.has_usable_password()) + self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password) # make_password() - n/a @@ -701,12 +767,15 @@ class DjangoExtensionTest(_ExtensionTest): passlib_to_django = DjangoTranslator().passlib_to_django # should return native django hasher if available - if DJANGO_VERSION > (1,10): + if DJANGO_VERSION > (1, 10): self.assertRaises(ValueError, passlib_to_django, "hex_md5") else: hasher = passlib_to_django("hex_md5") self.assertIsInstance(hasher, hashers.UnsaltedMD5PasswordHasher) + # should return native django hasher + # NOTE: present but not enabled by default in django as of 2.1 + # (see _builtin_django_hashers) hasher = passlib_to_django("django_bcrypt") self.assertIsInstance(hasher, hashers.BCryptPasswordHasher) @@ -732,6 +801,10 @@ class DjangoExtensionTest(_ExtensionTest): 'hash': u('v2RWkZ*************************************'), }) + # made up name should throw error + # XXX: should this throw ValueError instead, to match django? + self.assertRaises(KeyError, passlib_to_django, "does_not_exist") + #=================================================================== # PASSLIB_CONFIG settings #=================================================================== |