summaryrefslogtreecommitdiff
path: root/passlib/ext
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2016-11-22 16:10:12 -0500
committerEli Collins <elic@assurancetechnologies.com>2016-11-22 16:10:12 -0500
commit359fdd3cbb88326bbf76aeea6f48bffbcf305f51 (patch)
tree72387bd43c02a5095108540ca6a9649cf7c6baee /passlib/ext
parent5d26ffc04d36740a1c695023e36b1ee8114dffa8 (diff)
downloadpasslib-359fdd3cbb88326bbf76aeea6f48bffbcf305f51.tar.gz
passlib.ext.django: large refactor to make things more isolated & testable.
passlib.ext.django ------------------ * everything in .models relocated to the DjangoContextAdapter() class in .utils. all that's left in models is a couple of hooks. This consolidates all the model state into a single object, making it a lot easier to inspect and optimize. * consolidated a bunch of (undocumented) helper functions into DjangoTranslator() class, which now acts as based for DjangoContextAdapter. Translator instances handle converted passlib <-> django hashers, including caching speed-critical bits. * wrapper class now has guards against wrong type of hasher being passed in * wrapper class uses .using() instead of deprecated .hash(**kwds) format. * updated and confirmed passing tests w/ django 1.10.3 passlib.ext.django tests ------------------------ * split test wrapper for django's internal tests (HashersTest) into separate file, test_ext_django_source.py, to make it easier to run independantly. reworked to use patchAttr(wraps=True) rather than less flexible ContextHook() hack * tries to clean up HashersTest - adapts to django settings, fixed code syncing .iteration settings back to passlib hashers, * blocked out some django tests that we can't / won't pass, documented reasons why. other ----- * CryptContext: added temporary hack to access unpatched Hasher.needs_update() method. * PrefixWrapper: now proxies attr writes if it owns the wrapped hasher. * test utils: added wrap=True support to patchAttr(), for wrapping arbitrary functions.
Diffstat (limited to 'passlib/ext')
-rw-r--r--passlib/ext/django/models.py277
-rw-r--r--passlib/ext/django/utils.py859
2 files changed, 756 insertions, 380 deletions
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index 1337ce2..e766c2d 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -3,17 +3,10 @@
# imports
#=============================================================================
# core
-import logging; log = logging.getLogger(__name__)
-from warnings import warn
# site
-from django import VERSION
-from django.conf import settings
# pkg
from passlib.context import CryptContext
-from passlib.exc import ExpectedTypeError
-from passlib.ext.django.utils import _PatchManager, hasher_to_passlib_name, \
- get_passlib_hasher, get_preset_config, MIN_DJANGO_VERSION
-from passlib.utils.compat import unicode
+from passlib.ext.django.utils import DjangoContextAdapter
# local
__all__ = ["password_context"]
@@ -21,274 +14,22 @@ __all__ = ["password_context"]
# global attrs
#=============================================================================
+#: adapter instance used to drive most of this
+adapter = DjangoContextAdapter()
+
# the context object which this patches contrib.auth to use for password hashing.
# configuration controlled by ``settings.PASSLIB_CONFIG``.
-password_context = CryptContext()
-
-# function mapping User objects -> passlib user category.
-# may be overridden via ``settings.PASSLIB_GET_CATEGORY``.
-def _get_category(user):
- """default get_category() implementation"""
- if user.is_superuser:
- return "superuser"
- elif user.is_staff:
- return "staff"
- else:
- return None
+password_context = adapter.context
-# object used to track state of patches applied to django.
-_manager = _PatchManager(log=logging.getLogger(__name__ + "._manager"))
-
-# patch status
-_patched = False
-
-#=============================================================================
-# applying & removing the patches
-#=============================================================================
-def _apply_patch():
- """monkeypatch django's password handling to use ``passlib_context``,
- assumes the caller will configure the object.
- """
- #
- # setup environment & patch-paths
- #
- if VERSION < MIN_DJANGO_VERSION:
- raise RuntimeError("passlib.ext.django requires django >= %s" % (MIN_DJANGO_VERSION,))
-
- log.debug("preparing to monkeypatch 'django.contrib.auth' ...")
- global _patched
- assert not _patched, "monkeypatching already applied"
-
- HASHERS_PATH = "django.contrib.auth.hashers"
- MODELS_PATH = "django.contrib.auth.models"
- USER_PATH = MODELS_PATH + ":User"
- FORMS_PATH = "django.contrib.auth.forms"
-
- #
- # import some helpers from hashers module
- #
- from django.contrib.auth.hashers import is_password_usable
- from django.utils import lru_cache
- from passlib.utils.compat import lmap
-
- #
- # patch ``User.set_password() & ``User.check_password()`` to use
- # context & get_category (would just leave these as wrappers for hashers
- # module, but then we couldn't pass User object into get_category very easily)
- #
- @_manager.monkeypatch(USER_PATH)
- def set_password(user, password):
- """passlib replacement for User.set_password()"""
- if password is None:
- user.set_unusable_password()
- else:
- # NOTE: pulls _get_category from module globals
- cat = _get_category(user)
- user.password = password_context.hash(password, category=cat)
-
- @_manager.monkeypatch(USER_PATH)
- def check_password(user, password):
- """passlib replacement for User.check_password()"""
- hash = user.password
- if password is None or not is_password_usable(hash):
- return False
- # NOTE: pulls _get_category from module globals
- cat = _get_category(user)
- ok, new_hash = password_context.verify_and_update(password, hash,
- category=cat)
- if ok and new_hash is not None:
- # migrate to new hash if needed.
- user.password = new_hash
- user.save()
- return ok
-
- #
- # override check_password() with our own implementation
- #
- @_manager.monkeypatch(HASHERS_PATH)
- @_manager.monkeypatch(MODELS_PATH)
- def check_password(password, encoded, setter=None, preferred="default"):
- """passlib replacement for check_password()"""
- # XXX: this currently ignores "preferred" keyword, since its purpose
- # was for hash migration, and that's handled by the context.
- if password is None or not is_password_usable(encoded):
- return False
- ok = password_context.verify(password, encoded)
- 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
-
- #
- # patch the other functions defined in the ``hashers`` module, as well
- # as any other known locations where they're imported within ``contrib.auth``
- #
- @_manager.monkeypatch(HASHERS_PATH, wrap=True)
- @_manager.monkeypatch(MODELS_PATH, wrap=True)
- def make_password(__wrapped__, password, salt=None, hasher="default"):
- """passlib replacement for make_password()"""
- if password is None:
- return __wrapped__(None)
- if hasher == "default":
- scheme = None
- else:
- scheme = hasher_to_passlib_name(hasher)
- kwds = dict(scheme=scheme)
- handler = password_context.handler(scheme)
- if "salt" in handler.setting_kwds:
- if hasher.startswith("unsalted_"):
- # Django 1.4.6+ uses a separate 'unsalted_sha1' hasher for "sha1$$digest",
- # but passlib just reuses it's "sha1" handler ("sha1$salt$digest"). To make
- # this work, have to explicitly tell the sha1 handler to use an empty salt.
- kwds['salt'] = ''
- elif salt:
- # Django make_password() autogenerates a salt if salt is bool False (None / ''),
- # so we only pass the keyword on if there's actually a fixed salt.
- kwds['salt'] = salt
- return password_context.hash(password, **kwds)
-
- @_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"):
- """passlib replacement for get_hasher()"""
- if algorithm == "default":
- scheme = None
- else:
- scheme = hasher_to_passlib_name(algorithm)
- # NOTE: resolving scheme -> handler instead of
- # passing scheme into get_passlib_hasher(),
- # in case context contains custom handler
- # shadowing name of a builtin handler.
- handler = password_context.handler(scheme)
- return get_passlib_hasher(handler, algorithm=algorithm)
-
- # identify_hasher() was added in django 1.5,
- # patching it anyways for 1.4, so passlib's version is always available.
- @_manager.monkeypatch(HASHERS_PATH)
- @_manager.monkeypatch(FORMS_PATH)
- def identify_hasher(encoded):
- """passlib helper to identify hasher from encoded password"""
- handler = password_context.identify(encoded, resolve=True,
- required=True)
- algorithm = None
- if handler.name == "django_salted_sha1" and encoded.startswith("sha1$$"):
- # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
- # but passlib just reuses the "sha1$salt$digest" handler.
- # we want to resolve to correct django hasher.
- algorithm = "unsalted_sha1"
- return get_passlib_hasher(handler, algorithm=algorithm)
-
- _patched = True
- log.debug("... finished monkeypatching django")
-
-def _remove_patch():
- """undo the django monkeypatching done by this module.
- offered as a last resort if it's ever needed.
-
- .. warning::
- This may cause problems if any other Django modules have imported
- their own copies of the patched functions, though the patched
- code has been designed to throw an error as soon as possible in
- this case.
- """
- global _patched
- if _patched:
- log.debug("removing django monkeypatching...")
- _manager.unpatch_all(unpatch_conflicts=True)
- password_context.load({})
- _patched = False
- log.debug("...finished removing django monkeypatching")
- return True
- if _manager: # pragma: no cover -- sanity check
- log.warning("reverting partial monkeypatching of django...")
- _manager.unpatch_all()
- password_context.load({})
- log.debug("...finished removing django monkeypatching")
- return True
- log.debug("django not monkeypatched")
- return False
+#: hook callers should use if context is changed
+context_changed = adapter.reset_hashers
#=============================================================================
# main code
#=============================================================================
-def _load():
- global _get_category
-
- # TODO: would like to add support for inheriting config from a preset
- # (or from existing hasher state) and letting PASSLIB_CONFIG
- # be an update, not a replacement.
-
- # TODO: wrap and import any custom hashers as passlib handlers,
- # so they could be used in the passlib config.
-
- # load config from settings
- _UNSET = object()
- config = getattr(settings, "PASSLIB_CONFIG", _UNSET)
- if config is _UNSET:
- # XXX: should probably deprecate this alias
- config = getattr(settings, "PASSLIB_CONTEXT", _UNSET)
- if config is _UNSET:
- config = "passlib-default"
- if config is None:
- warn("setting PASSLIB_CONFIG=None is deprecated, "
- "and support will be removed in Passlib 1.8, "
- "use PASSLIB_CONFIG='disabled' instead.",
- DeprecationWarning)
- config = "disabled"
- elif not isinstance(config, (unicode, bytes, dict)):
- raise ExpectedTypeError(config, "str or dict", "PASSLIB_CONFIG")
-
- # load custom category func (if any)
- get_category = getattr(settings, "PASSLIB_GET_CATEGORY", None)
- if get_category and not callable(get_category):
- raise ExpectedTypeError(get_category, "callable", "PASSLIB_GET_CATEGORY")
-
- # check if we've been disabled
- if config == "disabled":
- if _patched: # pragma: no cover -- sanity check
- log.error("didn't expect monkeypatching would be applied!")
- _remove_patch()
- return
-
- # resolve any preset aliases
- if isinstance(config, str) and '\n' not in config:
- config = get_preset_config(config)
-
- # setup context
- _apply_patch()
- password_context.load(config)
- if get_category:
- # NOTE: _get_category is module global which is read by
- # monkeypatched functions constructed by _apply_patch()
- _get_category = get_category
- log.debug("passlib.ext.django loaded")
-# wrap load function so we can undo any patching if something goes wrong
-try:
- _load()
-except:
- _remove_patch()
- raise
+# load config & install monkeypatch
+adapter.load_model()
#=============================================================================
# eof
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 5107a52..52d41d7 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -3,9 +3,10 @@
# imports
#=============================================================================
# core
-from functools import update_wrapper
+from functools import update_wrapper, wraps
import logging; log = logging.getLogger(__name__)
-from weakref import WeakKeyDictionary
+import sys
+import weakref
from warnings import warn
# site
try:
@@ -15,17 +16,17 @@ except ImportError:
log.debug("django installation not found")
DJANGO_VERSION = ()
# pkg
+from passlib import exc, registry
from passlib.context import CryptContext
from passlib.exc import PasslibRuntimeWarning
-from passlib.registry import get_crypt_handler, list_crypt_handlers
from passlib.utils import memoized_property
-from passlib.utils.compat import get_method_function, iteritems, OrderedDict, native_string_types
+from passlib.utils.compat import get_method_function, iteritems, OrderedDict
# local
__all__ = [
"DJANGO_VERSION",
"MIN_DJANGO_VERSION",
"get_preset_config",
- "get_passlib_hasher",
+ "get_django_hasher",
]
#: minimum version supported by passlib.ext.django
@@ -107,45 +108,717 @@ superuser__django_pbkdf2_sha256__default_rounds = 15000
"""
#=============================================================================
-# translating passlib names <-> hasher names
+# helpers
#=============================================================================
-# prefix used to shoehorn passlib's handler names into django hasher namespace;
-# allows get_hasher() to be meaningfully called even if passlib handler
-# is the one being used.
-PASSLIB_HASHER_PREFIX = "passlib_"
-
-# prefix all the django-specific hash formats are stored under w/in passlib;
-# all of these hashes should expose their hasher name via ``.django_name``.
-DJANGO_PASSLIB_PREFIX = "django_"
-
-# non-django-specific hashes which also expose ``.django_name``.
-_other_django_hashes = ["hex_md5"]
-
-def passlib_to_hasher_name(passlib_name):
- """convert passlib handler name -> hasher name"""
- handler = get_crypt_handler(passlib_name)
- if hasattr(handler, "django_name"):
- return handler.django_name
- return PASSLIB_HASHER_PREFIX + passlib_name
-
-def hasher_to_passlib_name(hasher_name):
- """convert hasher name -> passlib handler name"""
- if hasher_name.startswith(PASSLIB_HASHER_PREFIX):
- return hasher_name[len(PASSLIB_HASHER_PREFIX):]
- if hasher_name == "unsalted_sha1":
- # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
- # but passlib just reuses the "sha1$salt$digest" handler.
- hasher_name = "sha1"
- for name in list_crypt_handlers():
- if name.startswith(DJANGO_PASSLIB_PREFIX) or name in _other_django_hashes:
- handler = get_crypt_handler(name)
- if getattr(handler, "django_name", None) == hasher_name:
- return name
- # XXX: this should only happen for custom hashers that have been registered.
- # _HasherHandler (below) is work in progress that would fix this.
- raise ValueError("can't translate hasher name to passlib name: %r" %
- hasher_name)
+#: prefix used to shoehorn passlib's handler names into django hasher namespace
+PASSLIB_WRAPPER_PREFIX = "passlib_"
+
+#: prefix used by all the django-specific hash formats in passlib;
+#: all of these hashes should have a ``.django_name`` attribute.
+DJANGO_COMPAT_PREFIX = "django_"
+
+#: set of hashes w/o "django_" prefix, but which also expose ``.django_name``.
+_other_django_hashes = set(["hex_md5"])
+
+def _wrap_method(method):
+ """wrap method object in bare function"""
+ @wraps(method)
+ def wrapper(*args, **kwds):
+ return method(*args, **kwds)
+ return wrapper
+
+#=============================================================================
+# translator
+#=============================================================================
+class DjangoTranslator(object):
+ """
+ Object which helps translate passlib hasher objects / names
+ to and from django hasher objects / names.
+
+ These methods are wrapped in a class so that results can be cached,
+ but with the ability to have independant caches, since django hasher
+ names may / may not correspond to the same instance (or even class).
+ """
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: CryptContext instance
+ #: (if any -- generally only set by DjangoContextAdapter subclass)
+ context = None
+
+ #: internal cache of passlib hasher -> django hasher instance.
+ #: key stores weakref to passlib hasher.
+ _django_hasher_cache = None
+
+ #: special case -- unsalted_sha1
+ _django_unsalted_sha1 = None
+
+ #: internal cache of django name -> passlib hasher
+ #: value stores weakrefs to passlib hasher.
+ _passlib_hasher_cache = None
+
+ #=============================================================================
+ # init
+ #=============================================================================
+
+ def __init__(self, context=None, **kwds):
+ super(DjangoTranslator, self).__init__(**kwds)
+ if context is not None:
+ self.context = context
+
+ self._django_hasher_cache = weakref.WeakKeyDictionary()
+ self._passlib_hasher_cache = weakref.WeakValueDictionary()
+
+ def reset_hashers(self):
+ self._django_hasher_cache.clear()
+ self._passlib_hasher_cache.clear()
+ self._django_unsalted_sha1 = None
+
+ def _get_passlib_hasher(self, passlib_name):
+ """
+ resolve passlib hasher by name, using context if available.
+ """
+ context = self.context
+ if context is None:
+ return registry.get_crypt_handler(passlib_name)
+ else:
+ return context.handler(passlib_name)
+
+ #=============================================================================
+ # resolve passlib hasher -> django hasher
+ #=============================================================================
+
+ def passlib_to_django_name(self, passlib_name):
+ """
+ Convert passlib hasher / name to Django hasher name.
+ """
+ return self.passlib_to_django(passlib_name).algorithm
+
+ # XXX: add option (in class, or call signature) to always return a wrapper,
+ # rather than native builtin -- would let HashersTest check that
+ # our own wrapper + implementations are matching up with their tests.
+ def passlib_to_django(self, passlib_hasher, cached=True):
+ """
+ Convert passlib hasher / name to Django hasher.
+
+ :param passlib_hasher:
+ passlib hasher / name
+
+ :returns:
+ django hasher instance
+ """
+ # resolve names to hasher
+ if not hasattr(passlib_hasher, "name"):
+ passlib_hasher = self._get_passlib_hasher(passlib_hasher)
+
+ # check cache
+ if cached:
+ cache = self._django_hasher_cache
+ try:
+ return cache[passlib_hasher]
+ except KeyError:
+ pass
+ result = cache[passlib_hasher] = \
+ self.passlib_to_django(passlib_hasher, cached=False)
+ return result
+
+ # find native equivalent, and return wrapper if there isn't one
+ django_name = getattr(passlib_hasher, "django_name", None)
+ if django_name:
+ return self._create_django_hasher(django_name)
+ else:
+ return _PasslibHasherWrapper(passlib_hasher)
+
+ _builtin_django_hashers = dict(
+ md5="MD5PasswordHasher",
+ )
+
+ def _create_django_hasher(self, django_name):
+ """
+ helper to create new django hasher by name.
+ wraps underlying django methods.
+ """
+ # if we haven't patched django, can use it directly
+ 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.
+ path = self._builtin_django_hashers.get(django_name)
+ if path:
+ if "." not in path:
+ path = "django.contrib.auth.hashers." + path
+ from django.utils.module_loading import import_string
+ return import_string(path)()
+
+ raise ValueError("unknown hasher: %r" % django_name)
+
+ #=============================================================================
+ # reverse django -> passlib
+ #=============================================================================
+
+ def django_to_passlib_name(self, django_name):
+ """
+ Convert Django hasher / name to Passlib hasher name.
+ """
+ return self.django_to_passlib(django_name).name
+
+ def django_to_passlib(self, django_name, cached=True):
+ """
+ Convert Django hasher / name to Passlib hasher / name.
+ If present, CryptContext will be checked instead of main registry.
+
+ :param django_name:
+ Django hasher class or algorithm name.
+ "default" allowed if context provided.
+
+ :raises ValueError:
+ if can't resolve hasher.
+
+ :returns:
+ passlib hasher or name
+ """
+ # check for django hasher
+ if hasattr(django_name, "algorithm"):
+
+ # check for passlib adapter
+ if isinstance(django_name, _PasslibHasherWrapper):
+ return django_name.passlib_handler
+
+ # resolve django hasher -> name
+ django_name = django_name.algorithm
+
+ # check cache
+ if cached:
+ cache = self._passlib_hasher_cache
+ try:
+ return cache[django_name]
+ except KeyError:
+ pass
+ result = cache[django_name] = \
+ self.django_to_passlib(django_name, cached=False)
+ return result
+
+ # check if it's an obviously-wrapped name
+ if django_name.startswith(PASSLIB_WRAPPER_PREFIX):
+ passlib_name = django_name[len(PASSLIB_WRAPPER_PREFIX):]
+ return self._get_passlib_hasher(passlib_name)
+
+ # resolve default
+ if django_name == "default":
+ context = self.context
+ if context is None:
+ raise TypeError("can't determine default scheme w/ context")
+ return context.handler()
+
+ # special case: Django uses a separate hasher for "sha1$$digest"
+ # hashes (unsalted_sha1) and "sha1$salt$digest" (sha1);
+ # but passlib uses "django_salted_sha1" for both of these.
+ if django_name == "unsalted_sha1":
+ django_name = "sha1"
+
+ # resolve name
+ # XXX: bother caching these lists / mapping?
+ # not needed in long-term due to cache above.
+ context = self.context
+ if context is None:
+ # check registry
+ # TODO: should make iteration via registry easier
+ candidates = (
+ registry.get_crypt_handler(passlib_name)
+ for passlib_name in registry.list_crypt_handlers()
+ if passlib_name.startswith(DJANGO_COMPAT_PREFIX) or
+ passlib_name in _other_django_hashes
+ )
+ else:
+ # check context
+ candidates = context.schemes(resolve=True)
+ for handler in candidates:
+ if getattr(handler, "django_name", None) == django_name:
+ return handler
+
+ # give up
+ # NOTE: this should only happen for custom django hashers that we don't
+ # know the equivalents for. _HasherHandler (below) is work in
+ # progress that would allow us to at least return a wrapper.
+ raise ValueError("can't translate django name to passlib name: %r" %
+ (django_name,))
+
+ #=============================================================================
+ # django hasher lookup
+ #=============================================================================
+
+ def resolve_django_hasher(self, django_name, cached=True):
+ """
+ Take in a django algorithm name, return django hasher.
+ """
+ # check for django hasher
+ if hasattr(django_name, "algorithm"):
+ return django_name
+
+ # resolve to passlib hasher
+ passlib_hasher = self.django_to_passlib(django_name, cached=cached)
+
+ # special case: Django uses a separate hasher for "sha1$$digest"
+ # hashes (unsalted_sha1) and "sha1$salt$digest" (sha1);
+ # but passlib uses "django_salted_sha1" for both of these.
+ # XXX: this isn't ideal way to handle this. would like to do something
+ # like pass "django_variant=django_name" into passlib_to_django(),
+ # and have it cache separate hasher there.
+ # but that creates a LOT of complication in it's cache structure,
+ # for what is just one special case.
+ if django_name == "unsalted_sha1" and passlib_hasher.name == "django_salted_sha1":
+ if not cached:
+ return self._create_django_hasher(django_name)
+ result = self._django_unsalted_sha1
+ if result is None:
+ result = self._django_unsalted_sha1 = self._create_django_hasher(django_name)
+ return result
+
+ # lookup corresponding django hasher
+ return self.passlib_to_django(passlib_hasher, cached=cached)
+
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
+#=============================================================================
+# adapter
+#=============================================================================
+class DjangoContextAdapter(DjangoTranslator):
+ """
+ Object which tries to adapt a Passlib CryptContext object,
+ using a Django-hasher compatible API.
+
+ When installed in django, :mod:`!passlib.ext.django` will create
+ an instance of this class, and then monkeypatch the appropriate
+ methods into :mod:`!django.contrib.auth` and other appropriate places.
+ """
+ #=============================================================================
+ # instance attrs
+ #=============================================================================
+
+ #: CryptContext instance we're wrapping
+ context = None
+
+ #: ref to original make_password(),
+ #: needed to generate usuable passwords that match django
+ _orig_make_password = None
+
+ #: ref to django helper of this name -- not monkeypatched
+ is_password_usable = None
+
+ #: PatchManager instance used to track installation
+ _manager = None
+
+ #: whether config=disabled flag was set
+ enabled = True
+
+ #: patch status
+ patched = False
+
+ #=============================================================================
+ # init
+ #=============================================================================
+ def __init__(self, context=None, get_user_category=None, **kwds):
+
+ # init log
+ self.log = logging.getLogger(__name__ + ".DjangoContextAdapter")
+
+ # init parent, filling in default context object
+ if context is None:
+ context = CryptContext()
+ super(DjangoContextAdapter, self).__init__(context=context, **kwds)
+
+ # setup user category
+ if get_user_category:
+ assert callable(get_user_category)
+ self.get_user_category = get_user_category
+
+ # install lru cache wrappers
+ from django.utils.lru_cache import lru_cache
+ self.get_hashers = lru_cache()(self.get_hashers)
+
+ # get copy of original make_password
+ from django.contrib.auth.hashers import make_password
+ if make_password.__module__.startswith("passlib."):
+ make_password = _PatchManager.peek_unpatched_func(make_password)
+ self._orig_make_password = make_password
+
+ # get other django helpers
+ from django.contrib.auth.hashers import is_password_usable
+ self.is_password_usable = is_password_usable
+
+ # init manager
+ mlog = logging.getLogger(__name__ + ".DjangoContextAdapter._manager")
+ self._manager = _PatchManager(log=mlog)
+
+ def reset_hashers(self):
+ """
+ Wrapper to manually reset django's hasher lookup cache
+ """
+ # resets cache for .get_hashers() & .get_hashers_by_algorithm()
+ from django.contrib.auth.hashers import reset_hashers
+ reset_hashers(setting="PASSWORD_HASHERS")
+
+ # reset internal caches
+ super(DjangoContextAdapter, self).reset_hashers()
+
+ #=============================================================================
+ # django hashers helpers -- hasher lookup
+ #=============================================================================
+
+ # lru_cache()'ed by init
+ def get_hashers(self):
+ """
+ Passlib replacement for get_hashers() --
+ Return list of available django hasher classes
+ """
+ passlib_to_django = self.passlib_to_django
+ return [passlib_to_django(hasher)
+ for hasher in self.context.schemes(resolve=True)]
+
+ def get_hasher(self, algorithm="default"):
+ """
+ Passlib replacement for get_hasher() --
+ Return django hasher by name
+ """
+ return self.resolve_django_hasher(algorithm)
+
+ def identify_hasher(self, encoded):
+ """
+ Passlib replacement for identify_hasher() --
+ Identify django hasher based on hash.
+ """
+ handler = self.context.identify(encoded, resolve=True, required=True)
+ if handler.name == "django_salted_sha1" and encoded.startswith("sha1$$"):
+ # Django uses a separate hasher for "sha1$$digest" hashes, but
+ # passlib identifies it as belonging to "sha1$salt$digest" handler.
+ # We want to resolve to correct django hasher.
+ return self.get_hasher("unsalted_sha1")
+ return self.passlib_to_django(handler)
+
+ #=============================================================================
+ # django.contrib.auth.hashers helpers -- password helpers
+ #=============================================================================
+
+ def make_password(self, password, salt=None, hasher="default"):
+ """
+ Passlib replacement for make_password()
+ """
+ if password is None:
+ return self._orig_make_password(None)
+ # NOTE: relying on hasher coming from context, and thus having
+ # context-specific config baked into it.
+ passlib_hasher = self.django_to_passlib(hasher)
+ if "salt" not in passlib_hasher.setting_kwds:
+ # ignore salt param even if preset
+ pass
+ elif hasher.startswith("unsalted_"):
+ # Django uses a separate 'unsalted_sha1' hasher for "sha1$$digest",
+ # but passlib just reuses it's "sha1" handler ("sha1$salt$digest"). To make
+ # this work, have to explicitly tell the sha1 handler to use an empty salt.
+ passlib_hasher = passlib_hasher.using(salt="")
+ elif salt:
+ # Django make_password() autogenerates a salt if salt is bool False (None / ''),
+ # so we only pass the keyword on if there's actually a fixed salt.
+ passlib_hasher = passlib_hasher.using(salt=salt)
+ return passlib_hasher.hash(password)
+
+ def check_password(self, password, encoded, setter=None, preferred="default"):
+ """
+ Passlib replacement for check_password()
+ """
+ # XXX: this currently ignores "preferred" keyword, since its purpose
+ # was for hash migration, and that's handled by the context.
+ if password is None or not self.is_password_usable(encoded):
+ return False
+
+ # verify password
+ context = self.context
+ correct = context.verify(password, encoded)
+ if not (correct and setter):
+ return correct
+
+ # check if we need to rehash
+ if preferred == "default":
+ if not context.needs_update(encoded, secret=password):
+ return correct
+ else:
+ # Django's check_password() won't call setter() on a
+ # 'preferred' alg, even if it's otherwise deprecated. To try and
+ # replicate this behavior if preferred is set, we look up the
+ # passlib hasher, and call it's original needs_update() method.
+ # TODO: * To make this less hackneyed, need to straighten things out
+ # on CryptContext end so we don't have to work around
+ # it's annoying monkeypatching.
+ # * Should also solve redundancy that verify() call
+ # above is already identifying hash.
+ hasher = self.django_to_passlib(preferred)
+ if (hasher.identify(encoded) and
+ not hasher._Context__orig_needs_update(encoded, secret=password)):
+ # alg is 'preferred' and hash itself doesn't need updating,
+ # so nothing to do.
+ return correct
+ # else: either hash isn't preferred, or it needs updating.
+
+ # call setter to rehash
+ setter(password)
+ return correct
+
+ #=============================================================================
+ # django users helpers
+ #=============================================================================
+
+ def user_check_password(self, user, password):
+ """
+ Passlib replacement for User.check_password()
+ """
+ if password is None:
+ return False
+ hash = user.password
+ 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)
+ if ok and new_hash is not None:
+ # migrate to new hash if needed.
+ user.password = new_hash
+ user.save()
+ return ok
+
+ def user_set_password(self, user, password):
+ """
+ Passlib replacement for User.set_password()
+ """
+ if password is None:
+ user.set_unusable_password()
+ else:
+ cat = self.get_user_category(user)
+ user.password = self.context.hash(password, category=cat)
+
+ def get_user_category(self, user):
+ """
+ Helper for hashing passwords per-user --
+ figure out the CryptContext category for specified Django user object.
+ .. note::
+ This may be overridden via PASSLIB_GET_CATEGORY django setting
+ """
+ if user.is_superuser:
+ return "superuser"
+ elif user.is_staff:
+ return "staff"
+ else:
+ return None
+
+ #=============================================================================
+ # patch control
+ #=============================================================================
+
+ HASHERS_PATH = "django.contrib.auth.hashers"
+ MODELS_PATH = "django.contrib.auth.models"
+ USER_CLASS_PATH = MODELS_PATH + ":User"
+ FORMS_PATH = "django.contrib.auth.forms"
+
+ #: list of locations to patch
+ patch_locations = [
+ #
+ # User object
+ # NOTE: could leave defaults alone, but want to have user available
+ # so that we can support get_user_category()
+ #
+ (USER_CLASS_PATH + ".check_password", "user_check_password", dict(method=True)),
+ (USER_CLASS_PATH + ".set_password", "user_set_password", dict(method=True)),
+
+ #
+ # Hashers module
+ #
+ (HASHERS_PATH + ":", "check_password"),
+ (HASHERS_PATH + ":", "make_password"),
+ (HASHERS_PATH + ":", "get_hashers"),
+ (HASHERS_PATH + ":", "get_hasher"),
+ (HASHERS_PATH + ":", "identify_hasher"),
+
+ #
+ # Patch known imports from hashers module
+ #
+ (MODELS_PATH + ":", "check_password"),
+ (MODELS_PATH + ":", "make_password"),
+ (FORMS_PATH + ":", "get_hasher"),
+ (FORMS_PATH + ":", "identify_hasher"),
+
+ ]
+
+ def install_patch(self):
+ """
+ Install monkeypatch to replace django hasher framework.
+ """
+ # don't reapply
+ log = self.log
+ if self.patched:
+ log.warning("monkeypatching already applied, refusing to reapply")
+ return False
+
+ # version check
+ if DJANGO_VERSION < MIN_DJANGO_VERSION:
+ raise RuntimeError("passlib.ext.django requires django >= %s" %
+ (MIN_DJANGO_VERSION,))
+
+ # log start
+ log.debug("preparing to monkeypatch django ...")
+
+ # run through patch locations
+ manager = self._manager
+ for record in self.patch_locations:
+ if len(record) == 2:
+ record += ({},)
+ target, source, opts = record
+ if target.endswith((":", ",")):
+ target += source
+ value = getattr(self, source)
+ if opts.get("method"):
+ # have to wrap our method in a function,
+ # since we're installing it in a class *as* a method
+ # XXX: make this a flag for .patch()?
+ value = _wrap_method(value)
+ manager.patch(target, value)
+
+ # reset django's caches (e.g. get_hash_by_algorithm)
+ self.reset_hashers()
+
+ # done!
+ self.patched = True
+ log.debug("... finished monkeypatching django")
+ return True
+
+ def remove_patch(self):
+ """
+ Remove monkeypatch from django hasher framework.
+ As precaution in case there are lingering refs to context,
+ context object will be wiped.
+
+ .. warning::
+ This may cause problems if any other Django modules have imported
+ their own copies of the patched functions, though the patched
+ code has been designed to throw an error as soon as possible in
+ this case.
+ """
+ log = self.log
+ manager = self._manager
+
+ if self.patched:
+ log.debug("removing django monkeypatching...")
+ manager.unpatch_all(unpatch_conflicts=True)
+ self.context.load({})
+ self.patched = False
+ self.reset_hashers()
+ log.debug("...finished removing django monkeypatching")
+ return True
+
+ if manager.isactive(): # pragma: no cover -- sanity check
+ log.warning("reverting partial monkeypatching of django...")
+ manager.unpatch_all()
+ self.context.load({})
+ self.reset_hashers()
+ log.debug("...finished removing django monkeypatching")
+ return True
+
+ log.debug("django not monkeypatched")
+ return False
+
+ #=============================================================================
+ # loading config
+ #=============================================================================
+
+ def load_model(self):
+ """
+ Load configuration from django, and install patch.
+ """
+ self._load_settings()
+ if self.enabled:
+ try:
+ self.install_patch()
+ except:
+ # try to undo what we can
+ self.remove_patch()
+ raise
+ else:
+ if self.patched: # pragma: no cover -- sanity check
+ log.error("didn't expect monkeypatching would be applied!")
+ self.remove_patch()
+ log.debug("passlib.ext.django loaded")
+
+ def _load_settings(self):
+ """
+ Update settings from django
+ """
+ from django.conf import settings
+
+ # TODO: would like to add support for inheriting config from a preset
+ # (or from existing hasher state) and letting PASSLIB_CONFIG
+ # be an update, not a replacement.
+
+ # TODO: wrap and import any custom hashers as passlib handlers,
+ # so they could be used in the passlib config.
+
+ # load config from settings
+ _UNSET = object()
+ config = getattr(settings, "PASSLIB_CONFIG", _UNSET)
+ if config is _UNSET:
+ # XXX: should probably deprecate this alias
+ config = getattr(settings, "PASSLIB_CONTEXT", _UNSET)
+ if config is _UNSET:
+ config = "passlib-default"
+ if config is None:
+ warn("setting PASSLIB_CONFIG=None is deprecated, "
+ "and support will be removed in Passlib 1.8, "
+ "use PASSLIB_CONFIG='disabled' instead.",
+ DeprecationWarning)
+ config = "disabled"
+ elif not isinstance(config, (unicode, bytes, dict)):
+ raise exc.ExpectedTypeError(config, "str or dict", "PASSLIB_CONFIG")
+
+ # load custom category func (if any)
+ get_category = getattr(settings, "PASSLIB_GET_CATEGORY", None)
+ if get_category and not callable(get_category):
+ raise exc.ExpectedTypeError(get_category, "callable", "PASSLIB_GET_CATEGORY")
+
+ # check if we've been disabled
+ if config == "disabled":
+ self.enabled = False
+ return
+ else:
+ self.__dict__.pop("enabled", None)
+
+ # resolve any preset aliases
+ if isinstance(config, str) and '\n' not in config:
+ config = get_preset_config(config)
+
+ # setup category func
+ if get_category:
+ self.get_user_category = get_category
+ else:
+ self.__dict__.pop("get_category", None)
+
+ # setup context
+ self.context.load(config)
+ self.reset_hashers()
+
+ #=============================================================================
+ # eof
+ #=============================================================================
#=============================================================================
# wrapping passlib handlers as django hashers
@@ -169,6 +842,7 @@ class ProxyProperty(object):
def __delete__(self, obj):
delattr(obj, self.attr)
+
class _PasslibHasherWrapper(object):
"""
adapter which which wraps a :cls:`passlib.ifc.PasswordHash` class,
@@ -177,7 +851,6 @@ class _PasslibHasherWrapper(object):
:param passlib_handler:
passlib hash handler (e.g. :cls:`passlib.hash.sha256_crypt`.
"""
-
#=====================================================================
# instance attrs
#=====================================================================
@@ -195,9 +868,14 @@ class _PasslibHasherWrapper(object):
#=====================================================================
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"
+ if getattr(passlib_handler, "django_name", None):
+ raise ValueError("handlers that reflect an official django "
+ "hasher shouldn't be wrapped: %r" %
+ (passlib_handler.name,))
+ if passlib_handler.is_disabled:
+ # XXX: could this be implemented?
+ raise ValueError("can't wrap disabled-hash handlers: %r" %
+ (passlib_handler.name))
self.passlib_handler = passlib_handler
# init rounds support
@@ -240,7 +918,7 @@ class _PasslibHasherWrapper(object):
@memoized_property
def algorithm(self):
- return PASSLIB_HASHER_PREFIX + self.passlib_handler.name
+ return PASSLIB_WRAPPER_PREFIX + self.passlib_handler.name
#=====================================================================
# hasher api
@@ -267,7 +945,10 @@ class _PasslibHasherWrapper(object):
kwds['rounds'] = self.rounds
elif rounds is not None or iterations is not None:
warn("%s.hash(): 'rounds' and 'iterations' are ignored" % self.__name__)
- return self.passlib_handler.hash(password, **kwds)
+ handler = self.passlib_handler
+ if kwds:
+ handler = handler.using(**kwds)
+ return handler.hash(password)
def safe_summary(self, encoded):
from django.contrib.auth.hashers import mask_hash
@@ -285,7 +966,6 @@ class _PasslibHasherWrapper(object):
items.append((_(key), value))
return OrderedDict(items)
- # added in django 1.6
def must_update(self, encoded):
# 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
@@ -302,68 +982,6 @@ class _PasslibHasherWrapper(object):
# 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, 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),
- and returns a wrapper instance which should be compatible with
- Django's Hashers framework.
-
- If the named hash corresponds to one of Django's builtin hashers,
- an instance of the real hasher class will be returned.
-
- Note that the format of the handler won't be altered,
- so will probably not be compatible with Django's algorithm format,
- so the monkeypatch provided by this plugin must have been applied.
- """
- if isinstance(handler, native_string_types):
- handler = get_crypt_handler(handler)
- if hasattr(handler, "django_name"):
- # return native hasher instance
- # XXX: should add this to _hasher_cache[]
- # TODO: should have this honor .using() configuration
- name = handler.django_name
- if name == "sha1" and algorithm == "unsalted_sha1":
- # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
- # but passlib just reuses the "sha1$salt$digest" handler.
- # 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:
- hasher = _hasher_cache[handler] = _PasslibHasherWrapper(handler)
- return hasher
-
-def _get_hasher(algorithm):
- """wrapper to call django.contrib.auth.hashers:get_hasher()"""
- import sys
- module = sys.modules.get("passlib.ext.django.models")
- if module is None:
- # we haven't patched django, so just import directly
- from django.contrib.auth.hashers import get_hasher
- return get_hasher(algorithm)
- 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 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
#=============================================================================
@@ -467,8 +1085,12 @@ class _PatchManager(object):
self.log = log or logging.getLogger(__name__ + "._PatchManager")
self._state = {}
+ def isactive(self):
+ return bool(self._state)
+
# bool value tests if any patches are currently applied.
- __bool__ = __nonzero__ = lambda self: bool(self._state)
+ # NOTE: this behavior is deprecated in favor of .isactive
+ __bool__ = __nonzero__ = isactive
def _import_path(self, path):
"""retrieve obj and final attribute name from resource path"""
@@ -548,9 +1170,16 @@ class _PatchManager(object):
return wrapped_by(wrapped, *args, **kwds)
update_wrapper(wrapper, value)
value = wrapper
+ if callable(value):
+ # needed by DjangoContextAdapter init
+ get_method_function(value)._patched_original_value = orig
self._set_path(path, value)
self._state[path] = (orig, value)
+ @classmethod
+ def peek_unpatched_func(cls, value):
+ return value._patched_original_value
+
##def patch_many(self, **kwds):
## "override specified resources with new values"
## for path, value in iteritems(kwds):
@@ -564,6 +1193,12 @@ class _PatchManager(object):
path = parent + sep + (name or func.__name__)
self.patch(path, func, wrap=wrap)
return func
+ if callable(name):
+ # called in non-decorator mode
+ func = name
+ name = None
+ builder(func)
+ return None
return builder
#===================================================================