diff options
| author | Eli Collins <elic@assurancetechnologies.com> | 2016-02-09 11:39:09 -0500 |
|---|---|---|
| committer | Eli Collins <elic@assurancetechnologies.com> | 2016-02-09 11:39:09 -0500 |
| commit | 79441fd7909b3d158e6432a28791c6689e00a43a (patch) | |
| tree | b5e276405a60bb60a5634bb696da45ccecec9b41 | |
| parent | b57e3887ba030a163ee1c98024218dda27b635a1 (diff) | |
| parent | bd44009b79cd2c0c2a232d9f0c7fef7e2521f41a (diff) | |
| download | passlib-79441fd7909b3d158e6432a28791c6689e00a43a.tar.gz | |
Merge with stable
| -rw-r--r-- | CHANGES | 12 | ||||
| -rw-r--r-- | docs/lib/passlib.context.rst | 1 | ||||
| -rw-r--r-- | docs/password_hash_api.rst | 2 | ||||
| -rw-r--r-- | passlib/context.py | 62 | ||||
| -rw-r--r-- | passlib/ext/django/models.py | 29 | ||||
| -rw-r--r-- | passlib/ext/django/utils.py | 170 | ||||
| -rw-r--r-- | passlib/ifc.py | 10 | ||||
| -rw-r--r-- | passlib/tests/test_context.py | 70 | ||||
| -rw-r--r-- | passlib/tests/test_ext_django.py | 79 | ||||
| -rw-r--r-- | passlib/tests/test_handlers_django.py | 10 | ||||
| -rw-r--r-- | passlib/tests/utils.py | 50 | ||||
| -rw-r--r-- | passlib/utils/handlers.py | 40 | ||||
| -rw-r--r-- | tox.ini | 8 |
13 files changed, 488 insertions, 55 deletions
@@ -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 #============================================================================= @@ -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 #--------------------------------------------------------------------- |
