"""passlib.ext.django.models -- monkeypatch django hashing framework""" #============================================================================= # 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 from passlib.utils.compat import callable, unicode, bytes # local __all__ = ["password_context"] #============================================================================= # global attrs #============================================================================= # 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 # 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 constants # 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 UNUSUABLE_PASSWORD and is_password_usuable() helpers # (providing stubs for older django versions) # if VERSION < (1,4): has_hashers = False if VERSION < (1,0): UNUSABLE_PASSWORD = "!" else: from django.contrib.auth.models import UNUSABLE_PASSWORD def is_password_usable(encoded): return encoded is not None and encoded != UNUSABLE_PASSWORD def is_valid_secret(secret): return secret is not None elif VERSION < (1,6): has_hashers = True from django.contrib.auth.hashers import UNUSABLE_PASSWORD, \ is_password_usable # NOTE: 1.4 - 1.5 - empty passwords no longer valid. def is_valid_secret(secret): return bool(secret) else: has_hashers = True from django.contrib.auth.hashers import is_password_usable # 1.6 - empty passwords valid again def is_valid_secret(secret): return secret is not None if VERSION < (1,6): def make_unusable_password(): return UNUSABLE_PASSWORD else: from django.contrib.auth.hashers import make_password as _make_password def make_unusable_password(): return _make_password(None) # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes has_unsalted_sha1 = (VERSION >= (1,4,6)) # # backport ``User.set_unusable_password()`` for Django 0.9 # (simplifies rest of the code) # if not hasattr(_manager.getorig(USER_PATH), "set_unusable_password"): assert VERSION < (1,0) @_manager.monkeypatch(USER_PATH) def set_unusable_password(user): user.password = make_unusable_password() @_manager.monkeypatch(USER_PATH) def has_usable_password(user): return is_password_usable(user.password) # # patch ``User.set_password() & ``User.check_password()`` to use # context & get_category (would just leave these as wrappers for hashers # module under django 1.4, 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 is_valid_secret(password): # NOTE: pulls _get_category from module globals cat = _get_category(user) user.password = password_context.encrypt(password, category=cat) else: user.set_unusable_password() @_manager.monkeypatch(USER_PATH) def check_password(user, password): "passlib replacement for User.check_password()" hash = user.password if not is_valid_secret(password) or not is_password_usable(hash): return False if not hash and VERSION < (1,4): 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, enable=has_hashers) @_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 it's purpose # was for hash migration, and that's handled by the context. if not is_valid_secret(password) or not is_password_usable(encoded): return False ok = password_context.verify(password, encoded) if ok and setter and password_context.needs_update(encoded): setter(password) 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`` # if has_hashers: @_manager.monkeypatch(HASHERS_PATH) @_manager.monkeypatch(MODELS_PATH) def make_password(password, salt=None, hasher="default"): "passlib replacement for make_password()" if not is_valid_secret(password): return make_unusable_password() if hasher == "default": scheme = None else: scheme = hasher_to_passlib_name(hasher) kwds = dict(scheme=scheme) handler = password_context.handler(scheme) # NOTE: django make specify an empty string for the salt, # even if scheme doesn't accept a salt. we omit keyword # in that case. if salt is not None and (salt or 'salt' in handler.setting_kwds): kwds['salt'] = salt return password_context.encrypt(password, **kwds) @_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 (has_unsalted_sha1 and 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 #============================================================================= # 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 #============================================================================= # eof #=============================================================================