diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2020-10-08 12:57:55 -0400 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2020-10-08 12:57:55 -0400 |
commit | 969eb2cf0721d8395f06209e16ab52955746aa30 (patch) | |
tree | 0bedc6474f504966bb4eb67b28ba071fe887e391 | |
parent | df3f02025c00a7623c6771c454cb3dfb2c29caa5 (diff) | |
parent | cc21426e0f7a93e5dbd2c66d9752123924a1a4dc (diff) | |
download | passlib-969eb2cf0721d8395f06209e16ab52955746aa30.tar.gz |
Merge from stable
As part of merge:
* various: reverted the py26 compat fixes from rev 5e2f92012412
* test utils: stripped out "has_real_subtest" compat from rev c732a9e2a582,
since now on py35+, which always has .subTest() method
-rw-r--r-- | docs/history/1.7.rst | 27 | ||||
-rw-r--r-- | docs/lib/passlib.ext.django.rst | 20 | ||||
-rw-r--r-- | passlib/apps.py | 64 | ||||
-rw-r--r-- | passlib/context.py | 3 | ||||
-rw-r--r-- | passlib/crypto/digest.py | 7 | ||||
-rw-r--r-- | passlib/exc.py | 14 | ||||
-rw-r--r-- | passlib/ext/django/utils.py | 70 | ||||
-rw-r--r-- | passlib/tests/test_context.py | 32 | ||||
-rw-r--r-- | passlib/tests/test_ext_django.py | 693 | ||||
-rw-r--r-- | passlib/tests/utils.py | 51 | ||||
-rw-r--r-- | tox.ini | 30 |
11 files changed, 710 insertions, 301 deletions
diff --git a/docs/history/1.7.rst b/docs/history/1.7.rst index 64fe51a..8a10f2a 100644 --- a/docs/history/1.7.rst +++ b/docs/history/1.7.rst @@ -10,6 +10,33 @@ Passlib 1.7 and will require Python >= 3.5. The 1.7 series will be the last to support Python 2.7. (See :issue:`119` for rationale). +**1.7.4** (NOT YET RELEASED) +============================ + +Bugfixes +-------- + +* Fixed some Python 2.6 errors from last release (:issue:`128`) + +Other Changes +------------- + +* :mod:`passlib.ext.django` -- updated tests to pass for Django 1.8 - 3.1 (:issue:`98`); + along with some internal refactoring of the test classes. + +* .. py:currentmodule:: passlib.context + + :class:`CryptContext` will now throw :exc:`~passlib.exc.UnknownHashError` when it can't identify + a hash provided to methods such as :meth:`!CryptContext.verify`. + Previously it would throw a generic :exc:`ValueError`. + + +Deprecations +------------ + +* :mod:`passlib.ext.django`: This extension will require Django 2.2 or newer as of Passlib 1.8. + + **1.7.3** (2020-10-06) ====================== diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst index 51097cc..747c5e7 100644 --- a/docs/lib/passlib.ext.django.rst +++ b/docs/lib/passlib.ext.django.rst @@ -6,12 +6,6 @@ :mod:`passlib.ext.django` - Django Password Hashing Plugin ========================================================== -.. versionadded:: 1.6 - -.. versionchanged:: 1.7 - - As of Passlib 1.7, this module requires Django 1.8 or newer. - .. rst-class:: float-center without-title .. warning:: @@ -49,9 +43,19 @@ of uses: 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 1.8 - 1.9. + It has been tested with Django 1.8 - 3.1. + +.. versionadded:: 1.6 + +.. versionchanged:: 1.7 + + Support for Django 1.0 - 1.7 was dropped; now requires Django 1.8 or newer. + +.. rst-class:: without-title + +.. warning:: - (Support for Django 1.0 - 1.7 was dropped after Passlib 1.6). + As of Passlib 1.8, this module will require Django 2.2 or newer. Installation ============= diff --git a/passlib/apps.py b/passlib/apps.py index 38c3500..e8a2555 100644 --- a/passlib/apps.py +++ b/passlib/apps.py @@ -86,9 +86,17 @@ custom_app_context = LazyCryptContext( #============================================================================= # django #============================================================================= + +#----------------------------------------------------------------------- +# 1.0 +#----------------------------------------------------------------------- + _django10_schemes = [ - "django_salted_sha1", "django_salted_md5", "django_des_crypt", - "hex_md5", "django_disabled", + "django_salted_sha1", + "django_salted_md5", + "django_des_crypt", + "hex_md5", + "django_disabled", ] django10_context = LazyCryptContext( @@ -97,28 +105,60 @@ django10_context = LazyCryptContext( deprecated=["hex_md5"], ) -_django14_schemes = ["django_pbkdf2_sha256", "django_pbkdf2_sha1", - "django_bcrypt"] + _django10_schemes +#----------------------------------------------------------------------- +# 1.4 +#----------------------------------------------------------------------- + +_django14_schemes = [ + "django_pbkdf2_sha256", + "django_pbkdf2_sha1", + "django_bcrypt" +] + _django10_schemes + django14_context = LazyCryptContext( schemes=_django14_schemes, deprecated=_django10_schemes, ) -_django16_schemes = _django14_schemes[:] +#----------------------------------------------------------------------- +# 1.6 +#----------------------------------------------------------------------- + +_django16_schemes = list(_django14_schemes) _django16_schemes.insert(1, "django_bcrypt_sha256") django16_context = LazyCryptContext( schemes=_django16_schemes, deprecated=_django10_schemes, ) -django110_context = LazyCryptContext( - schemes=["django_pbkdf2_sha256", "django_pbkdf2_sha1", - "django_argon2", "django_bcrypt", "django_bcrypt_sha256", - "django_disabled"], -) +#----------------------------------------------------------------------- +# 1.10 +#----------------------------------------------------------------------- + +_django_110_schemes = [ + "django_pbkdf2_sha256", + "django_pbkdf2_sha1", + "django_argon2", + "django_bcrypt", + "django_bcrypt_sha256", + "django_disabled", +] +django110_context = LazyCryptContext(schemes=_django_110_schemes) + +#----------------------------------------------------------------------- +# 2.1 +#----------------------------------------------------------------------- + +_django21_schemes = list(_django_110_schemes) +_django21_schemes.remove("django_bcrypt") +django21_context = LazyCryptContext(schemes=_django21_schemes) + +#----------------------------------------------------------------------- +# latest +#----------------------------------------------------------------------- -# this will always point to latest version -django_context = django110_context +# this will always point to latest version in passlib +django_context = django21_context #============================================================================= # ldap diff --git a/passlib/context.py b/passlib/context.py index f210a1f..21865a0 100644 --- a/passlib/context.py +++ b/passlib/context.py @@ -12,6 +12,7 @@ import time from warnings import warn # site # pkg +from passlib import exc from passlib.exc import ExpectedStringError, ExpectedTypeError, PasslibConfigWarning from passlib.registry import get_crypt_handler, _validate_handler_name from passlib.utils import (handlers as uh, to_bytes, @@ -611,7 +612,7 @@ class _CryptConfig(object): elif not self.schemes: raise KeyError("no crypt algorithms supported") else: - raise ValueError("hash could not be identified") + raise exc.UnknownHashError("hash could not be identified") @memoized_property def disabled_record(self): diff --git a/passlib/crypto/digest.py b/passlib/crypto/digest.py index fce6e19..c1b9769 100644 --- a/passlib/crypto/digest.py +++ b/passlib/crypto/digest.py @@ -118,6 +118,11 @@ _fallback_info = { def _gen_fallback_info(): + """ + internal helper used to generate ``_fallback_info`` dict. + currently only run manually to update the above list; + not invoked at runtime. + """ out = {} for alg in sorted(hashlib.algorithms_available | {"md4"}): info = lookup_hash(alg) @@ -467,7 +472,7 @@ class HashInfo(SequenceMixin): helper that installs stub constructor which throws specified error <msg>. """ def const(source=b""): - raise exc.UnknownHashError(name, message=msg) + raise exc.UnknownHashError(msg, name) if required: # if caller only wants supported digests returned, # just throw error immediately... diff --git a/passlib/exc.py b/passlib/exc.py index 262eb12..29ef8af 100644 --- a/passlib/exc.py +++ b/passlib/exc.py @@ -193,17 +193,23 @@ class UnknownHashError(ValueError): As of version 1.7.3, this may also be raised if hash algorithm is known, but has been disabled due to FIPS mode (message will include phrase "disabled for fips"). + As of version 1.7.4, this may be raised if a :class:`~passlib.context.CryptContext` + is unable to identify the algorithm used by a password hash. + .. versionadded:: 1.7 .. versionchanged: 1.7.3 added 'message' argument. + + .. versionchanged:: 1.7.4 + altered call signature. """ - def __init__(self, name, message=None): - self.name = name + def __init__(self, message=None, value=None): + self.value = value if message is None: - message = "unknown hash algorithm: %r" % name + message = "unknown hash algorithm: %r" % value self.message = message - ValueError.__init__(self, name, message) + ValueError.__init__(self, message, value) def __str__(self): return self.message diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index 001df4e..d25a501 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -27,13 +27,29 @@ __all__ = [ "DJANGO_VERSION", "MIN_DJANGO_VERSION", "get_preset_config", - "get_django_hasher", + "quirks", ] #: minimum version supported by passlib.ext.django MIN_DJANGO_VERSION = (1, 8) #============================================================================= +# quirk detection +#============================================================================= + +class quirks: + + #: django check_password() started throwing error on encoded=None + #: (really identify_hasher did) + none_causes_check_password_error = DJANGO_VERSION >= (2, 1) + + #: django is_usable_password() started returning True for password = {None, ""} values. + empty_is_usable_password = DJANGO_VERSION >= (2, 1) + + #: django is_usable_password() started returning True for non-hash strings in 2.1 + invalid_is_usable_password = DJANGO_VERSION >= (2, 1) + +#============================================================================= # default policies #============================================================================= @@ -236,6 +252,13 @@ class DjangoTranslator(object): md5="MD5PasswordHasher", ) + if DJANGO_VERSION > (2, 1): + # present but disabled by default as of django 2.1; not sure when added, + # so not listing it by default. + _builtin_django_hashers.update( + bcrypt="BCryptPasswordHasher", + ) + def _create_django_hasher(self, django_name): """ helper to create new django hasher by name. @@ -245,17 +268,22 @@ class DjangoTranslator(object): module = sys.modules.get("passlib.ext.django.models") if module is None or not module.adapter.patched: from django.contrib.auth.hashers import get_hasher - return get_hasher(django_name) - - # We've patched django's get_hashers(), so calling django's get_hasher() - # or get_hashers_by_algorithm() would only land us back here. - # As non-ideal workaround, have to use original get_hashers(), - get_hashers = module.adapter._manager.getorig("django.contrib.auth.hashers:get_hashers").__wrapped__ - for hasher in get_hashers(): - if hasher.algorithm == django_name: - return hasher - - # hardcode a few for cases where get_hashers() look won't work. + try: + return get_hasher(django_name) + except ValueError as err: + if not str(err).startswith("Unknown password hashing algorithm"): + raise + else: + # We've patched django's get_hashers(), so calling django's get_hasher() + # or get_hashers_by_algorithm() would only land us back here. + # As non-ideal workaround, have to use original get_hashers(), + get_hashers = module.adapter._manager.getorig("django.contrib.auth.hashers:get_hashers").__wrapped__ + for hasher in get_hashers(): + if hasher.algorithm == django_name: + return hasher + + # hardcode a few for cases where get_hashers() lookup won't work + # (mainly, hashers that are present in django, but disabled by their default config) path = self._builtin_django_hashers.get(django_name) if path: if "." not in path: @@ -546,12 +574,20 @@ class DjangoContextAdapter(DjangoTranslator): """ # XXX: this currently ignores "preferred" keyword, since its purpose # was for hash migration, and that's handled by the context. + # XXX: honor "none_causes_check_password_error" quirk for django 2.2+? + # seems safer to return False. if password is None or not self.is_password_usable(encoded): return False # verify password context = self.context - correct = context.verify(password, encoded) + try: + correct = context.verify(password, encoded) + except exc.UnknownHashError: + # As of django 1.5, unidentifiable hashes returns False + # (side-effect of django issue 18453) + return False + if not (correct and setter): return correct @@ -592,8 +628,12 @@ class DjangoContextAdapter(DjangoTranslator): if not self.is_password_usable(hash): return False cat = self.get_user_category(user) - ok, new_hash = self.context.verify_and_update(password, hash, - category=cat) + try: + ok, new_hash = self.context.verify_and_update(password, hash, category=cat) + except exc.UnknownHashError: + # As of django 1.5, unidentifiable hashes returns False + # (side-effect of django issue 18453) + return False if ok and new_hash is not None: # migrate to new hash if needed. user.password = new_hash diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py index ea11f4e..d7f90c6 100644 --- a/passlib/tests/test_context.py +++ b/passlib/tests/test_context.py @@ -1612,6 +1612,7 @@ sha512_crypt__min_rounds = 45000 # # init ref info # + from passlib.exc import UnknownHashError from passlib.hash import md5_crypt, unix_disabled ctx = CryptContext(["des_crypt"]) @@ -1649,18 +1650,13 @@ sha512_crypt__min_rounds = 45000 # test w/o disabled hash support self.assertTrue(ctx.is_enabled(h_ref)) - HASH_NOT_IDENTIFIED = "hash could not be identified" - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.is_enabled, h_other) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.is_enabled, h_dis) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.is_enabled, h_dis_ref) + self.assertRaises(UnknownHashError, ctx.is_enabled, h_other) + self.assertRaises(UnknownHashError, ctx.is_enabled, h_dis) + self.assertRaises(UnknownHashError, ctx.is_enabled, h_dis_ref) # test w/ disabled hash support self.assertTrue(ctx2.is_enabled(h_ref)) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.is_enabled, h_other) + self.assertRaises(UnknownHashError, ctx.is_enabled, h_other) self.assertFalse(ctx2.is_enabled(h_dis)) self.assertFalse(ctx2.is_enabled(h_dis_ref)) @@ -1669,24 +1665,18 @@ sha512_crypt__min_rounds = 45000 # # test w/o disabled hash support - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.enable, "") + self.assertRaises(UnknownHashError, ctx.enable, "") self.assertRaises(TypeError, ctx.enable, None) self.assertEqual(ctx.enable(h_ref), h_ref) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.enable, h_other) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.enable, h_dis) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.enable, h_dis_ref) + self.assertRaises(UnknownHashError, ctx.enable, h_other) + self.assertRaises(UnknownHashError, ctx.enable, h_dis) + self.assertRaises(UnknownHashError, ctx.enable, h_dis_ref) # test w/ disabled hash support - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx.enable, "") + self.assertRaises(UnknownHashError, ctx.enable, "") self.assertRaises(TypeError, ctx2.enable, None) self.assertEqual(ctx2.enable(h_ref), h_ref) - self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED, - ctx2.enable, h_other) + self.assertRaises(UnknownHashError, ctx2.enable, h_other) self.assertRaisesRegex(ValueError, "cannot restore original hash", ctx2.enable, h_dis) self.assertEqual(ctx2.enable(h_dis_ref), h_ref) diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index b43afea..4a2d4c9 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -12,7 +12,7 @@ from passlib import apps as _apps, exc, registry from passlib.apps import django10_context, django14_context, django16_context from passlib.context import CryptContext from passlib.ext.django.utils import ( - DJANGO_VERSION, MIN_DJANGO_VERSION, DjangoTranslator, + DJANGO_VERSION, MIN_DJANGO_VERSION, DjangoTranslator, quirks, ) from passlib.utils.compat import get_method_function from passlib.utils.decor import memoized_property @@ -23,7 +23,10 @@ from passlib.tests.test_handlers import get_handler_case __all__ = [ "DjangoBehaviorTest", "ExtensionBehaviorTest", + "DjangoExtensionTest", + "_ExtensionSupport", + "_ExtensionTest", ] #============================================================================= # configure django settings for testcases @@ -59,6 +62,11 @@ if has_min_django: from django.apps import apps apps.populate(["django.contrib.contenttypes", "django.contrib.auth"]) +# log a warning if tested w/ newer version. +# NOTE: this is mainly here as place to mark what version it was run against before release. +if DJANGO_VERSION >= (3, 2): + log.info("this release hasn't been tested against Django %r", DJANGO_VERSION) + #============================================================================= # support funcs #============================================================================= @@ -132,28 +140,58 @@ def check_django_hasher_has_backend(name): # work up stock django config #============================================================================= +def _modify_django_config(kwds, sha_rounds=None): + """ + helper to build django CryptContext config matching expected setup for stock django deploy. + :param kwds: + :param sha_rounds: + :return: + """ + # make sure we have dict + if hasattr(kwds, "to_dict"): + # type: CryptContext + kwds = kwds.to_dict() + + # update defaults + kwds.update( + # TODO: push this to passlib.apps django contexts + deprecated="auto", + ) + + # fill in default rounds for current django version, so our sample hashes come back + # unchanged, instead of being upgraded in-place by check_password(). + if sha_rounds is None and has_min_django: + from django.contrib.auth.hashers import PBKDF2PasswordHasher + sha_rounds = PBKDF2PasswordHasher.iterations + + # modify rounds + if sha_rounds: + kwds.update( + django_pbkdf2_sha1__default_rounds=sha_rounds, + django_pbkdf2_sha256__default_rounds=sha_rounds, + ) + + return kwds + +#---------------------------------------------------- # build config dict that matches stock django -# XXX: move these to passlib.apps? -if DJANGO_VERSION >= (1, 11): - stock_config = _apps.django110_context.to_dict() - stock_rounds = 36000 +#---------------------------------------------------- + +# XXX: replace this with code that interrogates default django config directly? +# could then separate out "validation of djangoXX_context objects" +# and "validation that individual hashers match django". +# or maybe add a "get_django_context(django_version)" helper to passlib.apps? +if DJANGO_VERSION >= (2, 1): + stock_config = _modify_django_config(_apps.django21_context) elif DJANGO_VERSION >= (1, 10): - stock_config = _apps.django110_context.to_dict() - stock_rounds = 30000 -elif DJANGO_VERSION >= (1, 9): - stock_config = _apps.django16_context.to_dict() - stock_rounds = 24000 -else: # 1.8 - stock_config = _apps.django16_context.to_dict() - stock_rounds = 20000 - -stock_config.update( - deprecated="auto", - django_pbkdf2_sha1__default_rounds=stock_rounds, - django_pbkdf2_sha256__default_rounds=stock_rounds, -) + stock_config = _modify_django_config(_apps.django110_context) +else: + # assert DJANGO_VERSION >= (1, 8) + stock_config = _modify_django_config(_apps.django16_context) +#---------------------------------------------------- # override sample hashes used in test cases +#---------------------------------------------------- from passlib.hash import django_pbkdf2_sha256 sample_hashes = dict( django_pbkdf2_sha256=("not a password", django_pbkdf2_sha256 @@ -164,11 +202,16 @@ sample_hashes = dict( #============================================================================= # test utils #============================================================================= + class _ExtensionSupport(object): - """support funcs for loading/unloading extension""" + """ + test support funcs for loading/unloading extension. + this class is mixed in to various TestCase subclasses. + """ #=================================================================== # support funcs #=================================================================== + @classmethod def _iter_patch_candidates(cls): """helper to scan for monkeypatches. @@ -205,8 +248,11 @@ class _ExtensionSupport(object): #=================================================================== # verify current patch state #=================================================================== + def assert_unpatched(self): - """test that django is in unpatched state""" + """ + 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.adapter.patched, "patch should not be enabled") @@ -223,7 +269,9 @@ class _ExtensionSupport(object): (obj, attr, source)) def assert_patched(self, context=None): - """helper to ensure django HAS been patched, and is using specified config""" + """ + helper to ensure django HAS been patched, and is using specified config + """ # make sure we're currently patched mod = sys.modules.get("passlib.ext.django.models") self.assertTrue(mod and mod.adapter.patched, "patch should have been enabled") @@ -248,9 +296,13 @@ class _ExtensionSupport(object): #=================================================================== # load / unload the extension (and verify it worked) #=================================================================== + _config_keys = ["PASSLIB_CONFIG", "PASSLIB_CONTEXT", "PASSLIB_GET_CATEGORY"] + def load_extension(self, check=True, **kwds): - """helper to load extension with specified config & patch django""" + """ + helper to load extension with specified config & patch django + """ self.unload_extension() if check: config = kwds.get("PASSLIB_CONFIG") or kwds.get("PASSLIB_CONTEXT") @@ -262,7 +314,9 @@ class _ExtensionSupport(object): self.assert_patched(context=config) def unload_extension(self): - """helper to remove patches and unload extension""" + """ + helper to remove patches and unload extension + """ # remove patches and unload module mod = sys.modules.get("passlib.ext.django.models") if mod: @@ -277,8 +331,19 @@ class _ExtensionSupport(object): # eoc #=================================================================== + # XXX: rename to ExtensionFixture? +# NOTE: would roll this into _ExtensionSupport class; +# but we have to mix that one into django's TestCase classes as well; +# and our TestCase class (and this setUp() method) would foul things up. class _ExtensionTest(TestCase, _ExtensionSupport): + """ + TestCase mixin which makes sure extension is unloaded before test; + and make sure it's unloaded after test as well. + """ + #============================================================================= + # setup + #============================================================================= def setUp(self): super().setUp() @@ -296,13 +361,40 @@ class _ExtensionTest(TestCase, _ExtensionSupport): # and do the same when the test exits self.addCleanup(self.unload_extension) + #============================================================================= + # eoc + #============================================================================= + #============================================================================= # extension tests #============================================================================= + +#: static passwords used by DjangoBehaviorTest methods +PASS1 = "toomanysecrets" +WRONG1 = "letmein" + + class DjangoBehaviorTest(_ExtensionTest): - """tests model to verify it matches django's behavior""" + """ + tests model to verify it matches django's behavior. + + running this class verifies the tests correctly assert what Django itself does. + + running the ExtensionBehaviorTest subclass below verifies "passlib.ext.django" + matches what the tests assert. + """ + #============================================================================= + # class attrs + #============================================================================= + descriptionPrefix = "verify django behavior" + + #: tracks whether tests should assume "passlib.ext.django" monkeypatch is applied. + #: (set to True by ExtensionBehaviorTest subclass) patched = False + + #: dict containing CryptContext() config which should match current django deploy. + #: used by tests to verify expected behavior. config = stock_config # NOTE: if this test fails, it means we're not accounting for @@ -310,22 +402,30 @@ class DjangoBehaviorTest(_ExtensionTest): # running against an untested version of django with a new # hashing policy. - @property + #============================================================================= + # test helpers + #============================================================================= + + @memoized_property def context(self): + """ + per-test CryptContext() created from .config. + """ return CryptContext._norm_source(self.config) def assert_unusable_password(self, user): - """check that user object is set to 'unusable password' constant""" + """ + check that user object is set to 'unusable password' constant + """ self.assertTrue(user.password.startswith("!")) self.assertFalse(user.has_usable_password()) self.assertEqual(user.pop_saved_passwords(), []) def assert_valid_password(self, user, hash=UNSET, saved=None): - """check that user object has a usuable password hash. - + """ + check that user object has a usable password hash. :param hash: optionally check it has this exact hash - :param saved: check that mock commit history - for user.password matches this list + :param saved: check that mock commit history for user.password matches this list """ if hash is UNSET: self.assertNotEqual(user.password, "!") @@ -337,55 +437,54 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertEqual(user.pop_saved_passwords(), [] if saved is None else [saved]) - def test_config(self): - """test hashing interface + #============================================================================= + # test hashing interface + #----------------------------------------------------------------------------- + # these functions are run against both the actual django code, + # to verify the assumptions of the unittests are correct; + # and run against the passlib extension, to verify it matches those assumptions. + # + # these tests check the following django methods: + # User.set_password() + # User.check_password() + # make_password() -- 1.4 only + # check_password() + # identify_hasher() + # User.has_usable_password() + # User.set_unusable_password() + # + # XXX: this take a while to run. what could be trimmed? + # + # TODO: add get_hasher() checks where appropriate in tests below. + #============================================================================= - this function is run against both the actual django code, to - verify the assumptions of the unittests are correct; - and run against the passlib extension, to verify it matches - those assumptions. + def test_extension_config(self): """ - log = self.getLogger() - patched, config = self.patched, self.config - # this tests the following methods: - # User.set_password() - # User.check_password() - # make_password() -- 1.4 only - # check_password() - # identify_hasher() - # User.has_usable_password() - # User.set_unusable_password() - # XXX: this take a while to run. what could be trimmed? - - # TODO: get_hasher() - - #======================================================= - # setup helpers & imports - #======================================================= + test extension config is loaded correctly + """ + if not self.patched: + raise self.skipTest("extension not loaded") + ctx = self.context - setter = create_mock_setter() - PASS1 = "toomanysecrets" - WRONG1 = "letmein" - - from django.contrib.auth.hashers import (check_password, make_password, - is_password_usable, identify_hasher) - - #======================================================= - # make sure extension is configured correctly - #======================================================= - if patched: - # contexts should match - from passlib.ext.django.models import password_context - self.assertEqual(password_context.to_dict(resolve=True), - ctx.to_dict(resolve=True)) - - # should have patched both places - from django.contrib.auth.models import check_password as check_password2 - self.assertEqual(check_password2, check_password) - - #======================================================= - # default algorithm - #======================================================= + + # contexts should match + from django.contrib.auth.hashers import check_password + from passlib.ext.django.models import password_context + self.assertEqual(password_context.to_dict(resolve=True), ctx.to_dict(resolve=True)) + + # should have patched both places + from django.contrib.auth.models import check_password as check_password2 + self.assertEqual(check_password2, check_password) + + def test_default_algorithm(self): + """ + test django's default algorithm + """ + ctx = self.context + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import make_password + # User.set_password() should use default alg user = FakeUser() user.set_password(PASS1) @@ -400,9 +499,19 @@ class DjangoBehaviorTest(_ExtensionTest): # check_password() - n/a - #======================================================= - # empty password behavior - #======================================================= + def test_empty_password(self): + """ + test how methods handle empty string as password + """ + ctx = self.context + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) # User.set_password() should use default alg user = FakeUser() @@ -415,14 +524,26 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertTrue(user.check_password("")) self.assert_valid_password(user, hash) - # no make_password() + # XXX: test make_password() ? + + # TODO: is_password_usable() + + # identify_hasher() -- na # check_password() should return True self.assertTrue(check_password("", hash)) - #======================================================= - # 'unusable flag' behavior - #======================================================= + def test_unusable_flag(self): + """ + test how methods handle 'unusable flag' in hash + """ + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) # sanity check via user.set_unusable_password() user = FakeUser() @@ -452,175 +573,270 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertFalse(is_password_usable(user.password)) self.assertRaises(ValueError, identify_hasher, user.password) - #======================================================= - # hash=None - #======================================================= + def test_none_hash_value(self): + """ + test how methods handle None as hash value + """ + patched = self.patched + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) + # User.set_password() - n/a # User.check_password() - returns False - # FIXME: at some point past 1.8, some of these django started handler None differently; - # and/or throwing TypeError. need to investigate when that change occurred; - # update these tests, and maybe passlib.ext.django as well. user = FakeUser() user.password = None - self.assertFalse(user.check_password(PASS1)) - self.assertFalse(user.has_usable_password()) + if quirks.none_causes_check_password_error and not patched: + # django 2.1+ + self.assertRaises(TypeError, user.check_password, PASS1) + else: + self.assertFalse(user.check_password(PASS1)) + + self.assertEqual(user.has_usable_password(), + quirks.empty_is_usable_password) + + # TODO: is_password_usable() # make_password() - n/a # check_password() - error - self.assertFalse(check_password(PASS1, None)) + if quirks.none_causes_check_password_error and not patched: + self.assertRaises(TypeError, check_password, PASS1, None) + else: + self.assertFalse(check_password(PASS1, None)) # identify_hasher() - error self.assertRaises(TypeError, identify_hasher, None) - #======================================================= - # empty & invalid hash values - # NOTE: django 1.5 behavior change due to django ticket 18453 - # NOTE: passlib integration tries to match current django version - #======================================================= - for hash in ("", # empty hash - "$789$foo", # empty identifier - ): - # User.set_password() - n/a - - # User.check_password() - # As of django 1.5, blank OR invalid hash returns False - user = FakeUser() - user.password = hash - self.assertFalse(user.check_password(PASS1)) + def test_empty_hash_value(self): + """ + test how methods handle empty string as hash value + """ + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) - # verify hash wasn't changed/upgraded during check_password() call - self.assertEqual(user.password, hash) - self.assertEqual(user.pop_saved_passwords(), []) + # User.set_password() - n/a - # User.has_usable_password() - self.assertFalse(user.has_usable_password()) + # User.check_password() + # As of django 1.5, blank hash returns False (django issue 18453) + user = FakeUser() + user.password = "" + self.assertFalse(user.check_password(PASS1)) - # make_password() - n/a + # verify hash wasn't changed/upgraded during check_password() call + self.assertEqual(user.password, "") + self.assertEqual(user.pop_saved_passwords(), []) - # check_password() - self.assertFalse(check_password(PASS1, hash)) + # User.has_usable_password() + self.assertEqual(user.has_usable_password(), quirks.empty_is_usable_password) - # identify_hasher() - throws error - self.assertRaises(ValueError, identify_hasher, hash) + # TODO: is_password_usable() - #======================================================= - # run through all the schemes in the context, - # testing various bits of per-scheme behavior. - #======================================================= - for scheme in ctx.schemes(): + # make_password() - n/a - # - # TODO: break this loop up into separate parameterized tests. - # + # check_password() + self.assertFalse(check_password(PASS1, "")) - #------------------------------------------------------- - # setup constants & imports, pick a sample secret/hash combo - #------------------------------------------------------- + # identify_hasher() - throws error + self.assertRaises(ValueError, identify_hasher, "") - handler = ctx.handler(scheme) - log.debug("testing scheme: %r => %r", scheme, handler) - deprecated = ctx.handler(scheme).deprecated - assert not deprecated or scheme != ctx.default_scheme() - try: - testcase = get_handler_case(scheme) - except exc.MissingBackendError: - continue - assert handler_derived_from(handler, testcase.handler) - if handler.is_disabled: - continue - - # verify that django has a backend available - # (since our hasher may use different set of backends, - # get_handler_case() above may work, but django will have nothing) - if not patched and not check_django_hasher_has_backend(handler.django_name): - assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \ - "%r scheme should always have active backend" % scheme - # TODO: make this a SkipTest() once this loop has been parameterized. - log.warn("skipping scheme %r due to missing django dependancy", scheme) - continue - - # find a sample (secret, hash) pair to test with - try: - secret, hash = sample_hashes[scheme] - except KeyError: - get_sample_hash = testcase("setUp").get_sample_hash - while True: - secret, hash = get_sample_hash() - if secret: # don't select blank passwords - break - other = 'dontletmein' - - #------------------------------------------------------- - # User.set_password() - not tested here - #------------------------------------------------------- - - #------------------------------------------------------- - # User.check_password()+migration against known hash - #------------------------------------------------------- - user = FakeUser() - user.password = hash - - # check against invalid password - self.assertFalse(user.check_password(None)) - ##self.assertFalse(user.check_password('')) - self.assertFalse(user.check_password(other)) + def test_invalid_hash_values(self): + """ + test how methods handle invalid hash values. + """ + for hash in [ + "$789$foo", # empty identifier + ]: + with self.subTest(hash=hash): + self._do_test_invalid_hash_value(hash) + + def _do_test_invalid_hash_value(self, hash): + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) + + # User.set_password() - n/a + + # User.check_password() + # As of django 1.5, invalid hash returns False (side effect of django issue 18453) + user = FakeUser() + user.password = hash + self.assertFalse(user.check_password(PASS1)) + + # verify hash wasn't changed/upgraded during check_password() call + self.assertEqual(user.password, hash) + self.assertEqual(user.pop_saved_passwords(), []) + + # User.has_usable_password() + self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password) + + # TODO: is_password_usable() + + # make_password() - n/a + + # check_password() + self.assertFalse(check_password(PASS1, hash)) + + # identify_hasher() - throws error + self.assertRaises(ValueError, identify_hasher, hash) + + def test_available_schemes(self): + """ + run a bunch of subtests for each hasher available in the default django setup + (as determined by reading self.context) + """ + for scheme in self.context.schemes(): + with self.subTest(scheme=scheme): + self._do_test_available_scheme(scheme) + + def _do_test_available_scheme(self, scheme): + """ + helper to test how specific hasher behaves. + :param scheme: *passlib* name of hasher (e.g. "django_pbkdf2_sha256") + """ + log = self.getLogger() + ctx = self.context + patched = self.patched + setter = create_mock_setter() + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) + + #------------------------------------------------------- + # setup constants & imports, pick a sample secret/hash combo + #------------------------------------------------------- + handler = ctx.handler(scheme) + log.debug("testing scheme: %r => %r", scheme, handler) + deprecated = ctx.handler(scheme).deprecated + assert not deprecated or scheme != ctx.default_scheme() + try: + testcase = get_handler_case(scheme) + except exc.MissingBackendError: + raise self.skipTest("backend not available") + assert handler_derived_from(handler, testcase.handler) + if handler.is_disabled: + raise self.skipTest("skip disabled hasher") + + # verify that django has a backend available + # (since our hasher may use different set of backends, + # get_handler_case() above may work, but django will have nothing) + if not patched and not check_django_hasher_has_backend(handler.django_name): + assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \ + "%r scheme should always have active backend" % scheme + log.warning("skipping scheme %r due to missing django dependency", scheme) + raise self.skipTest("skip due to missing dependency") + + # find a sample (secret, hash) pair to test with + try: + secret, hash = sample_hashes[scheme] + except KeyError: + get_sample_hash = testcase("setUp").get_sample_hash + while True: + secret, hash = get_sample_hash() + if secret: # don't select blank passwords + break + other = 'dontletmein' + + #------------------------------------------------------- + # User.set_password() - not tested here + #------------------------------------------------------- + + #------------------------------------------------------- + # User.check_password()+migration against known hash + #------------------------------------------------------- + user = FakeUser() + user.password = hash + + # check against invalid password + self.assertFalse(user.check_password(None)) + ##self.assertFalse(user.check_password('')) + self.assertFalse(user.check_password(other)) + self.assert_valid_password(user, hash) + + # check against valid password + self.assertTrue(user.check_password(secret)) + + # check if it upgraded the hash + # NOTE: needs_update kept separate in case we need to test rounds. + needs_update = deprecated + if needs_update: + self.assertNotEqual(user.password, hash) + self.assertFalse(handler.identify(user.password)) + self.assertTrue(ctx.handler().verify(secret, user.password)) + self.assert_valid_password(user, saved=user.password) + else: self.assert_valid_password(user, hash) - # check against valid password - self.assertTrue(user.check_password(secret)) - - # check if it upgraded the hash - # NOTE: needs_update kept separate in case we need to test rounds. - needs_update = deprecated - if needs_update: - self.assertNotEqual(user.password, hash) - self.assertFalse(handler.identify(user.password)) - self.assertTrue(ctx.handler().verify(secret, user.password)) - self.assert_valid_password(user, saved=user.password) - else: - self.assert_valid_password(user, hash) - - # don't need to check rest for most deployments - if TEST_MODE(max="default"): - continue - - #------------------------------------------------------- - # make_password() correctly selects algorithm - #------------------------------------------------------- - alg = DjangoTranslator().passlib_to_django_name(scheme) - hash2 = make_password(secret, hasher=alg) - self.assertTrue(handler.verify(secret, hash2)) - - #------------------------------------------------------- - # check_password()+setter against known hash - #------------------------------------------------------- - # should call setter only if it needs_update - self.assertTrue(check_password(secret, hash, setter=setter)) - self.assertEqual(setter.popstate(), [secret] if needs_update else []) - - # should not call setter - self.assertFalse(check_password(other, hash, setter=setter)) - self.assertEqual(setter.popstate(), []) - - ### check preferred kwd is ignored (feature we don't currently support fully) - ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey')) - ##self.assertEqual(setter.popstate(), [secret]) - - # TODO: get_hasher() - - #------------------------------------------------------- - # identify_hasher() recognizes known hash - #------------------------------------------------------- - self.assertTrue(is_password_usable(hash)) - name = DjangoTranslator().django_to_passlib_name(identify_hasher(hash).algorithm) - self.assertEqual(name, scheme) + # don't need to check rest for most deployments + if TEST_MODE(max="default"): + return + + #------------------------------------------------------- + # make_password() correctly selects algorithm + #------------------------------------------------------- + alg = DjangoTranslator().passlib_to_django_name(scheme) + hash2 = make_password(secret, hasher=alg) + self.assertTrue(handler.verify(secret, hash2)) + + #------------------------------------------------------- + # check_password()+setter against known hash + #------------------------------------------------------- + # should call setter only if it needs_update + self.assertTrue(check_password(secret, hash, setter=setter)) + self.assertEqual(setter.popstate(), [secret] if needs_update else []) + + # should not call setter + self.assertFalse(check_password(other, hash, setter=setter)) + self.assertEqual(setter.popstate(), []) + + ### check preferred kwd is ignored (feature we don't currently support fully) + ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey')) + ##self.assertEqual(setter.popstate(), [secret]) + + # TODO: get_hasher() + + #------------------------------------------------------- + # identify_hasher() recognizes known hash + #------------------------------------------------------- + self.assertTrue(is_password_usable(hash)) + name = DjangoTranslator().django_to_passlib_name(identify_hasher(hash).algorithm) + self.assertEqual(name, scheme) + + #=================================================================== + # eoc + #=================================================================== + +#=================================================================== +# extension fidelity tests +#=================================================================== class ExtensionBehaviorTest(DjangoBehaviorTest): - """test model to verify passlib.ext.django conforms to it""" + """ + test that "passlib.ext.django" conforms to behavioral assertions in DjangoBehaviorTest + """ descriptionPrefix = "verify extension behavior" - patched = True + config = dict( schemes="sha256_crypt,md5_crypt,des_crypt", deprecated="des_crypt", @@ -628,15 +844,29 @@ class ExtensionBehaviorTest(DjangoBehaviorTest): def setUp(self): super().setUp() + + # always load extension before each test self.load_extension(PASSLIB_CONFIG=self.config) + self.patched = True + +#=================================================================== +# extension internal tests +#=================================================================== class DjangoExtensionTest(_ExtensionTest): - """test the ``passlib.ext.django`` plugin""" + """ + test the ``passlib.ext.django`` plugin + """ + #=================================================================== + # class attrs + #=================================================================== + descriptionPrefix = "passlib.ext.django plugin" #=================================================================== # monkeypatch testing #=================================================================== + def test_00_patch_control(self): """test set_django_password_context patch/unpatch""" @@ -700,12 +930,15 @@ class DjangoExtensionTest(_ExtensionTest): passlib_to_django = DjangoTranslator().passlib_to_django # should return native django hasher if available - if DJANGO_VERSION > (1,10): + if DJANGO_VERSION > (1, 10): self.assertRaises(ValueError, passlib_to_django, "hex_md5") else: hasher = passlib_to_django("hex_md5") self.assertIsInstance(hasher, hashers.UnsaltedMD5PasswordHasher) + # should return native django hasher + # NOTE: present but not enabled by default in django as of 2.1 + # (see _builtin_django_hashers) hasher = passlib_to_django("django_bcrypt") self.assertIsInstance(hasher, hashers.BCryptPasswordHasher) @@ -731,6 +964,10 @@ class DjangoExtensionTest(_ExtensionTest): 'hash': u'v2RWkZ*************************************', }) + # made up name should throw error + # XXX: should this throw ValueError instead, to match django? + self.assertRaises(KeyError, passlib_to_django, "does_not_exist") + #=================================================================== # PASSLIB_CONFIG settings #=================================================================== diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index d00d30a..9ade9de 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -619,6 +619,57 @@ class TestCase(unittest.TestCase): return value #=================================================================== + # subtests + #=================================================================== + + @contextlib.contextmanager + def subTest(self, *args, **kwds): + """ + wrapper for .subTest() which traps SkipTest errors. + (see source for details) + """ + # this function works around issue that as 2020-10-08, + # .subTest() doesn't play nicely w/ .skipTest(); + # and also makes it hard to debug which subtest had a failure. + # (see https://bugs.python.org/issue25894 and https://bugs.python.org/issue35327) + # this method traps skipTest exceptions, and adds some logging to help debug + # which subtest caused the issue. + + # setup way to log subtest info + # XXX: would like better way to inject messages into test output; + # but this at least gets us something for debugging... + # NOTE: this hack will miss parent params if called from nested .subTest() + def _render_title(_msg=None, **params): + out = ("[%s] " % _msg if _msg else "") + if params: + out += "(%s)" % " ".join("%s=%r" % tuple(item) for item in params.items()) + return out.strip() or "<subtest>" + + test_log = self.getLogger() + title = _render_title(*args, **kwds) + + # run the subtest + ctx = super().subTest(*args, **kwds) + with ctx: + test_log.info("running subtest: %s", title) + try: + yield + except SkipTest: + # silence "SkipTest" exceptions, want to keep running next subtest. + test_log.info("subtest skipped: %s", title) + # XXX: should revisit whether latest py3 version of UTs handle this ok, + # meaning it's safe to re-raise this. + return + except Exception as err: + # log unhandled exception occurred + # (assuming traceback will be reported up higher, so not bothering here) + test_log.warning("subtest failed: %s: %s: %r", title, type(err).__name__, str(err)) + raise + + # XXX: check for "failed" state in ``self._outcome`` before writing this? + test_log.info("subtest passed: %s", title) + + #=================================================================== # other #=================================================================== _mktemp_queue = None @@ -25,7 +25,7 @@ # # "full" # extra regression and internal tests are enabled, hash algorithms are tested -# against all available backends, unavailable ones are mocked whre possible, +# against all available backends, unavailable ones are mocked where possible, # additional time is devoted to fuzz testing. # #----------------------------------------------------------------------- @@ -73,11 +73,13 @@ envlist = argon2hash-argon2pure-py{3,py3}, # django tests - # NOTE: django >= 1.7 distributes tests as part of source, not the package, so for full + # NOTE: django distributes it'a tests as part of source, not the package, so for full # integration tests to run, caller must provide a copy of the latest django source, # and set the env var PASSLIB_TESTS_DJANGO_SOURCE_PATH to point to it. - django{18,1x}-wdeps-py3, - django-{wdeps,nodeps}-py3, + # django support roadmap -- https://www.djangoproject.com/download/ + # django python versions -- https://docs.djangoproject.com/en/3.1/faq/install/#what-python-version-can-i-use-with-django + django-dj{Latest,31,30,22,21,20,1x,18}-wdeps-py3, + django-dj{Latest}-nodeps-py3, # other tests docs @@ -129,7 +131,7 @@ commands = argon2hash: nosetests {posargs:{env:TEST_OPTS} passlib.tests.test_handlers_argon2} # django tests - django{,1x,18}: nosetests {posargs:{env:TEST_OPTS} passlib.tests.test_ext_django passlib.tests.test_handlers_django} + django: nosetests {posargs:{env:TEST_OPTS} passlib.tests.test_ext_django passlib.tests.test_handlers_django} deps = # common @@ -168,14 +170,20 @@ deps = argon2hash-argon2pure: argon2pure # django extension tests - django18: django>=1.8,<1.9 - django1x: django<2 - django: django - django{,1x,18}-wdeps: bcrypt - # django{,18}-nodeps -- would like to use this as negative dependancy for 'bcrypt' instead + dj18: django<1.9 + dj1x: django<2.0 + dj20: django<2.1 + dj21: django<2.2 + dj22: django<3.0 + dj30: django<3.1 + dj31: django<3.2 + djLatest: django + django-wdeps: bcrypt + # django-nodeps -- would like to use this as negative dependancy for 'bcrypt' instead # needed by django's internal tests - django{,1x,18}: mock + # XXX: does django still need this as of py35? + django: mock #=========================================================================== # build documentation |