summaryrefslogtreecommitdiff
path: root/passlib/ext
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2016-02-08 17:34:34 -0500
committerEli Collins <elic@assurancetechnologies.com>2016-02-08 17:34:34 -0500
commit1d5a4a690012f1e78f228041c5190caffca295e9 (patch)
tree41a111996aacbba0bbf24e73398fb1a7df91d66e /passlib/ext
parent790a90b9c06716cc8e154e5fd691a1d6e0d3d6fe (diff)
downloadpasslib-1d5a4a690012f1e78f228041c5190caffca295e9.tar.gz
passlib.ext.django: fixes so we're compatible with django 1.9, fixes issue 68
failures were mostly in the unittests, not in passlib proper. however, to get things working, ended up making some internal improvements to django integration. * passlib.ext.django now patches get_hashers() for django 1.8+ * the (still internal) _PasslibHasherWrapper, which wraps a passlib hash in a django-hasher-compatible api, got an overhaul: - 'rounds' and 'iterations' attributes now populated from underlying passlib hasher, so inspecting them gives sane result. - now autohandles 'rounds' vs 'iterations' to match expected django api. - bugfix: correct OrderedDict import removed in django 1.9 - .must_update() now mimics django's semantics, using wrapped handler.parse_rounds(). previously just always returned False. - NOTE: all these changes bring this thing much closer to be able to provide passlib handlers TO django, rather than having to patch entire django framework. * unittests - django 1.7+: populate django apps before test; required as of 1.9 - added rounds settings required for 1.9 test to function - django tests integratio - now keeps context in sync for all hashers, not just pbkdf2_sha256; needed to pass some django 1.9 tests. * django 1.8 is LTS, and <= 1.7 is EOLed, per https://www.djangoproject.com/download/; so now planning to drop django 1.6 / 1.7 support in passlib 1.7.
Diffstat (limited to 'passlib/ext')
-rw-r--r--passlib/ext/django/models.py29
-rw-r--r--passlib/ext/django/utils.py177
2 files changed, 175 insertions, 31 deletions
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index f82e399..4ad5c82 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -166,8 +166,18 @@ def _apply_patch():
if not is_valid_secret(password) or not is_password_usable(encoded):
return False
ok = password_context.verify(password, encoded)
- if ok and setter and password_context.needs_update(encoded):
- setter(password)
+ if ok and setter:
+ # django's check_password() won't call setter() on a legacy alg if it's explicitly
+ # chosen via 'preferred' kwd (unless alg itself says hash needs updating, of course).
+ # as a hack to replicate this behavior, the following makes a temp copy of the
+ # active context, to ensure the preferred scheme isn't deprecated.
+ test_context = password_context
+ if preferred != "default":
+ scheme = hasher_to_passlib_name(preferred)
+ if password_context._is_deprecated_scheme(scheme):
+ test_context = password_context.copy(default=scheme)
+ if test_context.needs_update(encoded):
+ setter(password)
return ok
#
@@ -199,6 +209,21 @@ def _apply_patch():
kwds['salt'] = salt
return password_context.encrypt(password, **kwds)
+ if VERSION >= (1, 8):
+ from django.utils import lru_cache
+ from passlib.utils.compat import lmap
+
+ @_manager.monkeypatch(HASHERS_PATH)
+ @lru_cache.lru_cache()
+ def get_hashers():
+ """passlib replacement for get_hashers()"""
+ return lmap(get_passlib_hasher, password_context.schemes(resolve=True))
+
+ # NOTE: leaving get_hashers_by_algorithm() unpatched, since it just
+ # proxies get_hashers(). but we do want to wipe it's cache...
+ from django.contrib.auth.hashers import reset_hashers
+ reset_hashers(setting="PASSWORD_HASHERS")
+
@_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(FORMS_PATH)
def get_hasher(algorithm="default"):
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 863f11e..719df1a 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -17,8 +17,8 @@ except ImportError:
from passlib.context import CryptContext
from passlib.exc import PasslibRuntimeWarning
from passlib.registry import get_crypt_handler, list_crypt_handlers
-from passlib.utils import classproperty
-from passlib.utils.compat import bytes, get_method_function, iteritems
+from passlib.utils import memoized_property
+from passlib.utils.compat import bytes, get_method_function, iteritems, native_string_types
# local
__all__ = [
"get_preset_config",
@@ -152,18 +152,99 @@ def hasher_to_passlib_name(hasher_name):
#=============================================================================
_GEN_SALT_SIGNAL = "--!!!generate-new-salt!!!--"
-class _HasherWrapper(object):
- """helper for wrapping passlib handlers in Hasher-compatible class."""
+class ProxyProperty(object):
+ """helper that proxies another attribute"""
- # filled in by subclass, drives the other methods.
- passlib_handler = None
- iterations = None
+ def __init__(self, attr):
+ self.attr = attr
+
+ def __get__(self, obj, cls):
+ if obj is None:
+ cls = obj
+ return getattr(obj, self.attr)
+
+ def __set__(self, obj, value):
+ setattr(obj, self.attr, value)
+
+ def __delete__(self, obj):
+ delattr(obj, self.attr)
+
+class _PasslibHasherWrapper(object):
+ """
+ adapter which which wraps a :cls:`passlib.ifc.PasswordHash` class,
+ and provides an interface compatible with the Django hasher API.
+
+ :param passlib_handler:
+ passlib hash handler (e.g. :cls:`passlib.hash.sha256_crypt`.
+ """
+
+ #=====================================================================
+ # instance attrs
+ #=====================================================================
- @classproperty
- def algorithm(cls):
- assert not hasattr(cls.passlib_handler, "django_name")
- return PASSLIB_HASHER_PREFIX + cls.passlib_handler.name
+ #: passlib handler that we're adapting.
+ passlib_handler = None
+ # NOTE: 'rounds' attr will store variable rounds, IF handler supports it.
+ # 'iterations' will act as proxy, for compatibility with django pbkdf2 hashers.
+ # rounds = None
+ # iterations = None
+
+ #=====================================================================
+ # init
+ #=====================================================================
+ def __init__(self, passlib_handler):
+ # init handler
+ assert not hasattr(passlib_handler, "django_name"), \
+ "bug in get_passlib_hasher() -- handlers that reflect an official django hasher " \
+ "should be used directly"
+ self.passlib_handler = passlib_handler
+
+ # init rounds support
+ if self._has_rounds:
+ self.rounds = passlib_handler.default_rounds
+ self.iterations = ProxyProperty("rounds")
+
+ #=====================================================================
+ # internal methods
+ #=====================================================================
+ def __repr__(self):
+ return "<PasslibHasherWrapper handler=%r>" % self.passlib_handler
+
+ #=====================================================================
+ # internal properties
+ #=====================================================================
+
+ @memoized_property
+ def __name__(self):
+ return "Passlib_%s_PasswordHasher" % self.passlib_handler.name.title()
+
+ @memoized_property
+ def _has_rounds(self):
+ return "rounds" in self.passlib_handler.setting_kwds
+
+ @memoized_property
+ def _translate_kwds(self):
+ """
+ internal helper for safe_summary() --
+ used to translate passlib hash options -> django keywords
+ """
+ out = dict(checksum="hash")
+ if self._has_rounds and "pbkdf2" in self.passlib_handler.name:
+ out['rounds'] = 'iterations'
+ return out
+
+ #=====================================================================
+ # hasher properties
+ #=====================================================================
+
+ @memoized_property
+ def algorithm(self):
+ return PASSLIB_HASHER_PREFIX + self.passlib_handler.name
+
+ #=====================================================================
+ # hasher api
+ #=====================================================================
def salt(self):
# NOTE: passlib's handler.encrypt() should generate new salt each time,
# so this just returns a special constant which tells
@@ -173,22 +254,30 @@ class _HasherWrapper(object):
def verify(self, password, encoded):
return self.passlib_handler.verify(password, encoded)
- def encode(self, password, salt=None, iterations=None):
+ def encode(self, password, salt=None, rounds=None, iterations=None):
kwds = {}
if salt is not None and salt != _GEN_SALT_SIGNAL:
kwds['salt'] = salt
- if iterations is not None:
- kwds['rounds'] = iterations
- elif self.iterations is not None:
- kwds['rounds'] = self.iterations
+ if self._has_rounds:
+ if rounds is not None:
+ kwds['rounds'] = rounds
+ elif iterations is not None:
+ kwds['rounds'] = iterations
+ else:
+ kwds['rounds'] = self.rounds
+ elif rounds is not None or iterations is not None:
+ warn("%s.encrypt(): 'rounds' and 'iterations' are ignored" % self.__name__)
return self.passlib_handler.encrypt(password, **kwds)
- _translate_kwds = dict(checksum="hash", rounds="iterations")
-
def safe_summary(self, encoded):
+ # TODO: make these imports global.
from django.contrib.auth.hashers import mask_hash
from django.utils.translation import ugettext_noop as _
- from django.utils.datastructures import SortedDict
+ try:
+ from collections import OrderedDict as SortedDict
+ except ImportError:
+ # this was removed in django 1.9
+ from django.utils.datastructures import SortedDict
handler = self.passlib_handler
items = [
# since this is user-facing, we're reporting passlib's name,
@@ -204,15 +293,32 @@ class _HasherWrapper(object):
# added in django 1.6
def must_update(self, encoded):
- # TODO: would like to do something useful here,
- # but would require access to password context,
- # which would mean a serious recoding of this ext.
+ # TODO: would like access CryptContext, would need caller to pass it to get_passlib_hasher().
+ # for now (as of passlib 1.6.6), replicating django policy that this returns True
+ # if 'encoded' hash has different rounds value from self.rounds
+ if self._has_rounds:
+ handler = self.passlib_handler
+ if hasattr(handler, "parse_rounds"):
+ rounds = handler.parse_rounds(encoded)
+ if rounds != self.rounds:
+ return True
+ # TODO: for passlib 1.7, could check .needs_update() method.
+ # could also have this whole class create a handler subclass,
+ # which we can proxy the .rounds attr for. this would allow
+ # replacing entirety of the (above) rounds check
return False
+ #=====================================================================
+ # eoc
+ #=====================================================================
+
+#: legacy alias for < 1.6.6
+_HasherWrapper = _PasslibHasherWrapper
+
# cache of hasher wrappers generated by get_passlib_hasher()
_hasher_cache = WeakKeyDictionary()
-def get_passlib_hasher(handler, algorithm=None):
+def get_passlib_hasher(handler, algorithm=None, native_only=False):
"""create *Hasher*-compatible wrapper for specified passlib hash.
This takes in the name of a passlib hash (or the handler object itself),
@@ -231,7 +337,7 @@ def get_passlib_hasher(handler, algorithm=None):
"""
if DJANGO_VERSION < (1,4):
raise RuntimeError("get_passlib_hasher() requires Django >= 1.4")
- if isinstance(handler, str):
+ if isinstance(handler, native_string_types):
handler = get_crypt_handler(handler)
if hasattr(handler, "django_name"):
# return native hasher instance
@@ -243,14 +349,15 @@ def get_passlib_hasher(handler, algorithm=None):
# we want to resolve to correct django hasher.
name = algorithm
return _get_hasher(name)
+ if native_only:
+ # caller doesn't want any wrapped hashers.
+ return None
if handler.name == "django_disabled":
raise ValueError("can't wrap unusable-password handler")
try:
return _hasher_cache[handler]
except KeyError:
- name = "Passlib_%s_PasswordHasher" % handler.name.title()
- cls = type(name, (_HasherWrapper,), dict(passlib_handler=handler))
- hasher = _hasher_cache[handler] = cls()
+ hasher = _hasher_cache[handler] = _PasslibHasherWrapper(handler)
return hasher
def _get_hasher(algorithm):
@@ -260,11 +367,23 @@ def _get_hasher(algorithm):
if module is None:
# we haven't patched django, so just import directly
from django.contrib.auth.hashers import get_hasher
- else:
+ return get_hasher(algorithm)
+ elif DJANGO_VERSION < (1,8):
+ # django < 1.8
# we've patched django, so have to use patch manager to retrieve
# original get_hasher() function...
get_hasher = module._manager.getorig("django.contrib.auth.hashers:get_hasher")
- return get_hasher(algorithm)
+ return get_hasher(algorithm)
+ else:
+ # django >= 1.8
+ # we've patched django, but patched at get_hashers() level...
+ # calling original get_hasher() would only land us back here via patched get_hashers().
+ # as non-ideal workaround, have to use original get_hashers()
+ get_hashers = module._manager.getorig("django.contrib.auth.hashers:get_hashers")
+ for hasher in get_hashers():
+ if hasher.algorithm == algorithm:
+ return hasher
+ raise ValueError("unknown hasher: %r" % algorithm)
#=============================================================================
# adapting django hashers -> passlib handlers