summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2012-04-27 02:41:59 -0400
committerEli Collins <elic@assurancetechnologies.com>2012-04-27 02:41:59 -0400
commitb308b88118d1bb14f1adb5513d7290b25dda1e31 (patch)
treec6dbdcd0ab91fcc6ec1c319798fb9448bd7badd1
parent7a0d65a5a6d61a976daf311fec63171df49ecb37 (diff)
downloadpasslib-b308b88118d1bb14f1adb5513d7290b25dda1e31.tar.gz
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
-rw-r--r--CHANGES4
-rw-r--r--docs/conf.py1
-rw-r--r--docs/contents.rst7
-rw-r--r--docs/lib/passlib.ext.django.rst230
-rw-r--r--passlib/ext/django/__init__.py11
-rw-r--r--passlib/ext/django/models.py303
-rw-r--r--passlib/ext/django/utils.py665
-rw-r--r--passlib/handlers/digests.py1
-rw-r--r--passlib/handlers/django.py6
-rw-r--r--passlib/ifc.py5
-rw-r--r--passlib/tests/test_ext_django.py990
-rw-r--r--passlib/tests/test_handlers.py13
-rw-r--r--passlib/tests/tox_support.py31
-rw-r--r--passlib/utils/compat.py2
-rw-r--r--passlib/utils/handlers.py57
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 <genindex>`
* :ref:`Module List <modindex>`
-
-..
- 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 <http://www.djangoproject.com>`_ plugin which
+overriddes all of Django's password hashing functions, replacing them
+with wrappers around a Passlib :doc:`CryptContext <passlib.context>` 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 <django-1.4-hashes>`,
+ even under earlier Django releases.
-.. todo::
+* Allow your application to work with any password hash format
+ :doc:`supported </lib/passlib.hash>` 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 <http://www.djangoproject.com>`_-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 <passlib.context>` 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 </lib/passlib.hash.django_std>`.
+ * 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 <path> to have <value>, 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 <parent>"
+ 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='<preset>'"
+ # 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):