mirror of
https://github.com/GAM-team/GAM.git
synced 2025-05-12 12:17:20 +00:00
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""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
|
|
#=============================================================================
|