diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2016-11-22 16:10:12 -0500 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2016-11-22 16:10:12 -0500 |
commit | 359fdd3cbb88326bbf76aeea6f48bffbcf305f51 (patch) | |
tree | 72387bd43c02a5095108540ca6a9649cf7c6baee /passlib/ext | |
parent | 5d26ffc04d36740a1c695023e36b1ee8114dffa8 (diff) | |
download | passlib-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.py | 277 | ||||
-rw-r--r-- | passlib/ext/django/utils.py | 859 |
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 #=================================================================== |