summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2016-02-09 11:39:09 -0500
committerEli Collins <elic@assurancetechnologies.com>2016-02-09 11:39:09 -0500
commit79441fd7909b3d158e6432a28791c6689e00a43a (patch)
treeb5e276405a60bb60a5634bb696da45ccecec9b41
parentb57e3887ba030a163ee1c98024218dda27b635a1 (diff)
parentbd44009b79cd2c0c2a232d9f0c7fef7e2521f41a (diff)
downloadpasslib-79441fd7909b3d158e6432a28791c6689e00a43a.tar.gz
Merge with stable
-rw-r--r--CHANGES12
-rw-r--r--docs/lib/passlib.context.rst1
-rw-r--r--docs/password_hash_api.rst2
-rw-r--r--passlib/context.py62
-rw-r--r--passlib/ext/django/models.py29
-rw-r--r--passlib/ext/django/utils.py170
-rw-r--r--passlib/ifc.py10
-rw-r--r--passlib/tests/test_context.py70
-rw-r--r--passlib/tests/test_ext_django.py79
-rw-r--r--passlib/tests/test_handlers_django.py10
-rw-r--r--passlib/tests/utils.py50
-rw-r--r--passlib/utils/handlers.py40
-rw-r--r--tox.ini8
13 files changed, 488 insertions, 55 deletions
diff --git a/CHANGES b/CHANGES
index 921961b..97a788d 100644
--- a/CHANGES
+++ b/CHANGES
@@ -33,6 +33,8 @@ Release History
* FIXME: Default using() method won't work correctly for wrapper handlers just yet.
+ * Remove Django 1.6/1.7 support, bumping minimum to 1.8
+
Requirements
------------
@@ -113,6 +115,16 @@ Todo
* Thread safety audit and tests for CryptContext, HasManyBackends, and lazy-init subclasses.
+**1.6.6** (NOT YET RELEASED)
+============================
+
+ * :class:`~passlib.CryptContext` instances now pass contextual keywords (such as `"user"`)
+ to the hashes that support them, but ignores them for hashes that don't. This should
+ fix :issue:`63`.
+
+ * :mod:`passlib.ext.django` and unittests: compatibility fixes for Django 1.9,
+ and some internal cleanups (fixes :issue:`68`).
+
**1.6.5** (2015-08-04)
======================
diff --git a/docs/lib/passlib.context.rst b/docs/lib/passlib.context.rst
index 4043aac..cb61f7b 100644
--- a/docs/lib/passlib.context.rst
+++ b/docs/lib/passlib.context.rst
@@ -412,6 +412,7 @@ current configuration:
.. automethod:: CryptContext.schemes
.. automethod:: CryptContext.default_scheme
.. automethod:: CryptContext.handler
+.. autoattribute:: CryptContext.context_kwds
.. rst-class:: html-toggle expanded
diff --git a/docs/password_hash_api.rst b/docs/password_hash_api.rst
index 4e78932..8c04965 100644
--- a/docs/password_hash_api.rst
+++ b/docs/password_hash_api.rst
@@ -532,6 +532,8 @@ the hashes in passlib:
.. versionadded:: 1.6
+.. _context-keywords:
+
.. attribute:: PasswordHash.context_kwds
Tuple listing the keywords supported by :meth:`~PasswordHash.encrypt`,
diff --git a/passlib/context.py b/passlib/context.py
index e3b2153..3c05e62 100644
--- a/passlib/context.py
+++ b/passlib/context.py
@@ -696,6 +696,9 @@ class _CryptConfig(object):
# tuple of categories in alphabetical order (not including None)
categories = None
+ # set of all context keywords used by active schemes
+ context_kwds = None
+
# dict mapping category -> default scheme
_default_schemes = None
@@ -1016,10 +1019,12 @@ class _CryptConfig(object):
# so CryptContext throws error immediately rather than later.
self._record_lists = {}
records = self._records = {}
+ context_kwds = self.context_kwds = set()
get_options = self._get_record_options_with_flag
categories = self.categories
for handler in self.handlers:
scheme = handler.name
+ context_kwds.update(handler.context_kwds)
kwds, _ = get_options(scheme, None)
records[scheme, None] = _CryptRecord(handler, **kwds)
for cat in categories:
@@ -1592,6 +1597,12 @@ class CryptContext(object):
self._config = config
self._get_record = config.get_record
self._identify_record = config.identify_record
+ if config.context_kwds:
+ # (re-)enable method for this instance (in case ELSE clause below ran last load).
+ self.__dict__.pop("_strip_unused_context_kwds", None)
+ else:
+ # disable method for this instance, it's not needed.
+ self._strip_unused_context_kwds = None
@staticmethod
def _parse_config_key(ckey):
@@ -1799,6 +1810,16 @@ class CryptContext(object):
return tuple(handler for handler in self._config.handlers
if not _is_handler_registered(handler))
+ @property
+ def context_kwds(self):
+ """
+ return :class:`!set` containing union of all :ref:`contextual keywords <context-keywords>`
+ supported by the handlers in this context.
+
+ .. versionadded:: 1.6.6
+ """
+ return self._config.context_kwds
+
#===================================================================
# exporting config
#===================================================================
@@ -1960,6 +1981,22 @@ class CryptContext(object):
# hash typecheck handled by identify_record()
return self._identify_record(hash, category)
+ def _strip_unused_context_kwds(self, kwds, record):
+ """
+ helper which removes any context keywords from **kwds**
+ that are known to be used by another scheme in this context,
+ but are NOT supported by handler specified by **record**.
+
+ .. note::
+ as optimization, load() will set this method to None on a per-instance basis
+ if there are no context kwds.
+ """
+ if not kwds:
+ return
+ unused_kwds = self._config.context_kwds.difference(record.handler.context_kwds)
+ for key in unused_kwds:
+ kwds.pop(key, None)
+
def needs_update(self, hash, scheme=None, category=None, secret=None):
"""Check if hash needs to be replaced for some reason,
in which case the secret should be re-hashed.
@@ -2113,7 +2150,11 @@ class CryptContext(object):
method throws an error based on *secret* or the provided *kwds*.
"""
# XXX: could insert normalization to preferred unicode encoding here
- return self._get_record(scheme, category).genhash(secret, config, **kwds)
+ record = self._get_record(scheme, category)
+ strip_unused = self._strip_unused_context_kwds
+ if strip_unused:
+ strip_unused(kwds, record)
+ return record.genhash(secret, config, **kwds)
def identify(self, hash, category=None, resolve=False, required=False):
"""Attempt to identify which algorithm the hash belongs to.
@@ -2196,7 +2237,11 @@ class CryptContext(object):
.. seealso:: the :ref:`context-basic-example` example in the tutorial
"""
# XXX: could insert normalization to preferred unicode encoding here
- return self._get_record(scheme, category).encrypt(secret, **kwds)
+ record = self._get_record(scheme, category)
+ strip_unused = self._strip_unused_context_kwds
+ if strip_unused:
+ strip_unused(kwds, record)
+ return record.encrypt(secret, **kwds)
def verify(self, secret, hash, scheme=None, category=None, **kwds):
"""verify secret against an existing hash.
@@ -2249,10 +2294,12 @@ class CryptContext(object):
.. seealso:: the :ref:`context-basic-example` example in the tutorial
"""
- # XXX: have record strip context kwds if scheme doesn't use them?
# XXX: could insert normalization to preferred unicode encoding here
# XXX: what about supporting a setter() callback ala django 1.4 ?
record = self._get_or_identify_record(hash, scheme, category)
+ strip_unused = self._strip_unused_context_kwds
+ if strip_unused:
+ strip_unused(kwds, record)
return record.verify(secret, hash, **kwds)
def verify_and_update(self, secret, hash, scheme=None, category=None, **kwds):
@@ -2312,10 +2359,15 @@ class CryptContext(object):
.. seealso:: the :ref:`context-migration-example` example in the tutorial.
"""
- # XXX: have record strip context kwds if scheme doesn't use them?
# XXX: could insert normalization to preferred unicode encoding here.
record = self._get_or_identify_record(hash, scheme, category)
- if not record.verify(secret, hash, **kwds):
+ strip_unused = self._strip_unused_context_kwds
+ if strip_unused and kwds:
+ clean_kwds = kwds.copy()
+ strip_unused(clean_kwds, record)
+ else:
+ clean_kwds = kwds
+ if not record.verify(secret, hash, **clean_kwds):
return False, None
elif record.needs_update(hash, secret=secret):
# NOTE: we re-encrypt with default scheme, not current one.
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index 9ef4188..e60a9cf 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -113,8 +113,18 @@ def _apply_patch():
if password is None 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)
+ if ok and setter:
+ # django's check_password() won't call setter() on a legacy alg if it's explicitly
+ # chosen via 'preferred' kwd (unless alg itself says hash needs updating, of course).
+ # as a hack to replicate this behavior, the following makes a temp copy of the
+ # active context, to ensure the preferred scheme isn't deprecated.
+ test_context = password_context
+ if preferred != "default":
+ scheme = hasher_to_passlib_name(preferred)
+ if password_context._is_deprecated_scheme(scheme):
+ test_context = password_context.copy(default=scheme)
+ if test_context.needs_update(encoded):
+ setter(password)
return ok
#
@@ -145,6 +155,21 @@ def _apply_patch():
kwds['salt'] = salt
return password_context.encrypt(password, **kwds)
+ if VERSION >= (1, 8):
+ from django.utils import lru_cache
+ from passlib.utils.compat import lmap
+
+ @_manager.monkeypatch(HASHERS_PATH)
+ @lru_cache.lru_cache()
+ def get_hashers():
+ """passlib replacement for get_hashers()"""
+ return lmap(get_passlib_hasher, password_context.schemes(resolve=True))
+
+ # NOTE: leaving get_hashers_by_algorithm() unpatched, since it just
+ # proxies get_hashers(). but we do want to wipe it's cache...
+ from django.contrib.auth.hashers import reset_hashers
+ reset_hashers(setting="PASSWORD_HASHERS")
+
@_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(FORMS_PATH)
def get_hasher(algorithm="default"):
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 8e70a51..511427c 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -18,8 +18,8 @@ except ImportError:
from passlib.context import CryptContext
from passlib.exc import PasslibRuntimeWarning
from passlib.registry import get_crypt_handler, list_crypt_handlers
-from passlib.utils import classproperty
-from passlib.utils.compat import get_method_function, iteritems, OrderedDict
+from passlib.utils import memoized_property
+from passlib.utils.compat import get_method_function, iteritems, OrderedDict, native_string_types
# local
__all__ = [
"DJANGO_VERSION",
@@ -153,18 +153,99 @@ def hasher_to_passlib_name(hasher_name):
#=============================================================================
_GEN_SALT_SIGNAL = "--!!!generate-new-salt!!!--"
-class _HasherWrapper(object):
- """helper for wrapping passlib handlers in Hasher-compatible class."""
+class ProxyProperty(object):
+ """helper that proxies another attribute"""
- # filled in by subclass, drives the other methods.
- passlib_handler = None
- iterations = None
+ def __init__(self, attr):
+ self.attr = attr
+
+ def __get__(self, obj, cls):
+ if obj is None:
+ cls = obj
+ return getattr(obj, self.attr)
+
+ def __set__(self, obj, value):
+ setattr(obj, self.attr, value)
- @classproperty
- def algorithm(cls):
- assert not hasattr(cls.passlib_handler, "django_name")
- return PASSLIB_HASHER_PREFIX + cls.passlib_handler.name
+ def __delete__(self, obj):
+ delattr(obj, self.attr)
+
+class _PasslibHasherWrapper(object):
+ """
+ adapter which which wraps a :cls:`passlib.ifc.PasswordHash` class,
+ and provides an interface compatible with the Django hasher API.
+ :param passlib_handler:
+ passlib hash handler (e.g. :cls:`passlib.hash.sha256_crypt`.
+ """
+
+ #=====================================================================
+ # instance attrs
+ #=====================================================================
+
+ #: passlib handler that we're adapting.
+ passlib_handler = None
+
+ # NOTE: 'rounds' attr will store variable rounds, IF handler supports it.
+ # 'iterations' will act as proxy, for compatibility with django pbkdf2 hashers.
+ # rounds = None
+ # iterations = None
+
+ #=====================================================================
+ # init
+ #=====================================================================
+ def __init__(self, passlib_handler):
+ # init handler
+ assert not hasattr(passlib_handler, "django_name"), \
+ "bug in get_passlib_hasher() -- handlers that reflect an official django hasher " \
+ "should be used directly"
+ self.passlib_handler = passlib_handler
+
+ # init rounds support
+ if self._has_rounds:
+ self.rounds = passlib_handler.default_rounds
+ self.iterations = ProxyProperty("rounds")
+
+ #=====================================================================
+ # internal methods
+ #=====================================================================
+ def __repr__(self):
+ return "<PasslibHasherWrapper handler=%r>" % self.passlib_handler
+
+ #=====================================================================
+ # internal properties
+ #=====================================================================
+
+ @memoized_property
+ def __name__(self):
+ return "Passlib_%s_PasswordHasher" % self.passlib_handler.name.title()
+
+ @memoized_property
+ def _has_rounds(self):
+ return "rounds" in self.passlib_handler.setting_kwds
+
+ @memoized_property
+ def _translate_kwds(self):
+ """
+ internal helper for safe_summary() --
+ used to translate passlib hash options -> django keywords
+ """
+ out = dict(checksum="hash")
+ if self._has_rounds and "pbkdf2" in self.passlib_handler.name:
+ out['rounds'] = 'iterations'
+ return out
+
+ #=====================================================================
+ # hasher properties
+ #=====================================================================
+
+ @memoized_property
+ def algorithm(self):
+ return PASSLIB_HASHER_PREFIX + self.passlib_handler.name
+
+ #=====================================================================
+ # hasher api
+ #=====================================================================
def salt(self):
# NOTE: passlib's handler.encrypt() should generate new salt each time,
# so this just returns a special constant which tells
@@ -174,18 +255,21 @@ class _HasherWrapper(object):
def verify(self, password, encoded):
return self.passlib_handler.verify(password, encoded)
- def encode(self, password, salt=None, iterations=None):
+ def encode(self, password, salt=None, rounds=None, iterations=None):
kwds = {}
if salt is not None and salt != _GEN_SALT_SIGNAL:
kwds['salt'] = salt
- if iterations is not None:
- kwds['rounds'] = iterations
- elif self.iterations is not None:
- kwds['rounds'] = self.iterations
+ if self._has_rounds:
+ if rounds is not None:
+ kwds['rounds'] = rounds
+ elif iterations is not None:
+ kwds['rounds'] = iterations
+ else:
+ kwds['rounds'] = self.rounds
+ elif rounds is not None or iterations is not None:
+ warn("%s.encrypt(): 'rounds' and 'iterations' are ignored" % self.__name__)
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
from django.utils.translation import ugettext_noop as _
@@ -204,15 +288,32 @@ class _HasherWrapper(object):
# added in django 1.6
def must_update(self, encoded):
- # TODO: would like to do something useful here,
- # but would require access to password context,
- # which would mean a serious recoding of this ext.
+ # TODO: would like access CryptContext, would need caller to pass it to get_passlib_hasher().
+ # for now (as of passlib 1.6.6), replicating django policy that this returns True
+ # if 'encoded' hash has different rounds value from self.rounds
+ if self._has_rounds:
+ handler = self.passlib_handler
+ if hasattr(handler, "parse_rounds"):
+ rounds = handler.parse_rounds(encoded)
+ if rounds != self.rounds:
+ return True
+ # TODO: for passlib 1.7, could check .needs_update() method.
+ # could also have this whole class create a handler subclass,
+ # which we can proxy the .rounds attr for. this would allow
+ # replacing entirety of the (above) rounds check
return False
+ #=====================================================================
+ # eoc
+ #=====================================================================
+
+#: legacy alias for < 1.6.6
+_HasherWrapper = _PasslibHasherWrapper
+
# cache of hasher wrappers generated by get_passlib_hasher()
_hasher_cache = WeakKeyDictionary()
-def get_passlib_hasher(handler, algorithm=None):
+def get_passlib_hasher(handler, algorithm=None, native_only=False):
"""create *Hasher*-compatible wrapper for specified passlib hash.
This takes in the name of a passlib hash (or the handler object itself),
@@ -226,7 +327,7 @@ def get_passlib_hasher(handler, algorithm=None):
so will probably not be compatible with Django's algorithm format,
so the monkeypatch provided by this plugin must have been applied.
"""
- if isinstance(handler, str):
+ if isinstance(handler, native_string_types):
handler = get_crypt_handler(handler)
if hasattr(handler, "django_name"):
# return native hasher instance
@@ -238,14 +339,15 @@ def get_passlib_hasher(handler, algorithm=None):
# we want to resolve to correct django hasher.
name = algorithm
return _get_hasher(name)
+ if native_only:
+ # caller doesn't want any wrapped hashers.
+ return None
if handler.name == "django_disabled":
raise ValueError("can't wrap unusable-password handler")
try:
return _hasher_cache[handler]
except KeyError:
- name = "Passlib_%s_PasswordHasher" % handler.name.title()
- cls = type(name, (_HasherWrapper,), dict(passlib_handler=handler))
- hasher = _hasher_cache[handler] = cls()
+ hasher = _hasher_cache[handler] = _PasslibHasherWrapper(handler)
return hasher
def _get_hasher(algorithm):
@@ -255,11 +357,23 @@ def _get_hasher(algorithm):
if module is None:
# we haven't patched django, so just import directly
from django.contrib.auth.hashers import get_hasher
- else:
+ return get_hasher(algorithm)
+ elif DJANGO_VERSION < (1,8):
+ # django < 1.8
# we've patched django, so have to use patch manager to retrieve
# original get_hasher() function...
get_hasher = module._manager.getorig("django.contrib.auth.hashers:get_hasher")
- return get_hasher(algorithm)
+ return get_hasher(algorithm)
+ else:
+ # django >= 1.8
+ # we've patched django, but patched at get_hashers() level...
+ # calling original get_hasher() would only land us back here via patched get_hashers().
+ # as non-ideal workaround, have to use original get_hashers()
+ get_hashers = module._manager.getorig("django.contrib.auth.hashers:get_hashers")
+ for hasher in get_hashers():
+ if hasher.algorithm == algorithm:
+ return hasher
+ raise ValueError("unknown hasher: %r" % algorithm)
#=============================================================================
# adapting django hashers -> passlib handlers
diff --git a/passlib/ifc.py b/passlib/ifc.py
index 58931ab..387a1e3 100644
--- a/passlib/ifc.py
+++ b/passlib/ifc.py
@@ -197,6 +197,16 @@ class PasswordHash(object):
## components currently include checksum, salt, rounds.
## """
+ # temporary helper used by _CryptRecord to check if hash needs updating
+ # due to rounds boundary. only present if hash supports rounds.
+ # added in 1.6.6, but will be removed in 1.7, as the _CryptRecord internals have
+ # already been refactored in a way that this is no longer required.
+ ##@classmethod
+ ##def parse_rounds(cls, hash):
+ ## """
+ ## returns number of rounds configured for hash.
+ ## """
+
#===================================================================
# eoc
#===================================================================
diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py
index 5dab88e..b6c649f 100644
--- a/passlib/tests/test_context.py
+++ b/passlib/tests/test_context.py
@@ -1209,6 +1209,76 @@ sha512_crypt__min_rounds = 45000
# bad category values
self.assertRaises(TypeError, cc.verify_and_update, 'secret', refhash, category=1)
+ def test_48_context_kwds(self):
+ """encrypt(), verify(), and verify_and_update() -- discard unused context keywords"""
+
+ # setup test case
+ # NOTE: postgres_md5 hash supports 'user' context kwd, which is used for this test.
+ from passlib.hash import des_crypt, md5_crypt, postgres_md5
+ des_hash = des_crypt.encrypt("stub")
+ pg_root_hash = postgres_md5.encrypt("stub", user="root")
+ pg_admin_hash = postgres_md5.encrypt("stub", user="admin")
+
+ #------------------------------------------------------------
+ # case 1: contextual kwds not supported by any hash in CryptContext
+ #------------------------------------------------------------
+ cc1 = CryptContext([des_crypt, md5_crypt])
+ self.assertEqual(cc1.context_kwds, set())
+
+ # des_scrypt should work w/o any contextual kwds
+ self.assertTrue(des_crypt.identify(cc1.encrypt("stub")), "des_crypt")
+ self.assertTrue(cc1.verify("stub", des_hash))
+ self.assertEqual(cc1.verify_and_update("stub", des_hash), (True, None))
+
+ # des_crypt should throw error due to unknown context keyword
+ self.assertRaises(TypeError, cc1.encrypt, "stub", user="root")
+ self.assertRaises(TypeError, cc1.verify, "stub", des_hash, user="root")
+ self.assertRaises(TypeError, cc1.verify_and_update, "stub", des_hash, user="root")
+
+ #------------------------------------------------------------
+ # case 2: at least one contextual kwd supported by non-default hash
+ #------------------------------------------------------------
+ cc2 = CryptContext([des_crypt, postgres_md5])
+ self.assertEqual(cc2.context_kwds, set(["user"]))
+
+ # verify des_crypt works w/o "user" kwd
+ self.assertTrue(des_crypt.identify(cc2.encrypt("stub")), "des_crypt")
+ self.assertTrue(cc2.verify("stub", des_hash))
+ self.assertEqual(cc2.verify_and_update("stub", des_hash), (True, None))
+
+ # verify des_crypt ignores "user" kwd
+ self.assertTrue(des_crypt.identify(cc2.encrypt("stub", user="root")), "des_crypt")
+ self.assertTrue(cc2.verify("stub", des_hash, user="root"))
+ self.assertEqual(cc2.verify_and_update("stub", des_hash, user="root"), (True, None))
+
+ # verify error with unknown kwd
+ self.assertRaises(TypeError, cc2.encrypt, "stub", badkwd="root")
+ self.assertRaises(TypeError, cc2.verify, "stub", des_hash, badkwd="root")
+ self.assertRaises(TypeError, cc2.verify_and_update, "stub", des_hash, badkwd="root")
+
+ #------------------------------------------------------------
+ # case 3: at least one contextual kwd supported by default hash
+ #------------------------------------------------------------
+ cc3 = CryptContext([postgres_md5, des_crypt], deprecated="auto")
+ self.assertEqual(cc3.context_kwds, set(["user"]))
+
+ # postgres_md5 should have error w/o context kwd
+ self.assertRaises(TypeError, cc3.encrypt, "stub")
+ self.assertRaises(TypeError, cc3.verify, "stub", pg_root_hash)
+ self.assertRaises(TypeError, cc3.verify_and_update, "stub", pg_root_hash)
+
+ # postgres_md5 should work w/ context kwd
+ self.assertEqual(cc3.encrypt("stub", user="root"), pg_root_hash)
+ self.assertTrue(cc3.verify("stub", pg_root_hash, user="root"))
+ self.assertEqual(cc3.verify_and_update("stub", pg_root_hash, user="root"), (True, None))
+
+ # verify_and_update() should fail against wrong user
+ self.assertEqual(cc3.verify_and_update("stub", pg_root_hash, user="admin"), (False, None))
+
+ # verify_and_update() should pass all context kwds through when rehashing
+ self.assertEqual(cc3.verify_and_update("stub", des_hash, user="root"),
+ (True, pg_root_hash))
+
#===================================================================
# rounds options
#===================================================================
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
index 9d0c3e3..1666538 100644
--- a/passlib/tests/test_ext_django.py
+++ b/passlib/tests/test_ext_django.py
@@ -30,7 +30,14 @@ from passlib.ext.django.utils import DJANGO_VERSION, MIN_DJANGO_VERSION
has_min_django = DJANGO_VERSION >= MIN_DJANGO_VERSION
# import and configure empty django settings
+# NOTE: we don't want to set up entirety of django, so not using django.setup() directly.
+# instead, manually configuring the settings, and setting it up w/ no apps installed.
+# in future, may need to alter this so we call django.setup() after setting
+# DJANGO_SETTINGS_MODULE to a custom settings module w/ a dummy django app.
if has_min_django:
+ #
+ # initialize django settings manually
+ #
from django.conf import settings, LazySettings
if not isinstance(settings, LazySettings):
@@ -42,6 +49,14 @@ if has_min_django:
if not settings.configured:
settings.configure()
+ #
+ # init django apps w/ NO installed apps.
+ # NOTE: required for django >= 1.9, not compatible with django <= 1.6
+ #
+ if DJANGO_VERSION >= (1,7):
+ from django.apps import apps
+ apps.populate(["django.contrib.contenttypes", "django.contrib.auth"])
+
#=============================================================================
# support funcs
#=============================================================================
@@ -65,6 +80,10 @@ if has_min_django:
"""mock user object for use in testing"""
# NOTE: this mainly just overrides .save() to test commit behavior.
+ # NOTE: .Meta.app_label required for django >= 1.9, ignored for <= 1.6
+ class Meta:
+ app_label = __name__
+
@memoized_property
def saved_passwords(self):
return []
@@ -97,7 +116,9 @@ def create_mock_setter():
# build config dict that matches stock django
# TODO: move these to passlib.apps
-if DJANGO_VERSION >= (1, 8):
+if DJANGO_VERSION >= (1, 9):
+ stock_rounds = 24000
+elif DJANGO_VERSION >= (1, 8):
stock_rounds = 20000
elif DJANGO_VERSION >= (1, 7):
stock_rounds = 15000
@@ -141,9 +162,12 @@ class _ExtensionSupport(object):
from django.contrib.auth import models, hashers
user_attrs = ["check_password", "set_password"]
model_attrs = ["check_password", "make_password"]
+ hasher_attrs = ["check_password", "make_password", "get_hasher", "identify_hasher"]
+ if DJANGO_VERSION >= (1,8):
+ hasher_attrs.extend(["get_hashers"])
objs = [(models, model_attrs),
(models.User, user_attrs),
- (hashers, ["check_password", "make_password", "get_hasher", "identify_hasher"]),
+ (hashers, hasher_attrs),
]
for obj, patched in objs:
for attr in dir(obj):
@@ -669,13 +693,13 @@ class DjangoExtensionTest(_ExtensionTest):
self.assertFalse(hasher.verify("xxxx", encoded))
# test wrapper accepts options
- encoded = hasher.encode("stub", "abcd"*4, iterations=1234)
+ encoded = hasher.encode("stub", "abcd"*4, rounds=1234)
self.assertEqual(encoded, "$5$rounds=1234$abcdabcdabcdabcd$"
"v2RWkZQzctPdejyRqmmTDQpZN6wTh7.RUy9zF2LftT6")
self.assertEqual(hasher.safe_summary(encoded),
{'algorithm': 'sha256_crypt',
'salt': u('abcdab**********'),
- 'iterations': 1234,
+ 'rounds': 1234,
'hash': u('v2RWkZ*************************************'),
})
@@ -805,6 +829,14 @@ class ContextWithHook(CryptContext):
self.update_hook(self)
return super(ContextWithHook, self).verify(*args, **kwds)
+ def needs_update(self, *args, **kwds):
+ self.update_hook(self)
+ return super(ContextWithHook, self).needs_update(*args, **kwds)
+
+ def verify_and_update(self, *args, **kwds):
+ self.update_hook(self)
+ return super(ContextWithHook, self).verify_and_update(*args, **kwds)
+
#=============================================================================
# HashersTest --
# hack up the some of the real django tests to run w/ extension loaded,
@@ -867,12 +899,18 @@ if test_hashers_mod:
patchAttr = get_unbound_method_function(TestCase.patchAttr)
def setUp(self):
+ #
+ # install passlib.ext.django monkeypatches
# NOTE: omitted orig setup, want to install our extension,
# and load hashers through it instead.
+ #
self.load_extension(PASSLIB_CONTEXT=stock_config, check=False)
from passlib.ext.django.models import password_context
+ from passlib.ext.django.utils import get_passlib_hasher
+ #
# update test module to use our versions of some hasher funcs
+ #
from django.contrib.auth import hashers
for attr in ["make_password",
"check_password",
@@ -880,22 +918,37 @@ if test_hashers_mod:
"get_hasher"]:
self.patchAttr(test_hashers_mod, attr, getattr(hashers, attr))
- # django tests expect empty django_des_crypt salt field
+ #
+ # django 1.4 tests expect empty django_des_crypt salt field
+ #
from passlib.hash import django_des_crypt
self.patchAttr(django_des_crypt, "use_duplicate_salt", False)
+ #
# hack: need password_context to keep up to date with hasher.iterations
- def update_hook(self):
- rounds = test_hashers_mod.get_hasher("pbkdf2_sha256").iterations
- self.update(
- django_pbkdf2_sha256__min_rounds=rounds,
- django_pbkdf2_sha256__default_rounds=rounds,
- django_pbkdf2_sha256__max_rounds=rounds,
- )
+ #
+ def update_hook(context):
+ """called to sync hasher.rounds to crypt context before any operations"""
+ for handler in context.schemes(resolve=True):
+ if 'rounds' not in handler.setting_kwds:
+ continue
+ hasher = get_passlib_hasher(handler, native_only=True)
+ if not hasher:
+ continue
+ rounds = getattr(hasher, "rounds", None) or \
+ getattr(hasher, "iterations", None)
+ if rounds is None:
+ continue
+ prefix = handler.name + "__"
+ context.update({prefix + "default_rounds": rounds,
+ prefix + "min_rounds": rounds,
+ prefix + "max_rounds": rounds})
+
+ self.password_context = password_context
self.patchAttr(password_context, "__class__", ContextWithHook)
self.patchAttr(password_context, "update_hook", update_hook)
- # omitting this test, since it depends on updated to django hasher settings
+ # NOTE: omitting this test, since tries to reinitialize the hashers on top of our patch.
test_pbkdf2_upgrade_new_hasher = lambda self: self.skipTest("omitted by passlib")
def tearDown(self):
diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py
index 4d91b72..95c546a 100644
--- a/passlib/tests/test_handlers_django.py
+++ b/passlib/tests/test_handlers_django.py
@@ -41,7 +41,10 @@ class _DjangoHelper(object):
min_django_version = max(self.min_django_version, (1,0))
if DJANGO_VERSION < min_django_version:
return None
- from django.contrib.auth.models import check_password
+ try:
+ from django.contrib.auth.hashers import check_password
+ except ImportError: # legacy location - required < 1.4, removed 1.9
+ from django.contrib.auth.models import check_password
def verify_django(secret, hash):
"""django/check_password"""
if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"):
@@ -61,7 +64,10 @@ class _DjangoHelper(object):
min_django_version = max(self.min_django_version, (1,0))
if DJANGO_VERSION < min_django_version:
raise self.skipTest("Django >= %s not installed" % vstr(min_django_version))
- from django.contrib.auth.models import check_password
+ try:
+ from django.contrib.auth.hashers import check_password
+ except ImportError: # legacy location - required < 1.4, removed 1.9
+ from django.contrib.auth.models import check_password
assert self.known_correct_hashes
for secret, hash in self.iter_known_hashes():
self.assertTrue(check_password(secret, hash),
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index eeea4e9..5acb0f5 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -5,6 +5,7 @@
from __future__ import with_statement
# core
import logging; log = logging.getLogger(__name__)
+import random
import re
import os
import sys
@@ -1274,6 +1275,55 @@ class HandlerCase(TestCase):
# TODO: check relaxed mode clips max+1
+ def _get_rand_rounds(self):
+ handler = self.handler
+ min_rounds = handler.min_rounds
+ upper = (min_rounds or 1) * 2
+ max_rounds = handler.max_rounds
+ if max_rounds is not None and max_rounds < upper:
+ upper = max_rounds
+ rounds = random.randint(min_rounds, upper)
+ if getattr(handler, "_avoid_even_rounds", False):
+ rounds |= 1
+ return rounds
+
+ def test_22_parse_rounds(self):
+ """test parse_rounds() helper [will be removed in 1.7]"""
+ self.require_rounds_info()
+ handler = self.handler
+ for _ in range(5):
+ rounds = self._get_rand_rounds()
+ hash = self.do_encrypt("letmein", rounds=rounds)
+ self.assertEqual(handler.parse_rounds(hash), rounds)
+
+ def test_23_rounds_and_context_needs_update(self):
+ """test rounds + context.needs_update() integration"""
+ self.require_rounds_info()
+ from passlib.context import CryptContext
+ handler = self.handler
+
+ # pick two different rounds values
+ rounds1 = rounds2 = self._get_rand_rounds()
+ while rounds1 == rounds2:
+ rounds2 = self._get_rand_rounds()
+
+ # setup context which considers everything but rounds1 to need updating.
+ prefix = handler.name + "__"
+ context = CryptContext(**{
+ "schemes": [handler],
+ (prefix + "default_rounds"): rounds1,
+ (prefix + "min_rounds"): rounds1,
+ (prefix + "max_rounds"): rounds1,
+ })
+
+ # rounds1 hash should be fine
+ hash = self.do_encrypt("letmein", rounds=rounds1)
+ self.assertFalse(context.needs_update(hash))
+
+ # rounds2 hash should need updating
+ hash = self.do_encrypt("letmein", rounds=rounds2)
+ self.assertTrue(context.needs_update(hash))
+
def test_has_rounds_using_limits(self):
"""
HasRounds.using() -- desired rounds limits & defaults
diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py
index bcaaf8d..c413c70 100644
--- a/passlib/utils/handlers.py
+++ b/passlib/utils/handlers.py
@@ -1616,6 +1616,24 @@ class HasRounds(GenericHandler):
# the time-cost, and can't be randomized.
return info
+ @classmethod
+ def parse_rounds(cls, hash):
+ """
+ [experimental method] returns rounds value from hash
+
+ .. warning::
+
+ this is needed so :class:`passlib.context._CryptRecord` can reliably figure out
+ the rounds being used by a given hash, even if the handler is a PrefixWrapper.
+
+ added in 1.6.6, this is just a stopgap until 1.7 is released, and the whole
+ needs_update() framework has been revamped so _CryptRecord doesn't need to poke
+ into handler internals anymore.
+
+ this method will be removed in 1.7.
+ """
+ return cls.from_string(hash).rounds
+
#===================================================================
# eoc
#===================================================================
@@ -1942,8 +1960,7 @@ class PrefixWrapper(object):
if doc:
self.__doc__ = doc
if hasattr(wrapped, "name"):
- self._check_handler(wrapped)
- self._wrapped_handler = wrapped
+ self._set_wrapped(wrapped)
else:
self._wrapped_name = wrapped
if not lazy:
@@ -1966,19 +1983,26 @@ class PrefixWrapper(object):
_wrapped_name = None
_wrapped_handler = None
- def _check_handler(self, handler):
+ def _set_wrapped(self, handler):
+ # check this is a valid handler
if 'ident' in handler.setting_kwds and self.orig_prefix:
# TODO: look into way to fix the issues.
warn("PrefixWrapper: 'orig_prefix' option may not work correctly "
"for handlers which have multiple identifiers: %r" %
(handler.name,), exc.PasslibRuntimeWarning)
+ # store reference
+ self._wrapped_handler = handler
+
+ # init parse_rounds() proxy if applicable
+ if hasattr(handler, "parse_rounds"):
+ self.parse_rounds = self.__parse_rounds
+
def _get_wrapped(self):
handler = self._wrapped_handler
if handler is None:
handler = get_crypt_handler(self._wrapped_name)
- self._check_handler(handler)
- self._wrapped_handler = handler
+ self._set_wrapped(handler)
return handler
wrapped = property(_get_wrapped)
@@ -2118,6 +2142,12 @@ class PrefixWrapper(object):
hash = self._unwrap_hash(hash)
return self.wrapped.verify(secret, hash, **kwds)
+ def __parse_rounds(self, hash):
+ """parse_rounds() wrapper - exposed only if wrapped handler supports it"""
+ hash = to_unicode(hash, "ascii", "hash")
+ hash = self._unwrap_hash(hash)
+ return self.wrapped.parse_rounds(hash)
+
#=============================================================================
# eof
#=============================================================================
diff --git a/tox.ini b/tox.ini
index 8669ab3..efc59da 100644
--- a/tox.ini
+++ b/tox.ini
@@ -235,6 +235,14 @@ deps =
commands =
nosetests {posargs:--randomize passlib.tests.test_ext_django passlib.tests.test_handlers_django}
+[testenv:django18-py3]
+basepython = python3
+deps =
+ {[testenv]deps}
+ django<1.9
+commands =
+ nosetests {posargs:--randomize passlib.tests.test_ext_django passlib.tests.test_handlers_django}
+
#---------------------------------------------------------------------
# latest django version
#---------------------------------------------------------------------