summaryrefslogtreecommitdiff
path: root/passlib
diff options
context:
space:
mode:
Diffstat (limited to 'passlib')
-rw-r--r--passlib/apps.py64
-rw-r--r--passlib/ext/django/utils.py70
-rw-r--r--passlib/tests/test_ext_django.py141
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
#===================================================================