From b308b88118d1bb14f1adb5513d7290b25dda1e31 Mon Sep 17 00:00:00 2001 From: Eli Collins Date: Fri, 27 Apr 2012 02:41:59 -0400 Subject: near complete rewrite of django plugin, now making public - monkeypatching now formalized w/ a patch manager, and should be *much* more resilient. - patch states reduced greatly, simplified code and tests - now handles django 1.4 correctly - patches hashers module as well (had to write some new wrappers) - added experimental methods GenericHandler.parsehash() to back our wrapper of Hasher.safe_summary() - XXX: doesn't currently import current HASHER state, - XXX: can't import hashers into passlib either -- though left initial notes on this --- CHANGES | 4 + docs/conf.py | 1 - docs/contents.rst | 7 +- docs/lib/passlib.ext.django.rst | 230 ++++----- passlib/ext/django/__init__.py | 11 +- passlib/ext/django/models.py | 303 ++++++++++-- passlib/ext/django/utils.py | 665 +++++++++++++++++--------- passlib/handlers/digests.py | 1 + passlib/handlers/django.py | 6 + passlib/ifc.py | 5 + passlib/tests/test_ext_django.py | 990 ++++++++++++++++++++++----------------- passlib/tests/test_handlers.py | 13 +- passlib/tests/tox_support.py | 31 +- passlib/utils/compat.py | 2 + passlib/utils/handlers.py | 57 +++ 15 files changed, 1518 insertions(+), 808 deletions(-) diff --git a/CHANGES b/CHANGES index 1ae2434..c85db95 100644 --- a/CHANGES +++ b/CHANGES @@ -158,6 +158,10 @@ Release History Other + * Added :mod:`passlib.ext.django`, a Django plugin which can be used to + override Django's password hashing framework with a custom Passlib + policy (An undocumented beta version was present in the 1.5 release). + * The api for the :mod:`passlib.apache` module has been updated to add more flexibility, and to fix some ambiguous method and keyword names. The old names are still supported, but deprecated, diff --git a/docs/conf.py b/docs/conf.py index 6e0d6d1..093973a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -100,7 +100,6 @@ version = csp.get_version(release) # directories to ignore when looking for source files. exclude_patterns = [ #disabling documentation of this until module is more mature. - "lib/passlib.ext.django.rst", "lib/passlib.utils.compat.rst", ] diff --git a/docs/contents.rst b/docs/contents.rst index a4f3cc4..1849a66 100644 --- a/docs/contents.rst +++ b/docs/contents.rst @@ -19,6 +19,8 @@ Table Of Contents lib/passlib.apache lib/passlib.hosts + lib/passlib.ext.django + lib/passlib.exc lib/passlib.registry lib/passlib.utils @@ -30,8 +32,3 @@ Table Of Contents * :ref:`General Index ` * :ref:`Module List ` - -.. - unlisted: - - lib/passlib.ext.django diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst index d797e23..aef10fc 100644 --- a/docs/lib/passlib.ext.django.rst +++ b/docs/lib/passlib.ext.django.rst @@ -1,152 +1,172 @@ -.. index:: django; password hashing app - -================================================== -:mod:`passlib.ext.django` - Django Password Helper -================================================== +.. index:: django; password hashing plugin .. module:: passlib.ext.django -.. warning:: +========================================================== +:mod:`passlib.ext.django` - Django Password Hashing Plugin +========================================================== + +.. versionadded:: 1.6 - This submodule should be considered "release candidate" quality. - It works, and has good unittest coverage, - but has not seen very much real-world use. - *caveat emptor*, and please report any issues. +This module contains a `Django `_ plugin which +overriddes all of Django's password hashing functions, replacing them +with wrappers around a Passlib :doc:`CryptContext ` object +whose configuration is controled from Django's ``settings``. +While this extension's utility is diminished with the advent +of Django 1.4's *hashers* framework, this plugin still has a number +of uses: - This module is currently not compatible with Django 1.4's new - password hashing system, or formats. +* Make use of the new Django 1.4 :ref:`pbkdf2 & bcrypt formats `, + even under earlier Django releases. -.. todo:: +* Allow your application to work with any password hash format + :doc:`supported ` by Passlib, allowing you to import + existing hashes from other systems. + Common examples include SHA512-Crypt, PHPass, and BCrypt. - This documentation needs to be cleaned up significantly - for new users. +* Set different iterations / cost settings based on the type of user account, + and automatically update hashes that use weaker settings when the user + logs in. -Overview -======== -This module is intended for use with -`Django `_-based web applications. -It contains a Django app which allows you to override -Django's builtin password hashing routines -to use any Passlib :doc:`CryptContext ` configuration. -It provides the following features: +* Mark any hash algorithms as deprecated, and automatically migrate to stronger + hashes when the user logs in. -* Custom configurations allow the use of any password hash supported by Passlib. -* Increased-strength hashing for staff and admin accounts. -* Automatically upgrading of deprecated and weaker hashes. -* Default configuration supports all standard Django hash formats, - and automatically upgrades all hashes to use :class:`~passlib.hash.sha512_crypt` - (upgrades only occur when the user logs in or changes their password). -* Tested against Django 0.9 - 1.3 +.. warning:: + + This plugin should be considered "release candidate" quality. + It works, and has good unittest coverage, but has seen only + limited real-world use. Please report any issues. + It has been tested with Django 0.9.6 - 1.4. Installation ============= -Installation is simple: once Passlib is installed, just add -``"passlib.ext.django"`` to Django's ``settings.INSTALLED_APPS``. -This app will handle everything else. +Installation is simple: once Passlib itself has been installed, just add +``"passlib.ext.django"`` to Django's ``settings.INSTALLED_APPS``, +as soon as possible after ``django.contrib.auth``. -Once installed, when this app is imported by Django, it will automatically monkeypatch -:class:`!django.contrib.auth.models.User` to use a Passlib -:class:`~passlib.context.CryptContext` instance in place of the normal Django -password authentication routines. -This provides hash migration, the ability to set stronger policies -for superuser & staff passwords, and stronger password hashing schemes. +Once installed, this plugin will automatically monkeypatch +Django to use a Passlib :class:`!CryptContext` +instance in place of the normal Django password authentication routines +(as an unfortunate side effect, this disables Django 1.4's hashers framework entirely, +though the default configuration supports all the built-in Django 1.4 hashers). Configuration ============= -While the default configuration should be secure, once installed, -you may set the following options in django ``settings.py``: +While this plugin will function perfectly well without setting any configuration +options, you can customize it using the following options in Django's ``settings.py``: + +``PASSLIB_CONFIG`` + + This option specifies the CryptContext configuration options + that will be used when the plugin is loaded. + + * It's value will usually be an INI-formatted string or a dictionary, containing + options to be passed to :class:`~passlib.context.CryptContext`. + + * Alternately, it can be the name of any preset supported by + :func:`~passlib.ext.django.utils.get_preset_config`, such as + ``"passlib-default"`` or ``"django-default"``. + + * Finally, it can be the special string ``"disabled"``, which will disable + this plugin. -``PASSLIB_CONTEXT`` - This may be one of a number of values: - * The string ``"passlib-default"``, which will cause Passlib - to replace Django's hash routines with a builtin policy - that supports all existing django hashes; but as users - log in, upgrades them all to :class:`~passlib.hash.pbkdf2_sha256`. - It also supports stronger hashing for the superuser account. + At any point after this plugin has been loaded, you can serialize + it's current configuration to a string:: - This is the default behavior if ``PASSLIB_CONTEXT`` is not set. + >>> from passlib.ext.django.models import password_context + >>> print password_context.to_string() - The exact default policy used can be found in - :data:`~passlib.ext.django.utils.DEFAULT_CTX`. + This string can then be modified, and used as the new value + of ``PASSLIB_CONFIG``. - * ``"disabled"``, in which case this app will do nothing when Django is loaded. + .. note:: - * A multiline configuration string suitable for passing to - :meth:`passlib.context.CryptContext.from_string`. - It is *strongly* recommended to use a configuration which will support - the existing Django hashes - (see :data:`~passlib.ext.django.utils.STOCK_CTX`). + It is *strongly* recommended to use a configuration which will support + the existing Django hashes. Dumping and then modifying one of the + preset strings is a good starting point. ``PASSLIB_GET_CATEGORY`` - By default, Passlib will invoke the specified context with a category - string that's dependant on the User instance. superusers will be assigned - to the ``superuser`` category, staff to the ``staff`` category, and all - other accounts assigned to ``None``. + By default, Passlib will assign users to one of three categories: + ``"superuser"``, ``"staff"``, or ``None``; based on the attributes + of the ``User`` object. This allows ``PASSLIB_CONFIG`` + to have per-category policies, such as a larger number of iterations + for the superuser account. - This configuration option allows overriding that logic - by specifying an alternate function with the call signature - ``get_category(user) -> category|None``. + This option allows overidding the function which performs this mapping, + so that more fine-grained / alternate user categories can be used. + If specified, the function should have the call syntax + ``get_category(user) -> category_string|None``. .. seealso:: - See :ref:`user-categories` for more details about - the category system in Passlib. + See :ref:`user-categories` for more details. -Utility Functions -================= -.. module:: passlib.ext.django.utils +``PASSLIB_CONTEXT`` -Whether or not you install this application into Django, -the following utility functions are available for overriding -Django's password hashes: + .. deprecated:: 1.6 + This is a deprecated alias for ``PASSLIB_CONFIG``, + used by the (undocumented) version of this plugin that was + released with Passlib 1.5. It should not be used by new applications. -.. data:: DEFAULT_CTX +Module Contents +=============== +.. module:: passlib.ext.django.models - This is a string containing the default hashing policy - that will be used by this application if none is specified - via ``settings.PASSLIB_CONTEXT``. - It defaults to the following:: +.. data:: password_context - [passlib] - schemes = - sha512_crypt, - django_salted_sha1, django_salted_md5, - django_des_crypt, hex_md5, - django_disabled + The :class:`!CryptContext` instance that drives this plugin. + It can be imported and examined to inspect the current configuration, + changes made to it will immediately alter how Django hashes passwords. - default = sha512_crypt +.. module:: passlib.ext.django.utils - deprecated = - django_salted_sha1, django_salted_md5, - django_des_crypt, hex_md5 +.. autofunction:: get_preset_config - all__vary_rounds = 5%% +.. data:: PASSLIB_DEFAULT - sha512_crypt__default_rounds = 15000 - staff__sha512_crypt__default_rounds = 25000 - superuser__sha512_crypt__default_rounds = 35000 + This constant contains the default configuration for ``PASSLIB_CONFIG``. + It provides the following features: -.. data:: STOCK_CTX + * uses :class:`~passlib.hash.django_pbkdf2_sha256` as the default algorithm. + * supports all of the Django 1.0-1.4 :doc:`hash formats `. + * additionally supports SHA512-Crypt, BCrypt, and PHPass. + * is configured to use a larger number of rounds for the superuser account. + * is configured to automatically migrate all Django 1.0 hashes + to use the default hash as soon as each user logs in. - This is a string containing the a hashing policy - which should be exactly the same as Django's default behavior. - It is mainly useful as a template for building off of - when defining your own custom hashing policy - via ``settings.PASSLIB_CONTEXT``. - It defaults to the following:: + As of Passlib 1.6, it contains the following string:: [passlib] + + ; list of schemes supported by configuration + ; currently all django 1.4 hashes, django 1.0 hashes, + ; and three common modular crypt format hashes. schemes = - django_salted_sha1, django_salted_md5, - django_des_crypt, hex_md5, - django_disabled + django_pbkdf2_sha256, django_pbkdf2_sha1, django_bcrypt, + django_salted_sha1, django_salted_md5, django_des_crypt, hex_md5, + sha512_crypt, bcrypt, phpass + + ; default scheme to use for new hashes + default = django_pbkdf2_sha256 - default = django_salted_sha1 + ; hashes using these schemes will automatically be re-hashed + ; when the user logs in (currently all django 1.0 hashes) + deprecated = + django_pbkdf2_sha1, django_salted_sha1, django_salted_md5, + django_des_crypt, hex_md5 - deprecated = hex_md5 + ; sets some common options, including minimum rounds for two primary hashes. + ; if a hash has less than this number of rounds, it will be re-hashed. + all__vary_rounds = 0.05 + sha512_crypt__min_rounds = 80000 + django_pbkdf2_sha256__min_rounds = 10000 -.. autofunction:: get_category + ; set somewhat stronger iteration counts for ``User.is_staff`` + staff__sha512_crypt__default_rounds = 100000 + staff__django_pbkdf2_sha256__default_rounds = 12500 -.. autofunction:: set_django_password_context + ; and even stronger ones for ``User.is_superuser`` + superuser__sha512_crypt__default_rounds = 120000 + superuser__django_pbkdf2_sha256__default_rounds = 15000 diff --git a/passlib/ext/django/__init__.py b/passlib/ext/django/__init__.py index a9e019b..2dc9b28 100644 --- a/passlib/ext/django/__init__.py +++ b/passlib/ext/django/__init__.py @@ -1,9 +1,6 @@ -"""passlib.ext.django - Django app to monkeypatch better password hashing into django +"""passlib.ext.django.models -- monkeypatch django hashing framework -.. warning:: - - This code is experimental and subject to change - (though it should work). - -see the Passlib documentation for details on how to use this app +this plugin monkeypatches django's hashing framework +so that it uses a passlib context object, allowing handling of arbitrary +hashes in Django databases. """ diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py index 0bf9b99..77d1443 100644 --- a/passlib/ext/django/models.py +++ b/passlib/ext/django/models.py @@ -1,49 +1,280 @@ -"""passlib.ext.django.models - -.. warning:: - - This code is experimental and subject to change - (though it should work). - -see the Passlib documentation for details on how to use this app -""" +"""passlib.ext.django.models -- monkeypatch django hashing framework""" #=================================================================== #imports #=================================================================== -#site +# core +import logging; log = logging.getLogger(__name__) +from warnings import warn +# site +from django import VERSION from django.conf import settings -#pkg +# pkg from passlib.context import CryptContext -from passlib.utils import is_crypt_context -from passlib.utils.compat import bytes, unicode, base_string_types -from passlib.ext.django.utils import DEFAULT_CTX, get_category, \ - set_django_password_context +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 #=================================================================== -#main +# applying & removing the patches #=================================================================== -def patch(): - #get config - ctx = getattr(settings, "PASSLIB_CONTEXT", "passlib-default") - catfunc = getattr(settings, "PASSLIB_GET_CATEGORY", get_category) +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 password is not None + + else: + has_hashers = True + from django.contrib.auth.hashers import UNUSABLE_PASSWORD, \ + is_password_usable + + def is_valid_secret(secret): + # NOTE: changed in 1.4 - empty passwords no longer valid. + return bool(secret) + + # + # 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 = 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): + cat = _get_category(user) + user.password = password_context.encrypt(password, category=cat) + else: + user.set_unusable_password() - #parse & validate input value - if ctx == "disabled" or ctx is None: - # remove any patching that was already set, just in case. - set_django_password_context(None) + @_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 + 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 UNUSABLE_PASSWORD + kwds = {} + if salt is not None: + kwds['salt'] = salt + if hasher != "default": + kwds['scheme'] = hasher_to_passlib_name(hasher) + 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) + handler = password_context.handler(scheme) + return get_passlib_hasher(handler) + + # NOTE: custom helper that doesn't exist in django proper + # (though submitted a patch - https://code.djangoproject.com/ticket/18184) + @_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) + return get_passlib_hasher(handler) + + _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: + 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("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: + log.error("didn't expect monkeypatching would be applied!") + _remove_patch() return - if ctx == "passlib-default": - ctx = DEFAULT_CTX - if isinstance(ctx, base_string_types): - ctx = CryptContext.from_string(ctx) - if not is_crypt_context(ctx): - raise TypeError("django settings.PASSLIB_CONTEXT must be CryptContext " - "instance or configuration string: %r" % (ctx,)) - - #monkeypatch django.contrib.auth.models:User - set_django_password_context(ctx, get_category=catfunc) - -patch() + + # 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: + _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 diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index bb95a71..4358ab3 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -1,264 +1,473 @@ -"""passlib.ext.django.utils - helper functions for patching Django hashing - -.. warning:: - - This code is experimental and subject to change - (though it should work). - -Django 1.4 Notes -================ -they isolated the hashing code into auth.hashers. -public interface is check_password(), make_password(), is_password_unusable() -make_password(None) should return unusable. -User object uses these stubs. -will need to refactor monkeypatching quite a bit. -and their new hashers framework might not require passlib anymore anyways. - -as opposed to pre-1.4, which had everything in auth.models - -a check_password(), and User.set_password / check_password / set_unusable methods. -so if there is utility for this, will need to rethink. -""" +"""passlib.ext.django.utils - helper functions used by this plugin""" #=================================================================== #imports #=================================================================== -#site +# core +import logging; log = logging.getLogger(__name__) +from weakref import WeakKeyDictionary from warnings import warn -#pkg +# site +try: + from django import VERSION as DJANGO_VERSION + log.debug("found django %r installation", DJANGO_VERSION) +except ImportError: + log.debug("django installation not found") + DJANGO_VERSION = () +# pkg +from passlib.context import CryptContext from passlib.exc import PasslibRuntimeWarning -from passlib.utils import is_crypt_context -from passlib.utils.compat import bytes, get_method_function as um -#local +from passlib.registry import get_crypt_handler, list_crypt_handlers +from passlib.utils import classproperty +from passlib.utils.compat import bytes, method_function_attr, iteritems +# local __all__ = [ - "get_category", - "set_django_password_context", + "get_preset_config", + "get_passlib_hasher", ] #=================================================================== -#lazy imports -#=================================================================== - -_has_django0 = None # old 0.9 django - lacks unusable_password support -_has_django14 = None # new django 1.4 with auth.hashers -_dam = None #django.contrib.auth.models reference - -def _import_django(): - global _dam, _has_django0, _has_django4 - if _dam is None: - import django.contrib.auth.models as _dam - from django import VERSION - _has_django0 = VERSION < (1,0) - _has_django14 = VERSION >= (1,4) - if _has_django14: - # django 1.4 had a large rewrite that adds new stronger schemes, - # but changes how things work. our monkeypatching may not jive. - warn("passlib.ext.django may not work correctly with django >= 1.4") - return _dam - -#=================================================================== -#constants +# default policies #=================================================================== - -#: base context mirroring django's setup -STOCK_CTX = """ +def get_preset_config(name): + """Returns configuration string for one of the preset strings + supported by the ``PASSLIB_CONFIG`` setting. + Currently supported presets: + + * ``"passlib-default"`` - default config used by this release of passlib. + * ``"django-default"`` - config matching currently installed django version. + * ``"django-latest"`` - config matching newest django version (currently same as ``"django-1.4"``). + * ``"django-1.0"`` - config used by stock Django 1.0 - 1.3 installs + * ``"django-1.4"`` -config used by stock Django 1.4 installs + """ + # TODO: add preset which includes HASHERS + PREFERRED_HASHERS, + # after having imported any custom hashers. "django-current" + if name == "django-default": + if (0,0) < DJANGO_VERSION < (1,4): + name = "django-1.0" + else: + name = "django-1.4" + if name == "django-1.0": + from passlib.apps import django10_context + return django10_context.to_string() + if name == "django-1.4" or name == "django-latest": + from passlib.apps import django14_context + return django14_context.to_string() + if name == "passlib-default": + return PASSLIB_DEFAULT + raise ValueError("unknown preset config name: %r" % name) + +# default context used by passlib 1.6 +PASSLIB_DEFAULT = """ [passlib] -schemes = - django_salted_sha1, django_salted_md5, - django_des_crypt, hex_md5, - django_disabled - -default = django_salted_sha1 -deprecated = hex_md5 -""" - -#: default context used by app -DEFAULT_CTX = """ -[passlib] +; list of schemes supported by configuration +; currently all django 1.4 hashes, django 1.0 hashes, +; and three common modular crypt format hashes. schemes = - sha512_crypt, - django_salted_sha1, django_salted_md5, - django_des_crypt, hex_md5, - django_disabled + django_pbkdf2_sha256, django_pbkdf2_sha1, django_bcrypt, + django_salted_sha1, django_salted_md5, django_des_crypt, hex_md5, + sha512_crypt, bcrypt, phpass -default = sha512_crypt +; default scheme to use for new hashes +default = django_pbkdf2_sha256 +; hashes using these schemes will automatically be re-hashed +; when the user logs in (currently all django 1.0 hashes) deprecated = - django_salted_sha1, django_salted_md5, + django_pbkdf2_sha1, django_salted_sha1, django_salted_md5, django_des_crypt, hex_md5 -all__vary_rounds = 5%% +; sets some common options, including minimum rounds for two primary hashes. +; if a hash has less than this number of rounds, it will be re-hashed. +all__vary_rounds = 0.05 +sha512_crypt__min_rounds = 80000 +django_pbkdf2_sha256__min_rounds = 10000 + +; set somewhat stronger iteration counts for ``User.is_staff`` +staff__sha512_crypt__default_rounds = 100000 +staff__django_pbkdf2_sha256__default_rounds = 12500 -sha512_crypt__default_rounds = 15000 -staff__sha512_crypt__default_rounds = 25000 -superuser__sha512_crypt__default_rounds = 35000 +; and even stronger ones for ``User.is_superuser`` +superuser__sha512_crypt__default_rounds = 120000 +superuser__django_pbkdf2_sha256__default_rounds = 15000 """ #=================================================================== -# helpers +# translating passlib names <-> hasher names #=================================================================== -def get_category(user): - """default get_category() implementation used by set_django_password_context +# prefix used to shoehorn passlib's handler names into 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. +# 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):] + 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. + # work in progress (below) that would take care of those. + raise ValueError("can't translate hasher name to passlib name: %r" % + hasher_name) - this is the function used if ``settings.PASSLIB_GET_CONTEXT`` is not - specified. - - it maps superusers to the ``"superuser"`` category, - staff to the ``"staff"`` category, - and all others to the default category. +#=================================================================== +# wrapping passlib handlers as django hashers +#=================================================================== +_FAKE_SALT = "--fake-salt--" + +class _HasherWrapper(object): + """helper for wrapping passlib handlers in Hasher-compatible class.""" + + # filled in by subclass, drives the other methods. + passlib_handler = None + + @classproperty + def algorithm(cls): + assert not hasattr(cls.passlib_handler, "django_name") + return PASSLIB_HASHER_PREFIX + cls.passlib_handler.name + + def salt(self): + # XXX: our encode wrapper generates a new salt each time it's called, + # so just returning an 'no value' flag here. + return _FAKE_SALT + + def verify(self, password, encoded): + return self.passlib_handler.verify(password, encoded) + + def encode(self, password, salt=None, iterations=None): + kwds = {} + if salt is not None and salt != _FAKE_SALT: + kwds['salt'] = salt + if iterations is not None: + kwds['rounds'] = iterations + return self.passlib_handler.encrypt(password, **kwds) + + _translate_kwds = dict(checksum="hash", rounds="iterations") + + def safe_summary(self, encoded): + from django.contrib.auth.hashers import mask_hash, _, SortedDict + handler = self.passlib_handler + items = [ + # since this is user-facing, we're reporting passlib's name, + # without the distracting PASSLIB_HASHER_PREFIX prepended. + (_('algorithm'), handler.name), + ] + if hasattr(handler, "parsehash"): + kwds = handler.parsehash(encoded, sanitize=mask_hash) + for key, value in iteritems(kwds): + key = self._translate_kwds.get(key, key) + items.append((_(key), value)) + return SortedDict(items) + +# cache of hasher wrappers generated by get_passlib_hasher() +_hasher_cache = WeakKeyDictionary() + +def get_passlib_hasher(handler): + """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 1.4'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. + + .. note:: + This function requires Django 1.4 or later. """ - if user.is_superuser: - return "superuser" - if user.is_staff: - return "staff" - return None + if DJANGO_VERSION < (1,4): + raise RuntimeError("get_passlib_hasher() requires Django >= 1.4") + if isinstance(handler, str): + handler = get_crypt_handler(handler) + if hasattr(handler, "django_name"): + # return native hasher instance + # XXX: should cache this too. + return _get_hasher(handler.django_name)() + if handler.name == "django_disabled": + raise ValueError("can't wrap unusable-password handler") + try: + return _hasher_cache[handler] + except KeyError: + name = "Passlib_%s_PasswordHasher" % handler.name.title() + cls = type(name, (_HasherWrapper,), dict(passlib_handler=handler)) + hasher = _hasher_cache[handler] = cls() + 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 + else: + # we've patched django, so have to use patch manager to retreive + # original get_hasher() function... + get_hasher = module._manager.getorig("django.contrib.auth.hashers:get_hasher") + return get_hasher(algorithm) #=================================================================== -# monkeypatch framework +# adapting django hashers -> passlib handlers #=================================================================== +# TODO: this code probably halfway works, mainly just needs +# a routine to read HASHERS and PREFERRED_HASHER. -# NOTE: this moneypatcher was written to be useful -# outside of this module, and re-invokable, -# which is why it tries so hard to maintain -# sanity about it's patch state. - -_django_patch_state = None #dict holding refs to undo patch - -def set_django_password_context(context=None, get_category=get_category): - """monkeypatches :mod:`!django.contrib.auth` to use specified password context. - - :arg context: - Passlib context to use for Django password hashing. - If ``None``, restores original Django functions. - - In order to support existing hashes, - any context specified should include - all the hashes in :data:`django_context` - in addition to custom hashes. - - :param get_category: - Optional function to use when mapping Django user -> - CryptContext category. - - If a function, should have syntax ``catfunc(user) -> category|None``. - If ``None``, no function is used. - - By default, uses a function which returns ``"superuser"`` - for superusers, and ``"staff"`` for staff. - - This function monkeypatches the following parts of Django: - - * :func:`!django.contrib.auth.models.check_password` - * :meth:`!django.contrib.auth.models.User.check_password` - * :meth:`!django.contrib.auth.models.User.set_password` +##from passlib.registry import register_crypt_handler +##from passlib.utils import classproperty, to_native_str, to_unicode +##from passlib.utils.compat import unicode +## +## +##class _HasherHandler(object): +## "helper for wrapping Hasher instances as passlib handlers" +## # FIXME: this generic wrapper doesn't handle custom settings +## # FIXME: genconfig / genhash not supported. +## +## def __init__(self, hasher): +## self.django_hasher = hasher +## if hasattr(hasher, "iterations"): +## # assume encode() accepts an "iterations" parameter. +## # fake min/max rounds +## self.min_rounds = 1 +## self.max_rounds = 0xFFFFffff +## self.default_rounds = self.django_hasher.iterations +## self.setting_kwds += ("rounds",) +## +## # hasher instance - filled in by constructor +## django_hasher = None +## +## setting_kwds = ("salt",) +## context_kwds = () +## +## @property +## def name(self): +## # XXX: need to make sure this wont' collide w/ builtin django hashes. +## # maybe by renaming this to django compatible aliases? +## return DJANGO_PASSLIB_PREFIX + self.django_name +## +## @property +## def django_name(self): +## # expose this so hasher_to_passlib_name() extracts original name +## return self.django_hasher.algorithm +## +## @property +## def ident(self): +## # this should always be correct, as django relies on ident prefix. +## return unicode(self.django_name + "$") +## +## @property +## def identify(self, hash): +## # this should always work, as django relies on ident prefix. +## return to_unicode(hash, "latin-1", "hash").startswith(self.ident) +## +## @property +## def genconfig(self): +## # XXX: not sure how to support this. +## return None +## +## @property +## def genhash(self, secret, config): +## if config is not None: +## # XXX: not sure how to support this. +## raise NotImplementedError("genhash() for hashers not implemented") +## return self.encrypt(secret) +## +## @property +## def encrypt(self, secret, salt=None, **kwds): +## # NOTE: from how make_password() is coded, all hashers +## # should have salt param. but only some will have +## # 'iterations' parameter. +## opts = {} +## if 'rounds' in self.setting_kwds and 'rounds' in kwds: +## opts['iterations'] = kwds.pop("rounds") +## if kwds: +## raise TypeError("unexpected keyword arguments: %r" % list(kwds)) +## if isinstance(secret, unicode): +## secret = secret.encode("utf-8") +## if salt is None: +## salt = self.django_hasher.salt() +## return to_native_str(self.django_hasher(secret, salt, **opts)) +## +## @property +## def verify(self, secret, hash): +## hash = to_native_str(hash, "utf-8", "hash") +## if isinstance(secret, unicode): +## secret = secret.encode("utf-8") +## return self.django_hasher.verify(secret, hash) +## +##def register_hasher(hasher): +## handler = _HasherHandler(hasher) +## register_crypt_handler(handler) +## return handler - It also stores the provided context in - :data:`!django.contrib.auth.models.User.password_context`, - for easy access. - """ - global _django_patch_state, _dam, _has_django0 - _import_django() - state = _django_patch_state - User = _dam.User - - # issue warning if something else monkeypatched User - # while our patch was applied. - if state is not None: - if um(User.set_password) is not state['user_set_password']: - warn("another library has patched " - "django.contrib.auth.models:User.set_password", - PasslibRuntimeWarning) - if um(User.check_password) is not state['user_check_password']: - warn("another library has patched" - "django.contrib.auth.models:User.check_password", - PasslibRuntimeWarning) - if _dam.check_password is not state['models_check_password']: - warn("another library has patched" - "django.contrib.auth.models:check_password", - PasslibRuntimeWarning) - - #check if we should just restore original state - if context is None: - if state is not None: - del User.password_context - _dam.check_password = state['orig_models_check_password'] - User.set_password = state['orig_user_set_password'] - User.check_password = state['orig_user_check_password'] - _django_patch_state = None - return - - #validate inputs - if not is_crypt_context(context): - raise TypeError("context must be CryptContext instance or None: %r" % - (type(context),)) - - #backup original state if this is first call - if state is None: - _django_patch_state = state = dict( - orig_user_check_password = um(User.check_password), - orig_user_set_password = um(User.set_password), - orig_models_check_password = _dam.check_password, - ) - - #prepare replacements - if _has_django0: - UNUSABLE_PASSWORD = "!" - else: - UNUSABLE_PASSWORD = _dam.UNUSABLE_PASSWORD - - def set_password(user, raw_password): - "passlib replacement for User.set_password()" - if raw_password is None: - if _has_django0: - # django 0.9 - user.password = UNUSABLE_PASSWORD +#=================================================================== +# monkeypatch helpers +#=================================================================== +# private singleton indicating lack-of-value +_UNSET = object() + +class _PatchManager(object): + "helper to manage monkeypatches and run sanity checks" + + # NOTE: this could easily use a dict interface, + # but keeping it distinct to make clear that it's not a dict, + # since it has important side-effects. + + #=================================================================== + # init and support + #=================================================================== + def __init__(self, log=None): + # map of key -> (original value, patched value) + # original value may be _UNSET + self.log = log or logging.getLogger(__name__ + "._PatchManager") + self._state = {} + + # bool value tests if any patches are currently applied. + __bool__ = __nonzero__ = lambda self: bool(self._state) + + def _import_path(self, path): + "retrieve obj and final attribute name from resource path" + name, attr = path.split(":") + obj = __import__(name, fromlist=[attr], level=0) + while '.' in attr: + head, attr = attr.split(".", 1) + obj = getattr(obj, head) + return obj, attr + + @staticmethod + def _is_same_value(left, right): + "check if two values are the same (stripping method wrappers, etc)" + def resolve(value): + if hasattr(value, method_function_attr): + return getattr(value, method_function_attr) + return value + return resolve(left) == resolve(right) + + #=================================================================== + # reading + #=================================================================== + def _get_path(self, key, default=_UNSET): + obj, attr = self._import_path(key) + return getattr(obj, attr, default) + + def get(self, path, default=None): + "return current value for path" + return self._get_path(path, default) + + def getorig(self, path, default=None): + "return original (unpatched) value for path" + try: + value, _= self._state[path] + except KeyError: + value = self._get_path(path) + return default if value is _UNSET else value + + def check_all(self, strict=False): + """run sanity check on all keys, issue warning if out of sync""" + same = self._is_same_value + for path, (orig, expected) in iteritems(self._state): + if same(self._get_path(path), expected): + continue + msg = "another library has patched resource: %r" % path + if strict: + raise RuntimeError(msg) else: - user.set_unusable_password() + warn(msg, PasslibRuntimeWarning) + + #=================================================================== + # patching + #=================================================================== + def _set_path(self, path, value): + obj, attr = self._import_path(path) + if value is _UNSET: + if hasattr(obj, attr): + delattr(obj, attr) else: - cat = get_category(user) if get_category else None - user.password = context.encrypt(raw_password, category=cat) - - def check_password(user, raw_password): - "passlib replacement for User.check_password()" - if raw_password is None: - return False - hash = user.password - if not hash or hash == UNUSABLE_PASSWORD: - return False - cat = get_category(user) if get_category else None - ok, new_hash = context.verify_and_update(raw_password, hash, - category=cat) - if ok and new_hash is not None: - user.password = new_hash - user.save() - return ok - - def raw_check_password(raw_password, enc_password): - "passlib replacement for check_password()" - if not enc_password or enc_password == UNUSABLE_PASSWORD: - raise ValueError("no password hash specified") - return context.verify(raw_password, enc_password) - - #set new state - User.password_context = context - User.set_password = state['user_set_password'] = set_password - User.check_password = state['user_check_password'] = check_password - _dam.check_password = state['models_check_password'] = raw_check_password - state['context' ] = context - state['get_category'] = get_category - -##def get_django_password_context(): -## """return current django password context -## -## This returns the current :class:`~passlib.context.CryptContext` instance -## set by :func:`set_django_password_context`. -## If not context has been set, returns ``None``. -## """ -## global _django_patch_state -## if _django_patch_state: -## return _django_patch_state['context'] -## else: -## return None + setattr(obj, attr, value) + + def patch(self, path, value): + "monkeypatch object+attr at to have , stores original" + assert value != _UNSET + current = self._get_path(path) + try: + orig, expected = self._state[path] + except KeyError: + self.log.debug("patching resource: %r", path) + orig = current + else: + self.log.debug("modifying resource: %r", path) + if not self._is_same_value(current, expected): + warn("overridding resource another library has patched: %r" + % path, PasslibRuntimeWarning) + self._set_path(path, value) + self._state[path] = (orig, value) + + ##def patch_many(self, **kwds): + ## "override specified resources with new values" + ## for path, value in iteritems(kwds): + ## self.patch(path, value) + + def monkeypatch(self, parent, name=None, enable=True): + "function decorator which patches function of same name in " + def builder(func): + if enable: + sep = "." if ":" in parent else ":" + path = parent + sep + (name or func.__name__) + self.patch(path, func) + return func + return builder + + #=================================================================== + # unpatching + #=================================================================== + def unpatch(self, path, unpatch_conflicts=True): + try: + orig, expected = self._state[path] + except KeyError: + return + current = self._get_path(path) + self.log.debug("unpatching resource: %r", path) + if not self._is_same_value(current, expected): + if unpatch_conflicts: + warn("reverting resource another library has patched: %r" + % path, PasslibRuntimeWarning) + else: + warn("not reverting resource another library has patched: %r" + % path, PasslibRuntimeWarning) + del self._state[path] + return + self._set_path(path, orig) + del self._state[path] + + def unpatch_all(self, **kwds): + for key in list(self._state): + self.unpatch(key, **kwds) + + #=================================================================== + # eoc + #=================================================================== #=================================================================== #eof diff --git a/passlib/handlers/digests.py b/passlib/handlers/digests.py index 9e64656..e511e16 100644 --- a/passlib/handlers/digests.py +++ b/passlib/handlers/digests.py @@ -72,6 +72,7 @@ It supports no optional or contextual keywords. #========================================================= hex_md4 = create_hex_hash(md4, "md4") hex_md5 = create_hex_hash(hashlib.md5, "md5") +hex_md5.django_name = "unsalted_md5" hex_sha1 = create_hex_hash(hashlib.sha1, "sha1") hex_sha256 = create_hex_hash(hashlib.sha256, "sha256") hex_sha512 = create_hex_hash(hashlib.sha512, "sha512") diff --git a/passlib/handlers/django.py b/passlib/handlers/django.py index c61aae7..35d66f9 100644 --- a/passlib/handlers/django.py +++ b/passlib/handlers/django.py @@ -105,6 +105,7 @@ class django_salted_sha1(DjangoSaltedHash): Defaults to 5, but can be any non-negative value. """ name = "django_salted_sha1" + django_name = "sha1" ident = u("sha1$") checksum_size = 40 @@ -130,6 +131,7 @@ class django_salted_md5(DjangoSaltedHash): Defaults to 5, but can be any non-negative value. """ name = "django_salted_md5" + django_name = "md5" ident = u("md5$") checksum_size = 32 @@ -155,6 +157,7 @@ django_bcrypt = uh.PrefixWrapper("django_bcrypt", "bcrypt", .. versionadded:: 1.6 """) +django_bcrypt.django_name = "bcrypt" class django_pbkdf2_sha256(DjangoVariableHash): """This class implements Django's PBKDF2-HMAC-SHA256 hash, and follows the :ref:`password-hash-api`. @@ -182,6 +185,7 @@ class django_pbkdf2_sha256(DjangoVariableHash): .. versionadded:: 1.6 """ name = "django_pbkdf2_sha256" + django_name = "pbkdf2_sha256" ident = u('pbkdf2_sha256$') min_salt_size = 1 max_rounds = 0xffffffff # setting at 32-bit limit for now @@ -223,6 +227,7 @@ class django_pbkdf2_sha1(django_pbkdf2_sha256): .. versionadded:: 1.6 """ name = "django_pbkdf2_sha1" + django_name = "pbkdf2_sha1" ident = u('pbkdf2_sha1$') checksum_size = 28 # 20 bytes -> base64 _prf = "hmac-sha1" @@ -253,6 +258,7 @@ class django_des_crypt(uh.HasSalt, uh.GenericHandler): since Django 1.4 generates them this way. """ name = "django_des_crypt" + django_name = "crypt" setting_kwds = ("salt", "salt_size") ident = u("crypt$") checksum_chars = salt_chars = uh.HASH64_CHARS diff --git a/passlib/ifc.py b/passlib/ifc.py index e3eda06..91f5feb 100644 --- a/passlib/ifc.py +++ b/passlib/ifc.py @@ -165,6 +165,11 @@ class PasswordHash(object): ## currently only provided by bcrypt() to fix an historical passlib issue. ## """ + # experimental helper to parse hash into components. + ##@classmethod + ##def parsehash(cls, hash, checksum=True, sanitize=False): + ## """helper to parse hash into components, returns dict""" + # experiment helper to estimate bitsize of different hashes, # implement for GenericHandler, but may be currently be off for some hashes. # want to expand this into a way to programmatically compare diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index 0a8764f..96b7c13 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -3,65 +3,61 @@ #imports #========================================================= from __future__ import with_statement -#core +# core import logging; log = logging.getLogger(__name__) import sys import warnings -#site -#pkg +# site +# pkg +from passlib.apps import django10_context, django14_context from passlib.context import CryptContext -from passlib.apps import django_context -from passlib.ext.django import utils -from passlib.hash import sha256_crypt -from passlib.tests.utils import TestCase, unittest, ut_version, catch_warnings -import passlib.tests.test_handlers as th -from passlib.utils.compat import iteritems, get_method_function, unicode +from passlib.utils.compat import iteritems, unicode, method_function_attr +from passlib.utils import memoized_property from passlib.registry import get_crypt_handler -#module +# tests +from passlib.tests.utils import TestCase, unittest, ut_version, catch_warnings +from passlib.tests.test_handlers import get_handler_case +# local #========================================================= -# import & configure django settings, +# configure django settings for testcases #========================================================= -try: - from django.conf import settings, LazySettings - has_django = True -except ImportError: - settings = None - has_django = False - -has_django0 = False # are we using django 0.9? -has_django1 = False # are we using django >= 1.0? -has_django14 = False # are we using django >= 1.4? +# convert django version to some cheap flags +from passlib.ext.django.utils import DJANGO_VERSION +has_django = bool(DJANGO_VERSION) +has_django0 = has_django and DJANGO_VERSION < (1,0) +has_django1 = DJANGO_VERSION >= (1,0) +has_django14 = DJANGO_VERSION >= (1,4) +# import and configure empty django settings if has_django: - from django import VERSION - log.debug("found django %r installation", VERSION) - has_django0 = (VERSION < (1,0)) - has_django1 = (VERSION >= (1,0)) - has_django14 = (VERSION >= (1,4)) + from django.conf import settings, LazySettings if not isinstance(settings, LazySettings): - #this could mean django has been configured somehow, - #which we don't want, since test cases reset and manipulate settings. + # this probably means django globals have been configured already, + # which we don't want, since test cases reset and manipulate settings. raise RuntimeError("expected django.conf.settings to be LazySettings: %r" % (settings,)) - #else configure a blank settings instance for our unittests + # else configure a blank settings instance for the unittests if has_django0: if settings._target is None: from django.conf import UserSettingsHolder, global_settings settings._target = UserSettingsHolder(global_settings) - else: - if not settings.configured: - settings.configure() -else: - log.debug("django installation not found") + elif not settings.configured: + settings.configure() -_NOTSET = object() +#========================================================= +# support funcs +#========================================================= + +# flag for update_settings() to remove specified key entirely +UNSET = object() def update_settings(**kwds): + """helper to update django settings from kwds""" for k,v in iteritems(kwds): - if v is _NOTSET: + if v is UNSET: if hasattr(settings, k): if has_django0: delattr(settings._target, k) @@ -70,512 +66,660 @@ def update_settings(**kwds): else: setattr(settings, k, v) -#========================================================= -# and prepare helper to skip all relevant tests -# if django isn't installed. -#========================================================= def skipUnlessDjango(cls): - "helper to skip class if django not present" + "helper to skip testcase if django not present" if has_django: return cls if ut_version < 2: return None return unittest.skip("Django not installed")(cls) -#========================================================= -# mock user object -#========================================================= if has_django: - import django.contrib.auth.models as dam + if has_django14: + import django.contrib.auth.hashers as hashers + import django.contrib.auth.models as models - class FakeUser(dam.User): - "stub user object for testing" - #this mainly just overrides .save() to test commit behavior. + class FakeUser(models.User): + "mock user object for use in testing" + # NOTE: this mainly just overrides .save() to test commit behavior. - saved_password = None + @memoized_property + def saved_passwords(self): + return [] + + def pop_saved_passwords(self): + try: + return self.saved_passwords[:] + finally: + del self.saved_passwords[:] def save(self): - self.saved_password = self.password + self.saved_passwords.append(self.password) + +# attrs we're patching in various modules. +_patched_attrs = ["set_password", "check_password", + "make_password", "get_hasher", "identify_hasher"] + +def iter_patch_candidates(): + "helper to scan for monkeypatches" + objs = [models, models.User] + if has_django14: + objs.append(hashers) + for obj in objs: + for attr in dir(obj): + if attr.startswith("_"): + continue + value = getattr(obj, attr) + value = getattr(value, method_function_attr, value) + source = getattr(value, "__module__", None) + if source: + yield obj, attr, source + +config_keys = ["PASSLIB_CONFIG", "PASSLIB_CONTEXT", "PASSLIB_GET_CATEGORY"] + +def create_mock_setter(): + state = [] + def setter(password): + state.append(password) + def popstate(): + try: + return state[:] + finally: + del state[:] + setter.popstate = popstate + return setter #========================================================= -# helper contexts +# sample config used by basic tests #========================================================= # simple context which looks NOTHING like django, # so we can tell if patching worked. -simple_context = CryptContext( +simple_config = dict( schemes = [ "md5_crypt", "des_crypt" ], - default = "md5_crypt", deprecated = [ "des_crypt" ], ) -# some sample hashes +# sample password sample1 = 'password' + +# some sample hashes using above config sample1_md5 = '$1$kAd49ifN$biuRAv1Tv0zGHyCv0uIqW.' sample1_des = 'PPPTDkiCeu/jM' sample1_sha1 = 'sha1$b215d$9ee0a66f84ef1ad99096355e788135f7e949bd41' +empty_md5 = '$1$1.thfpQC$3bIi1iFVFxRQ6cZS7q/WR.' -# context for testing category funcs -category_context = CryptContext( - schemes = [ "sha256_crypt" ], - sha256_crypt__default_rounds = 1000, - staff__sha256_crypt__default_rounds = 2000, - superuser__sha256_crypt__default_rounds = 3000, -) - -def get_cc_rounds(**kwds): - "helper for testing category funcs" - user = FakeUser(**kwds) - user.set_password("placeholder") - return sha256_crypt.from_string(user.password).rounds +#========================================================= +# work up stock django config +#========================================================= +if has_django14: + # have to modify this a little - + # all but pbkdf2_sha256 will be deprecated here, + # whereas stock passlib policy is more permissive + stock_config = django14_context.to_dict() + stock_config['deprecated'] = ["django_pbkdf2_sha1", "django_bcrypt"] + stock_config['deprecated'] +elif has_django1: + stock_config = django10_context.to_dict() +else: + # 0.9.6 config + stock_config = dict(schemes=["django_salted_sha1", "django_salted_md5", "hex_md5"], + deprecated=["hex_md5"]) #========================================================= # test utils #========================================================= -class PatchTest(TestCase): - "test passlib.ext.django.utils:set_django_password_context" - - descriptionPrefix = "passlib.ext.django utils" +class _ExtensionSupport(object): + "support funcs for loading/unloading extension" + + def unload_extension(self): + "helper to remove patches and unload extension" + # remove patches and unload module + mod = sys.modules.get("passlib.ext.django.models") + if mod: + mod._remove_patch() + del sys.modules["passlib.ext.django.models"] + # wipe config from django settings + update_settings(**dict((key, UNSET) for key in config_keys)) + # check everything's gone + self.assert_unpatched() def assert_unpatched(self): - "helper to ensure django hasn't been patched" - state = utils._django_patch_state - - #make sure we aren't currently patched - self.assertIs(state, None) - - #make sure nothing else patches django - for func in [ - dam.check_password, - dam.User.check_password, - dam.User.set_password, - ]: - self.assertEqual(func.__module__, "django.contrib.auth.models") - self.assertFalse(hasattr(dam.User, "password_context")) - - def assert_patched(self, context=_NOTSET): + "test that django is in unpatched state" + # make sure we aren't currently patched + mod = sys.modules.get("passlib.ext.django.models") + self.assertFalse(mod and mod._patched, "patch should not be enabled") + + # make sure no objects have been replaced, by checking __module__ + for obj, attr, source in iter_patch_candidates(): + if attr in _patched_attrs: + self.assertTrue(source.startswith("django.contrib.auth."), + "obj=%r attr=%r was not reverted: %r" % + (obj, attr, source)) + else: + self.assertFalse(source.startswith("passlib."), + "obj=%r attr=%r should not have been patched: %r" % + (obj, attr, source)) + + def load_extension(self, check=True, **kwds): + "helper to load extension with specified config & patch django" + self.unload_extension() + if check: + config = kwds.get("PASSLIB_CONFIG") or kwds.get("PASSLIB_CONTEXT") + for key in config_keys: + kwds.setdefault(key, UNSET) + update_settings(**kwds) + import passlib.ext.django.models + if check: + self.assert_patched(context=config) + + def assert_patched(self, context=None): "helper to ensure django HAS been patched" - state = utils._django_patch_state - - #make sure we're patched - self.assertIsNot(state, None) - - #make sure our methods are exposed - for func in [ - dam.check_password, - dam.User.check_password, - dam.User.set_password, - ]: - self.assertEqual(func.__module__, "passlib.ext.django.utils") - - #make sure methods match - self.assertIs(dam.check_password, state['models_check_password']) - self.assertIs(get_method_function(dam.User.check_password), - state['user_check_password']) - self.assertIs(get_method_function(dam.User.set_password), - state['user_set_password']) - - #make sure context matches - obj = dam.User.password_context - self.assertIs(obj, state['context']) - if context is not _NOTSET: - self.assertIs(obj, context) - - #make sure old methods were stored - for key in [ - "orig_models_check_password", - "orig_user_check_password", - "orig_user_set_password", - ]: - value = state[key] - self.assertEqual(value.__module__, "django.contrib.auth.models") + # make sure we're currently patched + mod = sys.modules.get("passlib.ext.django.models") + self.assertTrue(mod and mod._patched, "patch should have been enabled") + + # make sure only the expected objects have been patched + for obj, attr, source in iter_patch_candidates(): + if attr in _patched_attrs: + self.assertTrue(source == "passlib.ext.django.models", + "obj=%r attr=%r should have been patched: %r" % + (obj, attr, source)) + else: + self.assertFalse(source.startswith("passlib."), + "obj=%r attr=%r should not have been patched: %r" % + (obj, attr, source)) + + # check context matches + if context is not None: + context = CryptContext._norm_source(context) + self.assertEqual(mod.password_context.to_dict(resolve=True), + context.to_dict(resolve=True)) + +class DjangoExtensionTest(TestCase, _ExtensionSupport): + """test the ``passlib.ext.django`` plugin""" + descriptionPrefix = "passlib.ext.django plugin" + #========================================================= + # init + #========================================================= def setUp(self): - #reset to baseline, and verify - utils.set_django_password_context(None) - self.assert_unpatched() + # reset to baseline, and verify it worked + self.unload_extension() - def tearDown(self): - #reset to baseline, and verify - utils.set_django_password_context(None) - self.assert_unpatched() + # and do the same when the test exits + self.addCleanup(self.unload_extension) + #========================================================= + # monkeypatch testing + #========================================================= def test_00_patch_control(self): "test set_django_password_context patch/unpatch" - #check context=None has no effect - utils.set_django_password_context(None) + # check config="disabled" + self.load_extension(PASSLIB_CONFIG="disabled", check=False) self.assert_unpatched() - #patch to use stock django context - utils.set_django_password_context(django_context) - self.assert_patched(context=django_context) + # check legacy config=None + with catch_warnings(record=True) as wlog: + self.load_extension(PASSLIB_CONFIG=None, check=False) + self.consumeWarningList(wlog, ["PASSLIB_CONFIG=None is deprecated.*"]) + self.assert_unpatched() + + # try stock django 1.0 context + self.load_extension(PASSLIB_CONFIG="django-1.0", check=False) + self.assert_patched(context=django10_context) - #try to remove patch - utils.set_django_password_context(None) - self.assert_unpatched() + # try to remove patch + self.unload_extension() - #patch to use stock django context again - utils.set_django_password_context(django_context) - self.assert_patched(context=django_context) + # patch to use stock django 1.4 context + self.load_extension(PASSLIB_CONFIG="django-1.4", check=False) + self.assert_patched(context=django14_context) - #try to remove patch again - utils.set_django_password_context(None) - self.assert_unpatched() + # try to remove patch again + self.unload_extension() - def test_01_patch_control_detection(self): - "test set_django_password_context detection of foreign monkeypatches" + def test_01_overwrite_detection(self): + "test detection of foreign monkeypatching" + # NOTE: this sets things up, and spot checks two methods. + # this should be enough to verify patch manager is working. + # TODO: test unpatch behavior honors flag. def dummy(): pass with catch_warnings(record=True) as wlog: - #patch to use stock django context - utils.set_django_password_context(django_context) - self.assert_patched(context=django_context) + # patch to use simple context, should issue no warnings + self.load_extension(PASSLIB_CONFIG=simple_config) self.consumeWarningList(wlog) + from passlib.ext.django.models import _manager + + # mess with User.set_password, make sure it's detected + orig = models.User.set_password + models.User.set_password = dummy + _manager.check_all() + self.consumeWarningList(wlog,"another library has patched.*User\.set_password") + models.User.set_password = orig + + # mess with models.check_password, make sure it's detected + orig = models.check_password + models.check_password = dummy + _manager.check_all() + self.consumeWarningList(wlog,"another library has patched.*models:check_password") + models.check_password = orig + + def test_02_check_password(self): + "test monkeypatched check_password() function" + # patch to use simple context + self.load_extension(PASSLIB_CONFIG=simple_config) + check_password = models.check_password + + # check hashers module has same function + if has_django14: + self.assertIs(hashers.check_password, check_password) + + # check correct password returns True + self.assertTrue(check_password(sample1, sample1_des)) + self.assertTrue(check_password(sample1, sample1_md5)) + + # check bad password returns False + self.assertFalse(check_password('x', sample1_des)) + self.assertFalse(check_password('x', sample1_md5)) + + # check empty password returns False + self.assertFalse(check_password(None, sample1_des)) + self.assertFalse(check_password('', sample1_des)) + if has_django14: + # 1.4 and up reject empty passwords even if they'd match hash + self.assertFalse(check_password('', empty_md5)) + else: + self.assertTrue(check_password('', empty_md5)) - #mess with User.set_password, make sure it's detected - dam.User.set_password = dummy - utils.set_django_password_context(django_context) - self.assert_patched(context=django_context) - self.consumeWarningList(wlog, - "^another library has patched.*User\.set_password$") - - #mess with user.check_password, make sure it's detected - dam.User.check_password = dummy - utils.set_django_password_context(django_context) - self.assert_patched(context=django_context) - self.consumeWarningList(wlog, - "^another library has patched.*User\.check_password$") - - #mess with user.check_password, make sure it's detected - dam.check_password = dummy - utils.set_django_password_context(django_context) - self.assert_patched(context=django_context) - self.consumeWarningList(wlog, - "^another library has patched.*models:check_password$") - - def test_01_patch_bad_types(self): - "test set_django_password_context bad inputs" - set = utils.set_django_password_context - self.assertRaises(TypeError, set, "") - - def test_02_models_check_password(self): - "test monkeypatched models.check_password()" + # test unusable hash returns False + self.assertFalse(check_password(sample1, None)) + self.assertFalse(check_password(sample1, "!")) - # patch to use simple context - utils.set_django_password_context(simple_context) - self.assert_patched(context=simple_context) + # check unsupported hash throws error + self.assertRaises(ValueError, check_password, sample1, sample1_sha1) - # check correct hashes pass - self.assertTrue(dam.check_password(sample1, sample1_des)) - self.assertTrue(dam.check_password(sample1, sample1_md5)) + def test_03_check_password_migration(self): + "test monkeypatched check_password() function's migration support" + # check setter callback works (django 1.4 feature) + self.load_extension(PASSLIB_CONFIG=simple_config) + setter = create_mock_setter() + check_password = models.check_password - # check bad password fail w/ false - self.assertFalse(dam.check_password('x', sample1_des)) - self.assertFalse(dam.check_password('x', sample1_md5)) + # correct pwd, deprecated hash + self.assertTrue(check_password(sample1, sample1_des, setter=setter)) + self.assertEqual(setter.popstate(), [sample1]) - # and other hashes fail w/ error - self.assertRaises(ValueError, dam.check_password, sample1, sample1_sha1) - self.assertRaises(ValueError, dam.check_password, sample1, None) + # wrong pwd, deprecated hash + self.assertFalse(check_password('x', sample1_des, setter=setter)) + self.assertEqual(setter.popstate(), []) - def test_03_check_password(self): - "test monkeypatched User.check_password()" - # NOTE: using FakeUser so we can test .save() - user = FakeUser() + # correct pwd, preferred hash + self.assertTrue(check_password(sample1, sample1_md5, setter=setter)) + self.assertEqual(setter.popstate(), []) + + # check preferred is ignored (django 1.4 feature) + self.assertTrue(check_password(sample1, sample1_des, setter=setter, + preferred='fooey')) + self.assertEqual(setter.popstate(), [sample1]) + def test_04_user_check_password(self): + "test monkeypatched User.check_password() method" # patch to use simple context - utils.set_django_password_context(simple_context) - self.assert_patched(context=simple_context) + self.load_extension(PASSLIB_CONFIG=simple_config) # test that blank hash is never accepted + user = FakeUser() self.assertEqual(user.password, '') - self.assertIs(user.saved_password, None) - self.assertFalse(user.check_password('x')) + self.assertEqual(user.saved_passwords, []) + self.assertRaises(ValueError, user.check_password, 'x') # check correct secrets pass, and wrong ones fail + user = FakeUser() user.password = sample1_md5 self.assertTrue(user.check_password(sample1)) self.assertFalse(user.check_password('x')) self.assertFalse(user.check_password(None)) - - # none of that should have triggered update of password + # none of that should have triggered update of password self.assertEqual(user.password, sample1_md5) - self.assertIs(user.saved_password, None) + self.assertEqual(user.saved_passwords, []) - #check unusable password - if has_django1: - user.set_unusable_password() - self.assertFalse(user.has_usable_password()) - self.assertFalse(user.check_password(None)) + # check empty password returns False + user = FakeUser() + user.password = sample1_md5 + self.assertFalse(user.check_password(None)) + self.assertFalse(user.check_password('')) + user.password = empty_md5 + if has_django14: + # 1.4 and up reject empty passwords even if they'd match hash self.assertFalse(user.check_password('')) - self.assertFalse(user.check_password(sample1)) + else: + self.assertTrue(user.check_password('')) - def test_04_check_password_migration(self): - "test User.check_password() hash migration" - # NOTE: using FakeUser so we can test .save() + #check unusable password + # NOTE: not present under django 0.9, but our patch backports it. user = FakeUser() + user.set_unusable_password() + self.assertFalse(user.has_usable_password()) + self.assertFalse(user.check_password(None)) + self.assertFalse(user.check_password('')) + self.assertFalse(user.check_password(sample1)) + self.assertEqual(user.saved_passwords, []) + def test_05_user_check_password_migration(self): + "test monkeypatched User.check_password() method's migration support" # patch to use simple context - utils.set_django_password_context(simple_context) - self.assert_patched(context=simple_context) + self.load_extension(PASSLIB_CONFIG=simple_config) # set things up with a password that needs migration + user = FakeUser() user.password = sample1_des self.assertEqual(user.password, sample1_des) - self.assertIs(user.saved_password, None) + self.assertEqual(user.pop_saved_passwords(), []) - # run check with bad password... - # shouldn't have migrated + # run check with wrong password... shouldn't have migrated self.assertFalse(user.check_password('x')) self.assertFalse(user.check_password(None)) - self.assertEqual(user.password, sample1_des) - self.assertIs(user.saved_password, None) + self.assertEqual(user.pop_saved_passwords(), []) - # run check with correct password... - # should have migrated to md5 and called save() + # run check with correct password... should have migrated to md5 and called save() self.assertTrue(user.check_password(sample1)) - self.assertTrue(user.password.startswith("$1$")) - self.assertEqual(user.saved_password, user.password) + self.assertEqual(user.pop_saved_passwords(), [user.password]) - # check resave doesn't happen - user.saved_password = None + # check re-migration doesn't happen + orig = user.password self.assertTrue(user.check_password(sample1)) - self.assertIs(user.saved_password, None) - - def test_05_set_password(self): - "test monkeypatched User.set_password()" - user = FakeUser() + self.assertEqual(user.password, orig) + self.assertEqual(user.pop_saved_passwords(), []) + def test_06_set_password(self): + "test monkeypatched User.set_password() method" # patch to use simple context - utils.set_django_password_context(simple_context) - self.assert_patched(context=simple_context) + self.load_extension(PASSLIB_CONFIG=simple_config) + from passlib.ext.django.models import password_context # sanity check + user = FakeUser() self.assertEqual(user.password, '') - self.assertIs(user.saved_password, None) - if has_django1: - self.assertTrue(user.has_usable_password()) + self.assertEqual(user.pop_saved_passwords(), []) + self.assertTrue(user.has_usable_password()) # set password user.set_password(sample1) + self.assertEqual(password_context.identify(user.password), "md5_crypt") self.assertTrue(user.check_password(sample1)) - self.assertEqual(simple_context.identify(user.password), "md5_crypt") - self.assertIs(user.saved_password, None) + self.assertEqual(user.pop_saved_passwords(), []) + self.assertTrue(user.has_usable_password()) - #check unusable password + # check unusable password user.set_password(None) - if has_django1: - self.assertFalse(user.has_usable_password()) - self.assertIs(user.saved_password, None) - - def test_06_get_category(self): - "test default get_category function" - func = utils.get_category - self.assertIs(func(FakeUser()), None) - self.assertEqual(func(FakeUser(is_staff=True)), "staff") - self.assertEqual(func(FakeUser(is_superuser=True)), "superuser") - self.assertEqual(func(FakeUser(is_staff=True, - is_superuser=True)), "superuser") - - def test_07_get_category(self): - "test set_django_password_context's get_category parameter" - # test patch uses default get_category - utils.set_django_password_context(category_context) - self.assertEqual(get_cc_rounds(), 1000) - self.assertEqual(get_cc_rounds(is_staff=True), 2000) - self.assertEqual(get_cc_rounds(is_superuser=True), 3000) - - # test patch uses explicit get_category - def get_category(user): - return user.first_name or None - utils.set_django_password_context(category_context, get_category) - self.assertEqual(get_cc_rounds(), 1000) - self.assertEqual(get_cc_rounds(first_name='other'), 1000) - self.assertEqual(get_cc_rounds(first_name='staff'), 2000) - self.assertEqual(get_cc_rounds(first_name='superuser'), 3000) - - # test patch can disable get_category - utils.set_django_password_context(category_context, None) - self.assertEqual(get_cc_rounds(), 1000) - self.assertEqual(get_cc_rounds(first_name='other'), 1000) - self.assertEqual(get_cc_rounds(first_name='staff', is_staff=True), 1000) - self.assertEqual(get_cc_rounds(first_name='superuser', is_superuser=True), 1000) - -PatchTest = skipUnlessDjango(PatchTest) - -#========================================================= -# test django plugin -#========================================================= - -django_hash_tests = [ - th.hex_md5_test, - th.django_des_crypt_test, - th.django_salted_md5_test, - th.django_salted_sha1_test, - ] - -default_hash_tests = django_hash_tests + [ th.builtin_sha512_crypt_test \ - or th.os_crypt_sha512_crypt_test ] - -if has_django0: - django_hash_tests.remove(th.django_des_crypt_test) - -class PluginTest(TestCase): - "test django plugin via settings" - - descriptionPrefix = "passlib.ext.django plugin" - - def setUp(self): - super(PluginTest, self).setUp() - - # remove django patch now, and at end - utils.set_django_password_context(None) - self.addCleanup(utils.set_django_password_context, None) + self.assertFalse(user.has_usable_password()) + self.assertEqual(user.pop_saved_passwords(), []) + + def test_07_get_hasher(self): + "test monkeypatched get_hasher() function" + if not has_django14: + raise self.skipTest("Django >= 1.4 not installed") + # TODO: test this + + def test_08_identify_hasher(self): + "test custom identify_hasher() function" + if not has_django14: + raise self.skipTest("Django >= 1.4 not installed") + # TODO: test this + + def test_09_handler_wrapper(self): + "test Hasher-compatible handler wrappers" + if not has_django14: + raise self.skipTest("Django >= 1.4 not installed") + from passlib.ext.django.utils import get_passlib_hasher + + # should return native django hasher if available + hasher = get_passlib_hasher("hex_md5") + self.assertIs(hasher.__class__, hashers.UnsaltedMD5PasswordHasher) + + hasher = get_passlib_hasher("django_bcrypt") + self.assertIs(hasher.__class__, hashers.BCryptPasswordHasher) + + # otherwise should return wrapper + from passlib.hash import sha256_crypt + hasher = get_passlib_hasher("sha256_crypt") + self.assertEqual(hasher.algorithm, "passlib_sha256_crypt") + encoded = hasher.encode("stub") + self.assertTrue(sha256_crypt.verify("stub", encoded)) + self.assertTrue(hasher.verify("stub", encoded)) + self.assertFalse(hasher.verify("xxxx", encoded)) + + # test wrapper accepts options + encoded = hasher.encode("stub", "abcd"*4, iterations=1234) + self.assertEqual(encoded, "$5$rounds=1234$abcdabcdabcdabcd$" + "v2RWkZQzctPdejyRqmmTDQpZN6wTh7.RUy9zF2LftT6") + self.assertEqual(hasher.safe_summary(encoded), + {'algorithm': 'sha256_crypt', + 'salt': u'abcdab**********', + 'iterations': 1234, + 'hash': u'v2RWkZ*************************************', + }) + + #========================================================= + # PASSLIB_CONFIG setting + #========================================================= + def test_10_stock(self): + "test unloaded extension / actual django behavior" + # test against stock django configuration before loading extension + #NOTE: if this test fails, probably means newer version of Django, + # and that passlib's stock configs should be updated. + self.check_config(stock_config, patched=False) + + def test_11_config_disabled(self): + "test PASSLIB_CONFIG='disabled'" + # test config=None (deprecated) + with catch_warnings(record=True) as wlog: + self.load_extension(PASSLIB_CONFIG=None,check=False) + self.consumeWarningList(wlog, "PASSLIB_CONFIG=None is deprecated") + self.assert_unpatched() - # ensure django settings are empty - update_settings( - PASSLIB_CONTEXT=_NOTSET, - PASSLIB_GET_CATEGORY=_NOTSET, - ) + # test disabled config + self.load_extension(PASSLIB_CONFIG="disabled", check=False) + self.assert_unpatched() - # unload module so it's re-run when imported - sys.modules.pop("passlib.ext.django.models", None) + def test_12_config_presets(self): + "test PASSLIB_CONFIG=''" + # test django presets + self.load_extension(PASSLIB_CONTEXT="django-default", check=False) + if has_django14: + ctx = django14_context + else: + ctx = django10_context + self.assert_patched(ctx) + + self.load_extension(PASSLIB_CONFIG="django-1.0", check=False) + self.assert_patched(django10_context) + + self.load_extension(PASSLIB_CONFIG="django-1.4", check=False) + self.assert_patched(django14_context) + + def test_13_config_defaults(self): + "test PASSLIB_CONFIG default behavior" + # check implicit default + from passlib.ext.django.utils import PASSLIB_DEFAULT + default = CryptContext.from_string(PASSLIB_DEFAULT) + self.load_extension() + self.check_config(default) + + # check default preset + self.load_extension(PASSLIB_CONTEXT="passlib-default", check=False) + self.assert_patched(PASSLIB_DEFAULT) + + # check explicit string + self.load_extension(PASSLIB_CONTEXT=PASSLIB_DEFAULT, check=False) + self.assert_patched(PASSLIB_DEFAULT) + + def test_14_config_invalid(self): + "test PASSLIB_CONFIG type checks" + update_settings(PASSLIB_CONTEXT=123, PASSLIB_CONFIG=UNSET) + self.assertRaises(TypeError, __import__, 'passlib.ext.django.models') - def check_hashes(self, tests, default_scheme, deprecated=[], load=True): - """run through django api to verify patch is configured & functioning""" - # load extension if it hasn't been already. - if load: - import passlib.ext.django.models + def check_config(self, context, patched=True): + """run through django api to verify it's matches the specified config""" + # XXX: this take a while to run. what could be trimmed? - # create fake user object - user = FakeUser() + # setup helpers + if isinstance(context, dict): + context = CryptContext(**context) + check_password = models.check_password + if has_django14: + from passlib.ext.django.utils import hasher_to_passlib_name, passlib_to_hasher_name + setter = create_mock_setter() # check new hashes constructed using default scheme + user = FakeUser() user.set_password("stub") - handler = get_crypt_handler(default_scheme) - self.assertTrue(handler.identify(user.password), - "handler failed to identify hash: %r %r" % - (default_scheme, user.password)) + default = context.handler() + self.assertTrue(default.verify("stub", user.password)) + + # test module-level make_password + if has_django14: + hash = hashers.make_password('stub') + self.assertTrue(default.verify('stub', hash)) + + # run through known hashes for supported schemes + for scheme in context.schemes(): + deprecated = context._is_deprecated_scheme(scheme) + assert not (deprecated and scheme == default.name) + testcase = get_handler_case(scheme) + if testcase.is_disabled_handler: + continue + handler = testcase.handler + for secret, hash in testcase.iter_known_hashes(): +## print [scheme, secret, hash, deprecated, scheme==default.name] + other = 'stub' + + # store hash + user = FakeUser() + user.password = hash + + # check against invalid password + self.assertFalse(user.check_password(other)) + self.assertEqual(user.password, hash) - # run against hashes from tests... - for test in tests: - for secret, hash in test.iter_known_hashes(): + # empty passwords no longer accepted by django 1.4 + if not secret and has_django14: + self.assertFalse(user.check_password(secret)) + self.assertFalse(check_password(secret, hash)) + user.set_password(secret) + self.assertFalse(user.has_usable_password()) + continue # check against valid password - user.password = hash if has_django0 and isinstance(secret, unicode): secret = secret.encode("utf-8") self.assertTrue(user.check_password(secret)) - if deprecated and test.handler.name in deprecated: - self.assertFalse(handler.identify(hash)) - self.assertTrue(handler.identify(user.password)) - # check against invalid password - user.password = hash - self.assertFalse(user.check_password('x'+secret)) - if deprecated and test.handler.name in deprecated: - self.assertFalse(handler.identify(hash)) + # check if it upgraded the hash + if deprecated: + self.assertNotEqual(user.password, hash) + self.assertFalse(handler.identify(user.password)) + self.assertTrue(default.identify(user.password)) + else: self.assertEqual(user.password, hash) - # check disabled handling - if has_django1: - user.set_password(None) - handler = get_crypt_handler("django_disabled") - self.assertTrue(handler.identify(user.password)) - self.assertFalse(user.check_password('placeholder')) - - def check_django_stock(self, load=True): - self.check_hashes(django_hash_tests, - "django_salted_sha1", - ["hex_md5"], load=load) - - def check_passlib_stock(self): - self.check_hashes(default_hash_tests, - "sha512_crypt", - ["hex_md5", "django_salted_sha1", - "django_salted_md5", - "django_des_crypt", - ]) - - def test_10_django(self): - "test actual Django behavior has not changed" - #NOTE: if this test fails, - # probably means newer version of Django, - # and passlib's policies should be updated. - self.check_django_stock(load=False) - - def test_11_none(self): - "test PASSLIB_CONTEXT=None" - update_settings(PASSLIB_CONTEXT=None) - self.check_django_stock(load=False) - - def test_12_string(self): - "test PASSLIB_CONTEXT=string" - update_settings(PASSLIB_CONTEXT=utils.STOCK_CTX) - self.check_django_stock(load=False) - - def test_13_unset(self): - "test unset PASSLIB_CONTEXT uses default" - self.check_passlib_stock() - - def test_14_default(self): - "test PASSLIB_CONTEXT = utils.DEFAULT_CTX" - update_settings(PASSLIB_CONTEXT=utils.DEFAULT_CTX) - self.check_passlib_stock() - - def test_15_default_alias(self): - "test PASSLIB_CONTEXT = 'passlib-default'" - update_settings(PASSLIB_CONTEXT="passlib-default") - self.check_passlib_stock() - - def test_16_invalid(self): - "test PASSLIB_CONTEXT = invalid type" - update_settings(PASSLIB_CONTEXT=123) - self.assertRaises(TypeError, __import__, 'passlib.ext.django.models') - - def test_20_categories(self): - "test PASSLIB_GET_CATEGORY unset" - update_settings( - PASSLIB_CONTEXT=category_context.to_string(), - ) - import passlib.ext.django.models - - self.assertEqual(get_cc_rounds(), 1000) - self.assertEqual(get_cc_rounds(is_staff=True), 2000) - self.assertEqual(get_cc_rounds(is_superuser=True), 3000) + # test module-level check_password + self.assertTrue(check_password(secret, hash, setter=setter)) + self.assertEqual(setter.popstate(), [secret] if deprecated else []) + self.assertFalse(check_password(other, hash, setter=setter)) + self.assertEqual(setter.popstate(), []) + + # test module-level identify_hasher + if has_django14 and patched: + self.assertTrue(hashers.is_password_usable(hash)) + hasher = hashers.identify_hasher(hash) + name = hasher_to_passlib_name(hasher.algorithm) + self.assertEqual(name, scheme) + + # test module-level make_password + if has_django14: + alg = passlib_to_hasher_name(scheme) + hash2 = hashers.make_password(secret, hasher=alg) + self.assertTrue(handler.verify(secret, hash2)) - def test_21_categories_explicit(self): - "test PASSLIB_GET_CATEGORY = function" + # check disabled handling + user = FakeUser() + user.set_password(None) + handler = get_crypt_handler("django_disabled") + self.assertTrue(handler.identify(user.password)) + self.assertFalse(user.check_password('stub')) + if has_django14 and patched: + self.assertFalse(hashers.is_password_usable(user.password)) + self.assertRaises(ValueError, hashers.identify_hasher, user.password) + + #========================================================= + # PASSLIB_GET_CATEGORY setting + #========================================================= + def test_20_category_setting(self): + "test PASSLIB_GET_CATEGORY parameter" + # define config where rounds can be used to detect category + config = dict( + schemes = ["sha256_crypt"], + sha256_crypt__default_rounds = 1000, + staff__sha256_crypt__default_rounds = 2000, + superuser__sha256_crypt__default_rounds = 3000, + ) + from passlib.hash import sha256_crypt + + def run(**kwds): + "helper to take in user opts, return rounds used in password" + user = FakeUser(**kwds) + user.set_password("stub") + return sha256_crypt.from_string(user.password).rounds + + # test default get_category + self.load_extension(PASSLIB_CONFIG=config) + self.assertEqual(run(), 1000) + self.assertEqual(run(is_staff=True), 2000) + self.assertEqual(run(is_superuser=True), 3000) + + # test patch uses explicit get_category function def get_category(user): return user.first_name or None - update_settings( - PASSLIB_CONTEXT = category_context.to_string(), - PASSLIB_GET_CATEGORY = get_category, - ) - import passlib.ext.django.models - - self.assertEqual(get_cc_rounds(), 1000) - self.assertEqual(get_cc_rounds(first_name='other'), 1000) - self.assertEqual(get_cc_rounds(first_name='staff'), 2000) - self.assertEqual(get_cc_rounds(first_name='superuser'), 3000) - - def test_22_categories_disabled(self): - "test PASSLIB_GET_CATEGORY = None" - update_settings( - PASSLIB_CONTEXT = category_context.to_string(), - PASSLIB_GET_CATEGORY = None, - ) - import passlib.ext.django.models - - self.assertEqual(get_cc_rounds(), 1000) - self.assertEqual(get_cc_rounds(first_name='other'), 1000) - self.assertEqual(get_cc_rounds(first_name='staff', is_staff=True), 1000) - self.assertEqual(get_cc_rounds(first_name='superuser', is_superuser=True), 1000) - -PluginTest = skipUnlessDjango(PluginTest) + self.load_extension(PASSLIB_CONTEXT=config, + PASSLIB_GET_CATEGORY=get_category) + self.assertEqual(run(), 1000) + self.assertEqual(run(first_name='other'), 1000) + self.assertEqual(run(first_name='staff'), 2000) + self.assertEqual(run(first_name='superuser'), 3000) + + # test patch can disable get_category entirely + def get_category(user): + return None + self.load_extension(PASSLIB_CONTEXT=config, + PASSLIB_GET_CATEGORY=get_category) + self.assertEqual(run(), 1000) + self.assertEqual(run(first_name='other'), 1000) + self.assertEqual(run(first_name='staff', is_staff=True), 1000) + self.assertEqual(run(first_name='superuser', is_superuser=True), 1000) + + #========================================================= + # eoc + #========================================================= + +DjangoExtensionTest = skipUnlessDjango(DjangoExtensionTest) + +# hack up the some of the real django tests to run w/ extension loaded, +# to ensure we mimic their behavior. +if has_django14: + from django.contrib.auth.tests.hashers import TestUtilsHashPass as _TestHashers + class HashersTest(_TestHashers, _ExtensionSupport): + def setUp(self): + # omitted orig setup, loading hashers our own way + self.load_extension(PASSLIB_CONTEXT=stock_config, check=False) + def tearDown(self): + self.unload_extension() + super(HashersTest, self).tearDown() #========================================================= #eof diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 81a67fd..206a089 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -17,7 +17,7 @@ from passlib.tests.utils import TestCase, HandlerCase, create_backend_case, \ #module #========================================================= -#some +# constants & support #========================================================= # some common unicode passwords which used as test cases @@ -27,6 +27,17 @@ UPASS_TABLE = u("t\u00e1\u0411\u2113\u0259") PASS_TABLE_UTF8 = b('t\xc3\xa1\xd0\x91\xe2\x84\x93\xc9\x99') # utf-8 +def get_handler_case(scheme): + "return HandlerCase instance for scheme, used by other tests" + from passlib.registry import get_crypt_handler + handler = get_crypt_handler(scheme) + if hasattr(handler, "backends") and not hasattr(handler, "wrapped"): + backend = handler.get_backend() + name = "%s_%s_test" % (backend, scheme) + else: + name = "%s_test" % scheme + return globals()[name] + #========================================================= #apr md5 crypt #========================================================= diff --git a/passlib/tests/tox_support.py b/passlib/tests/tox_support.py index 7da0546..4c8cee8 100644 --- a/passlib/tests/tox_support.py +++ b/passlib/tests/tox_support.py @@ -1,12 +1,20 @@ """passlib.tests.tox_support - helper script for tox tests""" +#============================================================================= +# init script env +#============================================================================= +import os, sys +root_dir = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) +sys.path.insert(0, root_dir) + #============================================================================= # imports #============================================================================= # core -import os +import re import logging; log = logging.getLogger(__name__) # site # pkg +from passlib.utils.compat import print_ # local __all__ = [ ] @@ -14,7 +22,22 @@ __all__ = [ #============================================================================= # main #============================================================================= -def main(path, runtime): +def do_preset_tests(name): + "return list of preset test names" + if name == "django" or name == "django-hashes": + from passlib.tests import test_handlers + names = [ + "passlib/tests/test_handlers.py:" + name + for name in dir(test_handlers) + if re.match("^django_.*_test$", name) + ] + ["hex_md5_test"] + if name == "django": + names.append("passlib/tests/test_ext_django.py") + print_(" ".join(names)) + else: + raise ValueError("unknown name: %r" % name) + +def do_setup_gae(path, runtime): "write fake GAE ``app.yaml`` to current directory so nosegae will work" from passlib.tests.utils import set_file set_file(os.path.join(path, "app.yaml"), """\ @@ -28,6 +51,10 @@ handlers: script: dummy.py """ % runtime) +def main(cmd, *args): + func = globals()["do_" + cmd] + return func(*args) + if __name__ == "__main__": import sys sys.exit(main(*sys.argv[1:]) or 0) diff --git a/passlib/utils/compat.py b/passlib/utils/compat.py index 1d88f75..02c2de3 100644 --- a/passlib/utils/compat.py +++ b/passlib/utils/compat.py @@ -260,9 +260,11 @@ def exc_err(): return sys.exc_info()[1] if PY3: + method_function_attr = "__func__" def get_method_function(method): return method.__func__ else: + method_function_attr = "im_func" def get_method_function(method): return method.im_func diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index 6831003..6d5cb49 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -573,6 +573,63 @@ class GenericHandler(PasswordHash): #========================================================= # experimental methods #========================================================= + _unparsed_settings = ("salt_size", "relaxed") + _unsafe_settings = ("salt", "checksum") + + @classproperty + def _parsed_settings(cls): + return (key for key in cls.setting_kwds + if key not in cls._unparsed_settings) + + @staticmethod + def _sanitize(value, char=u("*")): + "default method to obscure sensitive fields" + if value is None: + return None + if isinstance(value, bytes): + from passlib.utils import ab64_encode + value = ab64_encode(value).decode("ascii") + elif not isinstance(value, unicode): + value = unicode(value) + size = len(value) + clip = min(4, size//8) + return value[:clip] + char * (size-clip) + + @classmethod + def parsehash(cls, hash, checksum=True, sanitize=False): + """[experimental method] parse hash into dictionary of settings. + + this essentially acts as the inverse of :meth:`encrypt`: for most + cases, if ``hash = cls.encrypt(secret, **opts)``, then + ``cls.parsehash(hash)`` will return a dict matching the original options + (with the extra keyword *checksum*). + + this method may not work correctly for all hashes, + and may not be available on some few. it's interface may + change in future releases, if it's kept around at all. + + :arg hash: hash to parse + :param checksum: include checksum keyword? (defaults to True) + :param sanitize: mask data for sensitive fields? (defaults to False) + """ + # FIXME: this may not work for hashes with non-standard settings. + # XXX: how should this handle checksum/salt encoding? + # need to work that out for encrypt anyways. + self = cls.from_string(hash) + # XXX: could split next few lines out as self._parsehash() for subclassing + # XXX: could try to resolve ident/variant to publically suitable alias. + UNSET = object() + kwds = dict((key, getattr(self, key)) for key in self._parsed_settings + if getattr(self, key) != getattr(cls, key, UNSET)) + if checksum and self.checksum is not None: + kwds['checksum'] = self.checksum + if sanitize: + if sanitize is True: + sanitize = cls._sanitize + for key in cls._unsafe_settings: + if key in kwds: + kwds[key] = sanitize(kwds[key]) + return kwds @classmethod def bitsize(cls, **kwds): -- cgit v1.2.1