summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2013-12-20 22:11:07 -0500
committerEli Collins <elic@assurancetechnologies.com>2013-12-20 22:11:07 -0500
commit4af9748bcaed1085a8c1e64c2370fa00fb244f11 (patch)
treed9ab9cb8cd7aef752bccfb2c9461eef4530eb4ef
parent4c08f92f9caa64140e0010eae88179f260a28704 (diff)
downloadpasslib-4af9748bcaed1085a8c1e64c2370fa00fb244f11.tar.gz
django compatibility updates (should fix issue 50)
passlib.ext.django & it's tests have gotten out of sync with django, leading to a number of UT failures, as reported in issue 50. tests now pass on django 1.2 through 1.6 passlib.ext.django ------------------ mimic changes in django's hasher logic: * handle unsalted_sha1 hasher (django 1.4.6+) * check_password(): empty hashes return False, rather throw error (django 1.5+ * allow empty passwords (django 1.6+) * generate unusuable password suffixes (django 1.6+) passlib.hash ------------ * django_des_crypt: added "use_duplicate_salt" class attr, allowing tests to enable django 1.4+ style hashes which omit 1st salt. * django_disabled: added support for django 1.6+ random suffixes passlib.tests ------------- * test_ext_django: lots of changes to verify django 1.5/1.6 behavior * test_handlers_django: split django tests out of test_handlers to make it easiers to run django-related tests. * added workaround for encoding glitch in salted_md5 / salted_sha1 hashers (django 1.5+)
-rw-r--r--CHANGES13
-rw-r--r--docs/lib/passlib.ext.django.rst6
-rw-r--r--passlib/ext/django/models.py64
-rw-r--r--passlib/ext/django/utils.py37
-rw-r--r--passlib/handlers/django.py38
-rw-r--r--passlib/tests/test_ext_django.py209
-rw-r--r--passlib/tests/test_handlers.py268
-rw-r--r--passlib/tests/test_handlers_django.py297
-rw-r--r--passlib/tests/utils.py16
-rw-r--r--tox.ini87
10 files changed, 697 insertions, 338 deletions
diff --git a/CHANGES b/CHANGES
index 6f9f2be..34fbb7e 100644
--- a/CHANGES
+++ b/CHANGES
@@ -4,6 +4,19 @@
Release History
===============
+**1.6.2** (NOT YET RELEASED)
+============================
+
+ Bugfix release
+
+ * *Django compatibility* -- Passlib's Django extension (:mod:`passlib.ext.django`),
+ and it's related hashes and unittests, have been updated to handle
+ some minor API changes in Django 1.5-1.6. They should now be compatible with Django 1.2 and up.
+
+ .. note::
+
+ As of Passlib 1.7, :mod:`passlib.ext.django` will require Django >= 1.4.
+
**1.6.1** (2012-08-02)
======================
diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst
index 7905d3f..f9ac7a2 100644
--- a/docs/lib/passlib.ext.django.rst
+++ b/docs/lib/passlib.ext.django.rst
@@ -8,6 +8,10 @@
.. versionadded:: 1.6
+.. note::
+
+ As of Passlib 1.7, :mod:`passlib.ext.django` will require Django >= 1.4.
+
This module contains a `Django <http://www.djangoproject.com>`_ plugin which
overriddes all of Django's password hashing functions, replacing them
with wrappers around a Passlib :ref:`CryptContext <context-overview>` object
@@ -31,7 +35,7 @@ of uses:
* Mark any hash algorithms as deprecated, and automatically migrate to stronger
hashes when the user logs in.
-.. warning::
+.. note::
This plugin should be considered "release candidate" quality.
It works, and has good unittest coverage, but has seen only
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index b4a40da..6c4d245 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -72,20 +72,39 @@ def _apply_patch():
from django.contrib.auth.models import UNUSABLE_PASSWORD
def is_password_usable(encoded):
- return (encoded is not None and encoded != UNUSABLE_PASSWORD)
+ return encoded is not None and encoded != UNUSABLE_PASSWORD
def is_valid_secret(secret):
return secret is not None
- else:
+ elif VERSION < (1,6):
has_hashers = True
from django.contrib.auth.hashers import UNUSABLE_PASSWORD, \
is_password_usable
+ # NOTE: 1.4 - 1.5 - empty passwords no longer valid.
def is_valid_secret(secret):
- # NOTE: changed in 1.4 - empty passwords no longer valid.
return bool(secret)
+ else:
+ has_hashers = True
+ from django.contrib.auth.hashers import is_password_usable
+
+ # 1.6 - empty passwords valid again
+ def is_valid_secret(secret):
+ return secret is not None
+
+ if VERSION < (1,6):
+ def make_unusable_password():
+ return UNUSABLE_PASSWORD
+ else:
+ from django.contrib.auth.hashers import make_password as _make_password
+ def make_unusable_password():
+ return _make_password(None)
+
+ # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes
+ has_unsalted_sha1 = (VERSION >= (1,4,6))
+
#
# backport ``User.set_unusable_password()`` for Django 0.9
# (simplifies rest of the code)
@@ -95,7 +114,7 @@ def _apply_patch():
@_manager.monkeypatch(USER_PATH)
def set_unusable_password(user):
- user.password = UNUSABLE_PASSWORD
+ user.password = make_unusable_password()
@_manager.monkeypatch(USER_PATH)
def has_usable_password(user):
@@ -123,6 +142,8 @@ def _apply_patch():
hash = user.password
if not is_valid_secret(password) or not is_password_usable(hash):
return False
+ if not hash and VERSION < (1,4):
+ return False
# NOTE: pulls _get_category from module globals
cat = _get_category(user)
ok, new_hash = password_context.verify_and_update(password, hash,
@@ -159,12 +180,18 @@ def _apply_patch():
def make_password(password, salt=None, hasher="default"):
"passlib replacement for make_password()"
if not is_valid_secret(password):
- return UNUSABLE_PASSWORD
- kwds = {}
- if salt is not None:
+ return make_unusable_password()
+ if hasher == "default":
+ scheme = None
+ else:
+ scheme = hasher_to_passlib_name(hasher)
+ kwds = dict(scheme=scheme)
+ handler = password_context.handler(scheme)
+ # NOTE: django make specify an empty string for the salt,
+ # even if scheme doesn't accept a salt. we omit keyword
+ # in that case.
+ if salt is not None and (salt or 'salt' in handler.setting_kwds):
kwds['salt'] = salt
- if hasher != "default":
- kwds['scheme'] = hasher_to_passlib_name(hasher)
return password_context.encrypt(password, **kwds)
@_manager.monkeypatch(HASHERS_PATH)
@@ -175,18 +202,29 @@ def _apply_patch():
scheme = None
else:
scheme = hasher_to_passlib_name(algorithm)
+ # NOTE: resolving scheme -> handler instead of
+ # passing scheme into get_passlib_hasher(),
+ # in case context contains custom handler
+ # shadowing name of a builtin handler.
handler = password_context.handler(scheme)
- return get_passlib_hasher(handler)
+ return get_passlib_hasher(handler, algorithm=algorithm)
- # NOTE: custom helper that doesn't exist in django proper
- # (though submitted a patch - https://code.djangoproject.com/ticket/18184)
+ # identify_hasher() was added in django 1.5,
+ # patching it anyways for 1.4, so passlib's version is always available.
@_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(FORMS_PATH)
def identify_hasher(encoded):
"passlib helper to identify hasher from encoded password"
handler = password_context.identify(encoded, resolve=True,
required=True)
- return get_passlib_hasher(handler)
+ algorithm = None
+ if (has_unsalted_sha1 and handler.name == "django_salted_sha1" and
+ encoded.startswith("sha1$$")):
+ # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
+ # but passlib just reuses the "sha1$salt$digest" handler.
+ # we want to resolve to correct django hasher.
+ algorithm = "unsalted_sha1"
+ return get_passlib_hasher(handler, algorithm=algorithm)
_patched = True
log.debug("... finished monkeypatching django")
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 3c03637..ab10b6f 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -119,6 +119,10 @@ def hasher_to_passlib_name(hasher_name):
"convert hasher name -> passlib handler name"
if hasher_name.startswith(PASSLIB_HASHER_PREFIX):
return hasher_name[len(PASSLIB_HASHER_PREFIX):]
+ if hasher_name == "unsalted_sha1":
+ # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
+ # but passlib just reuses the "sha1$salt$digest" handler.
+ hasher_name = "sha1"
for name in list_crypt_handlers():
if name.startswith(DJANGO_PASSLIB_PREFIX) or name in _other_django_hashes:
handler = get_crypt_handler(name)
@@ -132,13 +136,14 @@ def hasher_to_passlib_name(hasher_name):
#=============================================================================
# wrapping passlib handlers as django hashers
#=============================================================================
-_FAKE_SALT = "--fake-salt--"
+_GEN_SALT_SIGNAL = "--!!!generate-new-salt!!!--"
class _HasherWrapper(object):
"""helper for wrapping passlib handlers in Hasher-compatible class."""
# filled in by subclass, drives the other methods.
passlib_handler = None
+ iterations = None
@classproperty
def algorithm(cls):
@@ -146,19 +151,22 @@ class _HasherWrapper(object):
return PASSLIB_HASHER_PREFIX + cls.passlib_handler.name
def salt(self):
- # XXX: our encode wrapper generates a new salt each time it's called,
- # so just returning a 'no value' flag here.
- return _FAKE_SALT
+ # NOTE: passlib's handler.encrypt() should generate new salt each time,
+ # so this just returns a special constant which tells
+ # encode() (below) not to pass a salt keyword along.
+ return _GEN_SALT_SIGNAL
def verify(self, password, encoded):
return self.passlib_handler.verify(password, encoded)
def encode(self, password, salt=None, iterations=None):
kwds = {}
- if salt is not None and salt != _FAKE_SALT:
+ 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
return self.passlib_handler.encrypt(password, **kwds)
_translate_kwds = dict(checksum="hash", rounds="iterations")
@@ -178,10 +186,17 @@ class _HasherWrapper(object):
items.append((_(key), value))
return SortedDict(items)
+ # 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.
+ return False
+
# cache of hasher wrappers generated by get_passlib_hasher()
_hasher_cache = WeakKeyDictionary()
-def get_passlib_hasher(handler):
+def get_passlib_hasher(handler, algorithm=None):
"""create *Hasher*-compatible wrapper for specified passlib hash.
This takes in the name of a passlib hash (or the handler object itself),
@@ -204,8 +219,14 @@ def get_passlib_hasher(handler):
handler = get_crypt_handler(handler)
if hasattr(handler, "django_name"):
# return native hasher instance
- # XXX: should cache this too.
- return _get_hasher(handler.django_name)
+ # XXX: should add this to _hasher_cache[]
+ name = handler.django_name
+ if name == "sha1" and algorithm == "unsalted_sha1":
+ # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes,
+ # but passlib just reuses the "sha1$salt$digest" handler.
+ # we want to resolve to correct django hasher.
+ name = algorithm
+ return _get_hasher(name)
if handler.name == "django_disabled":
raise ValueError("can't wrap unusable-password handler")
try:
diff --git a/passlib/handlers/django.py b/passlib/handlers/django.py
index 5e67abf..b643a66 100644
--- a/passlib/handlers/django.py
+++ b/passlib/handlers/django.py
@@ -224,7 +224,7 @@ class django_pbkdf2_sha256(DjangoVariableHash):
max_rounds = 0xffffffff # setting at 32-bit limit for now
checksum_chars = uh.PADDED_BASE64_CHARS
checksum_size = 44 # 32 bytes -> base64
- default_rounds = 10000 # NOTE: using django default here
+ default_rounds = 12000 # NOTE: using django default here
_prf = "hmac-sha256"
def _calc_checksum(self, secret):
@@ -311,6 +311,21 @@ class django_des_crypt(uh.HasSalt, uh.GenericHandler):
min_salt_size = default_salt_size = 2
_stub_checksum = u('.')*11
+ # NOTE: regarding duplicate salt field:
+ #
+ # django 1.0 had a "crypt$<salt1>$<salt2><digest>" hash format,
+ # used [a-z0-9] to generate a 5 char salt, stored it in salt1,
+ # duplicated the first two chars of salt1 as salt2.
+ # it would throw an error if salt1 was empty.
+ #
+ # django 1.4 started generating 2 char salt using the full alphabet,
+ # left salt1 empty, and only paid attention to salt2.
+ #
+ # in order to be compatible with django 1.0, the hashes generated
+ # by this function will always include salt1, unless the following
+ # class-level field is disabled (mainly used for testing)
+ use_duplicate_salt = True
+
@classmethod
def from_string(cls, hash):
salt, chk = uh.parse_mc2(hash, cls.ident, handler=cls)
@@ -331,11 +346,14 @@ class django_des_crypt(uh.HasSalt, uh.GenericHandler):
return cls(salt=salt, checksum=chk)
def to_string(self):
- # NOTE: always filling in salt field, so that we're compatible
- # with django 1.0 (which requires it)
salt = self.salt
chk = salt[:2] + (self.checksum or self._stub_checksum)
- return uh.render_mc2(self.ident, salt, chk)
+ if self.use_duplicate_salt:
+ # filling in salt field, so that we're compatible with django 1.0
+ return uh.render_mc2(self.ident, salt, chk)
+ else:
+ # django 1.4+ style hash
+ return uh.render_mc2(self.ident, "", chk)
def _calc_checksum(self, secret):
# NOTE: we lazily import des_crypt,
@@ -352,15 +370,23 @@ class django_disabled(uh.StaticHandler):
claims the special hash string ``"!"`` which Django uses
to indicate an account's password has been disabled.
- * newly encrypted passwords will hash to ``!``.
+ * newly encrypted passwords will hash to ``"!"``.
* it rejects all passwords.
+
+ .. note::
+
+ Django 1.6 prepends a randomly generate 40-char alphanumeric string
+ to each unusuable password. This class recognizes such strings,
+ but for backwards compatibility, still returns ``"!"``.
+
+ .. versionchanged:: 1.6.2 added Django 1.6 support
"""
name = "django_disabled"
@classmethod
def identify(cls, hash):
hash = uh.to_unicode_for_identify(hash)
- return hash == u("!")
+ return hash.startswith(u("!"))
def _calc_checksum(self, secret):
return u("!")
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
index f9fd689..d522386 100644
--- a/passlib/tests/test_ext_django.py
+++ b/passlib/tests/test_ext_django.py
@@ -89,7 +89,8 @@ if has_django:
finally:
del self.saved_passwords[:]
- def save(self):
+ def save(self, update_fields=None):
+ # NOTE: ignoring update_fields for test purposes
self.saved_passwords.append(self.password)
def create_mock_setter():
@@ -107,12 +108,17 @@ def create_mock_setter():
#=============================================================================
# work up stock django config
#=============================================================================
+sample_hashes = {} # override sample hashes used in test cases
if has_django14:
# have to modify this a little -
# all but pbkdf2_sha256 will be deprecated here,
# whereas preconfigured passlib policy is more permissive
stock_config = django14_context.to_dict()
stock_config['deprecated'] = ["django_pbkdf2_sha1", "django_bcrypt"] + stock_config['deprecated']
+ if DJANGO_VERSION >= (1,6):
+ sample_hashes.update(
+ django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$12000$rpUPFQOVetrY$cEcWG4DjjDpLrDyXnduM+XJUz25U63RcM3//xaFnBnw="),
+ )
elif has_django1:
stock_config = django10_context.to_dict()
else:
@@ -239,7 +245,9 @@ class _ExtensionSupport(object):
# eoc
#===================================================================
+# XXX: rename to ExtensionFixture?
class _ExtensionTest(TestCase, _ExtensionSupport):
+
def setUp(self):
super(_ExtensionTest, self).setUp()
@@ -273,12 +281,23 @@ class DjangoBehaviorTest(_ExtensionTest):
return CryptContext._norm_source(self.config)
def assert_unusable_password(self, user):
- self.assertEqual(user.password, "!")
+ """check that user object is set to 'unusable password' constant"""
+ if DJANGO_VERSION >= (1,6):
+ # 1.6 on adds a random(?) suffix
+ self.assertTrue(user.password.startswith("!"))
+ else:
+ self.assertEqual(user.password, "!")
if has_django1 or self.patched:
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.
+
+ :param hash: optionally check it has this exact hash
+ :param saved: check that mock commit history
+ for user.password matches this list
+ """
if hash is UNSET:
self.assertNotEqual(user.password, "!")
self.assertNotEqual(user.password, None)
@@ -318,11 +337,19 @@ class DjangoBehaviorTest(_ExtensionTest):
PASS1 = "toomanysecrets"
WRONG1 = "letmein"
+ has_hashers = False
+ has_identify_hasher = False
if has_django14:
from passlib.ext.django.utils import hasher_to_passlib_name, passlib_to_hasher_name
from django.contrib.auth.hashers import check_password, make_password, is_password_usable
- if patched:
+ if patched or DJANGO_VERSION > (1,5):
+ # identify_hasher()
+ # django 1.4 -- not present
+ # django 1.5 -- present (added in django ticket 18184)
+ # passlib integration -- present even under 1.4
from django.contrib.auth.hashers import identify_hasher
+ has_identify_hasher = True
+ hash_hashers = True
else:
from django.contrib.auth.models import check_password
@@ -361,8 +388,8 @@ class DjangoBehaviorTest(_ExtensionTest):
#=======================================================
# empty password behavior
#=======================================================
- if has_django14:
- # NOTE: django 1.4 treats empty password as invalid
+ if (1,4) <= DJANGO_VERSION < (1,6):
+ # NOTE: django 1.4-1.5 treat empty password as invalid
# User.set_password() should set unusable flag
user = FakeUser()
@@ -408,21 +435,36 @@ class DjangoBehaviorTest(_ExtensionTest):
user.set_unusable_password()
self.assert_unusable_password(user)
- # ensure User.set_password() sets flag
+ # ensure User.set_password() sets unusable flag
user = FakeUser()
user.set_password(None)
- self.assert_unusable_password(user)
+ if DJANGO_VERSION < (1,2):
+ # would set password to hash of "None"
+ self.assert_valid_password(user)
+ else:
+ self.assert_unusable_password(user)
# User.check_password() should always fail
- self.assertFalse(user.check_password(None))
- self.assertFalse(user.check_password(''))
- self.assertFalse(user.check_password(PASS1))
- self.assertFalse(user.check_password(WRONG1))
- self.assert_unusable_password(user)
+ if DJANGO_VERSION < (1,2):
+ self.assertTrue(user.check_password(None))
+ self.assertTrue(user.check_password('None'))
+ self.assertFalse(user.check_password(''))
+ self.assertFalse(user.check_password(PASS1))
+ self.assertFalse(user.check_password(WRONG1))
+ else:
+ self.assertFalse(user.check_password(None))
+ self.assertFalse(user.check_password('None'))
+ self.assertFalse(user.check_password(''))
+ self.assertFalse(user.check_password(PASS1))
+ self.assertFalse(user.check_password(WRONG1))
+ self.assert_unusable_password(user)
# make_password() should also set flag
if has_django14:
- self.assertEqual(make_password(None), "!")
+ if DJANGO_VERSION >= (1,6):
+ self.assertTrue(make_password(None).startswith("!"))
+ else:
+ self.assertEqual(make_password(None), "!")
# check_password() should return False (didn't handle disabled under 1.3)
if has_django14 or patched:
@@ -431,7 +473,7 @@ class DjangoBehaviorTest(_ExtensionTest):
# identify_hasher() and is_password_usable() should reject it
if has_django14:
self.assertFalse(is_password_usable(user.password))
- if has_django14 and patched:
+ if has_identify_hasher:
self.assertRaises(ValueError, identify_hasher, user.password)
#=======================================================
@@ -447,7 +489,10 @@ class DjangoBehaviorTest(_ExtensionTest):
else:
self.assertRaises(TypeError, user.check_password, PASS1)
if has_django1 or patched:
- self.assertFalse(user.has_usable_password())
+ if DJANGO_VERSION < (1,2):
+ self.assertTrue(user.has_usable_password())
+ else:
+ self.assertFalse(user.has_usable_password())
# make_password() - n/a
@@ -458,32 +503,65 @@ class DjangoBehaviorTest(_ExtensionTest):
self.assertRaises(AttributeError, check_password, PASS1, None)
# identify_hasher() - error
- if has_django14 and patched:
+ if has_identify_hasher:
self.assertRaises(TypeError, identify_hasher, None)
#=======================================================
- # invalid hash values
+ # 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 ("", "$789$foo"):
+ for hash in ("", # empty hash
+ "$789$foo", # empty identifier
+ ):
# User.set_password() - n/a
- # User.check_password() - blank hash causes error
+ # User.check_password()
+ # empty
+ # -----
+ # django 1.3 and earlier -- blank hash returns False
+ # django 1.4 -- blank threw error (fixed in 1.5)
+ # django 1.5 -- blank hash returns False
+ #
+ # invalid
+ # -------
+ # django 1.4 and earlier -- invalid hash threw error (fixed in 1.5)
+ # django 1.5 -- invalid hash returns False
user = FakeUser()
user.password = hash
- if has_django14 or patched or hash:
- self.assertRaises(ValueError, user.check_password, PASS1)
- else:
- # django 1.3 returns False for empty hashes
+ if DJANGO_VERSION >= (1,5) or (not hash and DJANGO_VERSION < (1,4)):
+ # returns False for hash
self.assertFalse(user.check_password(PASS1))
- self.assert_valid_password(user, hash) # '' counts as valid for some reason
+ else:
+ # throws error for hash
+ self.assertRaises(ValueError, 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()
+ # passlib shim for django 0.x -- invalid/empty usable, to match 1.0-1.4
+ # django 1.0-1.4 -- invalid/empty usable (fixed in 1.5)
+ # django 1.5 -- invalid/empty no longer usable
+ if has_django1 or self.patched:
+ if DJANGO_VERSION < (1,5):
+ self.assertTrue(user.has_usable_password())
+ else:
+ self.assertFalse(user.has_usable_password())
# make_password() - n/a
- # check_password() - error
- self.assertRaises(ValueError, check_password, PASS1, hash)
+ # check_password()
+ # django 1.4 and earlier -- invalid/empty hash threw error (fixed in 1.5)
+ # django 1.5 -- invalid/empty hash now returns False
+ if DJANGO_VERSION < (1,5):
+ self.assertRaises(ValueError, check_password, PASS1, hash)
+ else:
+ self.assertFalse(check_password(PASS1, hash))
- # identify_hasher() - error
- if has_django14 and patched:
+ # identify_hasher() - throws error
+ if has_identify_hasher:
self.assertRaises(ValueError, identify_hasher, hash)
#=======================================================
@@ -508,11 +586,14 @@ class DjangoBehaviorTest(_ExtensionTest):
if not has_active_backend(handler):
assert scheme == "django_bcrypt"
continue
- while True:
- secret, hash = testcase('setUp').get_sample_hash()
- if secret: # don't select blank passwords, special under django
- break
- other = 'letmein'
+ try:
+ secret, hash = sample_hashes[scheme]
+ except KeyError:
+ while True:
+ secret, hash = testcase('setUp').get_sample_hash()
+ if secret: # don't select blank passwords, especially under django 1.4/1.5
+ break
+ other = 'dontletmein'
# User.set_password() - n/a
@@ -582,7 +663,7 @@ class DjangoBehaviorTest(_ExtensionTest):
#-------------------------------------------------------
# identify_hasher() recognizes known hash
#-------------------------------------------------------
- if has_django14 and patched:
+ if has_identify_hasher:
self.assertTrue(is_password_usable(hash))
name = hasher_to_passlib_name(identify_hasher(hash).algorithm)
self.assertEqual(name, scheme)
@@ -808,18 +889,70 @@ class DjangoExtensionTest(_ExtensionTest):
# eoc
#===================================================================
+from passlib.context import CryptContext
+class ContextWithHook(CryptContext):
+ """subclass which invokes update_hook(self) before major actions"""
+
+ @staticmethod
+ def update_hook(self):
+ pass
+
+ def encrypt(self, *args, **kwds):
+ self.update_hook(self)
+ return super(ContextWithHook, self).encrypt(*args, **kwds)
+
+ def verify(self, *args, **kwds):
+ self.update_hook(self)
+ return super(ContextWithHook, self).verify(*args, **kwds)
+
# hack up the some of the real django tests to run w/ extension loaded,
# to ensure we mimic their behavior.
if has_django14:
- from django.contrib.auth.tests.hashers import TestUtilsHashPass as _TestHashers
- class HashersTest(_TestHashers, _ExtensionSupport):
+ from passlib.tests.utils import patchAttr
+ if DJANGO_VERSION >= (1,6):
+ from django.contrib.auth.tests import test_hashers as _thmod
+ else:
+ from django.contrib.auth.tests import hashers as _thmod
+
+ class HashersTest(_thmod.TestUtilsHashPass, _ExtensionSupport):
+ """run django's hasher unittests against passlib's extension
+ and workalike implementations"""
def setUp(self):
- # omitted orig setup, loading hashers our own way
+ # 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
+
+ # update test module to use our versions of some hasher funcs
+ from django.contrib.auth import hashers
+ for attr in ["make_password",
+ "check_password",
+ "identify_hasher",
+ "get_hasher"]:
+ patchAttr(self, _thmod, attr, getattr(hashers, attr))
+
+ # django 1.5 tests expect empty django_des_crypt salt field
+ if DJANGO_VERSION > (1,4):
+ from passlib.hash import django_des_crypt
+ patchAttr(self, django_des_crypt, "use_duplicate_salt", False)
+
+ # hack: need password_context to keep up to date with hasher.iterations
+ if DJANGO_VERSION >= (1,6):
+ def update_hook(self):
+ rounds = _thmod.get_hasher("pbkdf2_sha256").iterations
+ self.update(
+ django_pbkdf2_sha256__min_rounds=rounds,
+ django_pbkdf2_sha256__max_rounds=rounds,
+ )
+ patchAttr(self, password_context, "__class__", ContextWithHook)
+ patchAttr(self, password_context, "update_hook", update_hook)
+
+ # omitting this test, since it depends on updated to django hasher settings
+ test_pbkdf2_upgrade_new_hasher = lambda self: self.skipTest("omitted by passlib")
+
def tearDown(self):
self.unload_extension()
super(HashersTest, self).tearDown()
- del _TestHashers
HashersTest = skipUnless(TEST_MODE("default"),
"requires >= 'default' test mode")(HashersTest)
diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py
index 4845824..293a5da 100644
--- a/passlib/tests/test_handlers.py
+++ b/passlib/tests/test_handlers.py
@@ -7,6 +7,7 @@ from __future__ import with_statement
import hashlib
import logging; log = logging.getLogger(__name__)
import os
+import sys
import warnings
# site
# pkg
@@ -37,7 +38,19 @@ def get_handler_case(scheme):
name = "%s_%s_test" % (scheme, backend)
else:
name = "%s_test" % scheme
- return globals()[name]
+ try:
+ return globals()[name]
+ except KeyError:
+ pass
+ for suffix in ("handlers_django",):
+ modname = "passlib.tests.test_" + suffix
+ __import__(modname)
+ mod = sys.modules[modname]
+ try:
+ return getattr(mod, name)
+ except AttributeError:
+ pass
+ raise KeyError("test case %r not found" % name)
#=============================================================================
# apr md5 crypt
@@ -689,259 +702,6 @@ des_crypt_os_crypt_test, des_crypt_builtin_test = \
_des_crypt_test.create_backend_cases(["os_crypt","builtin"])
#=============================================================================
-# django
-#=============================================================================
-class _DjangoHelper(object):
- # NOTE: not testing against Django < 1.0 since it doesn't support
- # most of these hash formats.
-
- # flag if hash wasn't added until Django 1.4
- requires14 = False
-
- def fuzz_verifier_django(self):
- from passlib.tests.test_ext_django import has_django1, has_django14
- if not has_django1:
- return None
- if self.requires14 and not has_django14:
- return None
- from django.contrib.auth.models import check_password
- def verify_django(secret, hash):
- "django/check_password"
- if has_django14 and not secret:
- return "skip"
- if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"):
- hash = hash.replace("$$2y$", "$$2a$")
- return check_password(secret, hash)
- return verify_django
-
- def test_90_django_reference(self):
- "run known correct hashes through Django's check_password()"
- from passlib.tests.test_ext_django import has_django1, has_django14
- if self.requires14 and not has_django14:
- raise self.skipTest("Django >= 1.4 not installed")
- if not has_django1:
- raise self.skipTest("Django >= 1.0 not installed")
- from django.contrib.auth.models import check_password
- assert self.known_correct_hashes
- for secret, hash in self.iter_known_hashes():
- if has_django14 and not secret:
- # django 1.4 rejects empty passwords
- self.assertFalse(check_password(secret, hash),
- "empty string should not have verified")
- continue
- self.assertTrue(check_password(secret, hash),
- "secret=%r hash=%r failed to verify" %
- (secret, hash))
- self.assertFalse(check_password('x' + secret, hash),
- "mangled secret=%r hash=%r incorrect verified" %
- (secret, hash))
-
- def test_91_django_generation(self):
- "test against output of Django's make_password()"
- from passlib.tests.test_ext_django import has_django14
- if not has_django14:
- raise self.skipTest("Django >= 1.4 not installed")
- from passlib.utils import tick
- from django.contrib.auth.hashers import make_password
- name = self.handler.django_name # set for all the django_* handlers
- end = tick() + self.max_fuzz_time/2
- while tick() < end:
- secret, other = self.get_fuzz_password_pair()
- if not secret: # django 1.4 rejects empty passwords.
- continue
- hash = make_password(secret, hasher=name)
- self.assertTrue(self.do_identify(hash))
- self.assertTrue(self.do_verify(secret, hash))
- self.assertFalse(self.do_verify(other, hash))
-
-class django_disabled_test(HandlerCase):
- "test django_disabled"
- handler = hash.django_disabled
- is_disabled_handler = True
-
- known_correct_hashes = [
- # *everything* should hash to "!", and nothing should verify
- ("password", "!"),
- ("", "!"),
- (UPASS_TABLE, "!"),
- ]
-
-class django_des_crypt_test(HandlerCase, _DjangoHelper):
- "test django_des_crypt"
- handler = hash.django_des_crypt
- secret_size = 8
-
- known_correct_hashes = [
- # ensures only first two digits of salt count.
- ("password", 'crypt$c2$c2M87q...WWcU'),
- ("password", 'crypt$c2e86$c2M87q...WWcU'),
- ("passwordignoreme", 'crypt$c2.AZ$c2M87q...WWcU'),
-
- # ensures utf-8 used for unicode
- (UPASS_USD, 'crypt$c2e86$c2hN1Bxd6ZiWs'),
- (UPASS_TABLE, 'crypt$0.aQs$0.wB.TT0Czvlo'),
- (u("hell\u00D6"), "crypt$sa$saykDgk3BPZ9E"),
-
- # prevent regression of issue 22
- ("foo", 'crypt$MNVY.9ajgdvDQ$MNVY.9ajgdvDQ'),
- ]
-
- known_alternate_hashes = [
- # ensure django 1.4 empty salt field is accepted;
- # but that salt field is re-filled (for django 1.0 compatibility)
- ('crypt$$c2M87q...WWcU', "password", 'crypt$c2$c2M87q...WWcU'),
- ]
-
- known_unidentified_hashes = [
- 'sha1$aa$bb',
- ]
-
- known_malformed_hashes = [
- # checksum too short
- 'crypt$c2$c2M87q',
-
- # salt must be >2
- 'crypt$f$c2M87q...WWcU',
-
- # make sure first 2 chars of salt & chk field agree.
- 'crypt$ffe86$c2M87q...WWcU',
- ]
-
-class django_salted_md5_test(HandlerCase, _DjangoHelper):
- "test django_salted_md5"
- handler = hash.django_salted_md5
-
- known_correct_hashes = [
- # test extra large salt
- ("password", 'md5$123abcdef$c8272612932975ee80e8a35995708e80'),
-
- # test django 1.4 alphanumeric salt
- ("test", 'md5$3OpqnFAHW5CT$54b29300675271049a1ebae07b395e20'),
-
- # ensures utf-8 used for unicode
- (UPASS_USD, 'md5$c2e86$92105508419a81a6babfaecf876a2fa0'),
- (UPASS_TABLE, 'md5$d9eb8$01495b32852bffb27cf5d4394fe7a54c'),
- ]
-
- known_unidentified_hashes = [
- 'sha1$aa$bb',
- ]
-
- known_malformed_hashes = [
- # checksum too short
- 'md5$aa$bb',
- ]
-
- def fuzz_setting_salt_size(self):
- # workaround for django14 regression --
- # 1.4 won't accept hashes with empty salt strings, unlike 1.3 and earlier.
- # looks to be fixed in a future release -- https://code.djangoproject.com/ticket/18144
- # for now, we avoid salt_size==0 under 1.4
- handler = self.handler
- from passlib.tests.test_ext_django import has_django14
- default = handler.default_salt_size
- assert handler.min_salt_size == 0
- lower = 1 if has_django14 else 0
- upper = handler.max_salt_size or default*4
- return randintgauss(lower, upper, default, default*.5)
-
-class django_salted_sha1_test(HandlerCase, _DjangoHelper):
- "test django_salted_sha1"
- handler = hash.django_salted_sha1
-
- known_correct_hashes = [
- # test extra large salt
- ("password",'sha1$123abcdef$e4a1877b0e35c47329e7ed7e58014276168a37ba'),
-
- # test django 1.4 alphanumeric salt
- ("test", 'sha1$bcwHF9Hy8lxS$6b4cfa0651b43161c6f1471ce9523acf1f751ba3'),
-
- # ensures utf-8 used for unicode
- (UPASS_USD, 'sha1$c2e86$0f75c5d7fbd100d587c127ef0b693cde611b4ada'),
- (UPASS_TABLE, 'sha1$6d853$ef13a4d8fb57aed0cb573fe9c82e28dc7fd372d4'),
-
- # generic password
- ("MyPassword", 'sha1$54123$893cf12e134c3c215f3a76bd50d13f92404a54d3'),
- ]
-
- known_unidentified_hashes = [
- 'md5$aa$bb',
- ]
-
- known_malformed_hashes = [
- # checksum too short
- 'sha1$c2e86$0f75',
- ]
-
- fuzz_setting_salt_size = get_method_function(django_salted_md5_test.fuzz_setting_salt_size)
-
-class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper):
- "test django_pbkdf2_sha256"
- handler = hash.django_pbkdf2_sha256
- requires14 = True
-
- known_correct_hashes = [
- #
- # custom - generated via django 1.4 hasher
- #
- ('not a password',
- 'pbkdf2_sha256$10000$kjVJaVz6qsnJ$5yPHw3rwJGECpUf70daLGhOrQ5+AMxIJdz1c3bqK1Rs='),
- (UPASS_TABLE,
- 'pbkdf2_sha256$10000$bEwAfNrH1TlQ$OgYUblFNUX1B8GfMqaCYUK/iHyO0pa7STTDdaEJBuY0='),
- ]
-
-class django_pbkdf2_sha1_test(HandlerCase, _DjangoHelper):
- "test django_pbkdf2_sha1"
- handler = hash.django_pbkdf2_sha1
- requires14 = True
-
- known_correct_hashes = [
- #
- # custom - generated via django 1.4 hashers
- #
- ('not a password',
- 'pbkdf2_sha1$10000$wz5B6WkasRoF$atJmJ1o+XfJxKq1+Nu1f1i57Z5I='),
- (UPASS_TABLE,
- 'pbkdf2_sha1$10000$KZKWwvqb8BfL$rw5pWsxJEU4JrZAQhHTCO+u0f5Y='),
- ]
-
-class django_bcrypt_test(HandlerCase, _DjangoHelper):
- "test django_bcrypt"
- handler = hash.django_bcrypt
- secret_size = 72
- requires14 = True
-
- known_correct_hashes = [
- #
- # just copied and adapted a few test vectors from bcrypt (above),
- # since django_bcrypt is just a wrapper for the real bcrypt class.
- #
- ('', 'bcrypt$$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.'),
- ('abcdefghijklmnopqrstuvwxyz',
- 'bcrypt$$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq'),
- (UPASS_TABLE,
- 'bcrypt$$2a$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG'),
- ]
-
- # NOTE: the following have been cloned from _bcrypt_test()
-
- def populate_settings(self, kwds):
- # speed up test w/ lower rounds
- kwds.setdefault("rounds", 4)
- super(django_bcrypt_test, self).populate_settings(kwds)
-
- def fuzz_setting_rounds(self):
- # decrease default rounds for fuzz testing to speed up volume.
- return randintgauss(5, 8, 6, 1)
-
- def fuzz_setting_ident(self):
- # omit multi-ident tests, only $2a$ counts for this class
- return None
-
-django_bcrypt_test = skipUnless(hash.bcrypt.has_backend(),
- "no bcrypt backends available")(django_bcrypt_test)
-
-#=============================================================================
# fshp
#=============================================================================
class fshp_test(HandlerCase):
diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py
new file mode 100644
index 0000000..ac5d1f9
--- /dev/null
+++ b/passlib/tests/test_handlers_django.py
@@ -0,0 +1,297 @@
+"""passlib.tests.test_handlers_django - tests for passlib hash algorithms"""
+#=============================================================================
+# imports
+#=============================================================================
+from __future__ import with_statement
+# core
+import hashlib
+import logging; log = logging.getLogger(__name__)
+import os
+import warnings
+# site
+# pkg
+from passlib import hash
+from passlib.utils import repeat_string
+from passlib.utils.compat import irange, PY3, u, get_method_function
+from passlib.tests.utils import TestCase, HandlerCase, skipUnless, \
+ TEST_MODE, b, catch_warnings, UserHandlerMixin, randintgauss, EncodingHandlerMixin
+from passlib.tests.test_handlers import UPASS_WAV, UPASS_USD, UPASS_TABLE
+# module
+
+#=============================================================================
+# django
+#=============================================================================
+class _DjangoHelper(object):
+ # NOTE: not testing against Django < 1.0 since it doesn't support
+ # most of these hash formats.
+
+ # flag if hash wasn't added until Django 1.4
+ requires14 = False
+
+ def fuzz_verifier_django(self):
+ from passlib.tests.test_ext_django import DJANGO_VERSION
+ if DJANGO_VERSION < (1,0):
+ return None
+ if self.requires14 and DJANGO_VERSION < (1,4):
+ return None
+ from django.contrib.auth.models import check_password
+ def verify_django(secret, hash):
+ "django/check_password"
+ if DJANGO_VERSION >= (1,4) and not secret:
+ return "skip"
+ if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"):
+ hash = hash.replace("$$2y$", "$$2a$")
+ if DJANGO_VERSION >= (1,5) and self.django_has_encoding_glitch and isinstance(secret, bytes):
+ # e.g. unsalted_md5 on 1.5 and higher try to combine
+ # salt + password before encoding to bytes, leading to ascii error.
+ # this works around that issue.
+ secret = secret.decode("utf-8")
+ return check_password(secret, hash)
+ return verify_django
+
+ def test_90_django_reference(self):
+ "run known correct hashes through Django's check_password()"
+ from passlib.tests.test_ext_django import has_django1, has_django14
+ if self.requires14 and not has_django14:
+ raise self.skipTest("Django >= 1.4 not installed")
+ if not has_django1:
+ raise self.skipTest("Django >= 1.0 not installed")
+ from django.contrib.auth.models import check_password
+ assert self.known_correct_hashes
+ for secret, hash in self.iter_known_hashes():
+ if has_django14 and not secret:
+ # django 1.4 rejects empty passwords
+ self.assertFalse(check_password(secret, hash),
+ "empty string should not have verified")
+ continue
+ self.assertTrue(check_password(secret, hash),
+ "secret=%r hash=%r failed to verify" %
+ (secret, hash))
+ self.assertFalse(check_password('x' + secret, hash),
+ "mangled secret=%r hash=%r incorrect verified" %
+ (secret, hash))
+
+ django_has_encoding_glitch = False
+
+ def test_91_django_generation(self):
+ "test against output of Django's make_password()"
+ from passlib.tests.test_ext_django import DJANGO_VERSION
+ if DJANGO_VERSION < (1,4):
+ raise self.skipTest("Django >= 1.4 not installed")
+ from passlib.utils import tick
+ from django.contrib.auth.hashers import make_password
+ name = self.handler.django_name # set for all the django_* handlers
+ end = tick() + self.max_fuzz_time/2
+ while tick() < end:
+ secret, other = self.get_fuzz_password_pair()
+ if not secret: # django 1.4 rejects empty passwords.
+ continue
+ if DJANGO_VERSION >= (1,5) and self.django_has_encoding_glitch and isinstance(secret, bytes):
+ # e.g. unsalted_md5 on 1.5 and higher try to combine
+ # salt + password before encoding to bytes, leading to ascii error.
+ # this works around that issue.
+ secret = secret.decode("utf-8")
+ hash = make_password(secret, hasher=name)
+ self.assertTrue(self.do_identify(hash))
+ self.assertTrue(self.do_verify(secret, hash))
+ self.assertFalse(self.do_verify(other, hash))
+
+class django_disabled_test(HandlerCase):
+ "test django_disabled"
+ handler = hash.django_disabled
+ is_disabled_handler = True
+
+ known_correct_hashes = [
+ # *everything* should hash to "!", and nothing should verify
+ ("password", "!"),
+ ("", "!"),
+ (UPASS_TABLE, "!"),
+ ]
+
+ known_alternate_hashes = [
+ # django 1.6 appends random alpnum string
+ ("!9wa845vn7098ythaehasldkfj", "password", "!"),
+ ]
+
+class django_des_crypt_test(HandlerCase, _DjangoHelper):
+ "test django_des_crypt"
+ handler = hash.django_des_crypt
+ secret_size = 8
+
+ known_correct_hashes = [
+ # ensures only first two digits of salt count.
+ ("password", 'crypt$c2$c2M87q...WWcU'),
+ ("password", 'crypt$c2e86$c2M87q...WWcU'),
+ ("passwordignoreme", 'crypt$c2.AZ$c2M87q...WWcU'),
+
+ # ensures utf-8 used for unicode
+ (UPASS_USD, 'crypt$c2e86$c2hN1Bxd6ZiWs'),
+ (UPASS_TABLE, 'crypt$0.aQs$0.wB.TT0Czvlo'),
+ (u("hell\u00D6"), "crypt$sa$saykDgk3BPZ9E"),
+
+ # prevent regression of issue 22
+ ("foo", 'crypt$MNVY.9ajgdvDQ$MNVY.9ajgdvDQ'),
+ ]
+
+ known_alternate_hashes = [
+ # ensure django 1.4 empty salt field is accepted;
+ # but that salt field is re-filled (for django 1.0 compatibility)
+ ('crypt$$c2M87q...WWcU', "password", 'crypt$c2$c2M87q...WWcU'),
+ ]
+
+ known_unidentified_hashes = [
+ 'sha1$aa$bb',
+ ]
+
+ known_malformed_hashes = [
+ # checksum too short
+ 'crypt$c2$c2M87q',
+
+ # salt must be >2
+ 'crypt$f$c2M87q...WWcU',
+
+ # make sure first 2 chars of salt & chk field agree.
+ 'crypt$ffe86$c2M87q...WWcU',
+ ]
+
+class django_salted_md5_test(HandlerCase, _DjangoHelper):
+ "test django_salted_md5"
+ handler = hash.django_salted_md5
+
+ django_has_encoding_glitch = True
+
+ known_correct_hashes = [
+ # test extra large salt
+ ("password", 'md5$123abcdef$c8272612932975ee80e8a35995708e80'),
+
+ # test django 1.4 alphanumeric salt
+ ("test", 'md5$3OpqnFAHW5CT$54b29300675271049a1ebae07b395e20'),
+
+ # ensures utf-8 used for unicode
+ (UPASS_USD, 'md5$c2e86$92105508419a81a6babfaecf876a2fa0'),
+ (UPASS_TABLE, 'md5$d9eb8$01495b32852bffb27cf5d4394fe7a54c'),
+ ]
+
+ known_unidentified_hashes = [
+ 'sha1$aa$bb',
+ ]
+
+ known_malformed_hashes = [
+ # checksum too short
+ 'md5$aa$bb',
+ ]
+
+ def fuzz_setting_salt_size(self):
+ # workaround for django14 regression --
+ # 1.4 won't accept hashes with empty salt strings, unlike 1.3 and earlier.
+ # looks to be fixed in a future release -- https://code.djangoproject.com/ticket/18144
+ # for now, we avoid salt_size==0 under 1.4
+ handler = self.handler
+ from passlib.tests.test_ext_django import has_django14
+ default = handler.default_salt_size
+ assert handler.min_salt_size == 0
+ lower = 1 if has_django14 else 0
+ upper = handler.max_salt_size or default*4
+ return randintgauss(lower, upper, default, default*.5)
+
+class django_salted_sha1_test(HandlerCase, _DjangoHelper):
+ "test django_salted_sha1"
+ handler = hash.django_salted_sha1
+
+ django_has_encoding_glitch = True
+
+ known_correct_hashes = [
+ # test extra large salt
+ ("password",'sha1$123abcdef$e4a1877b0e35c47329e7ed7e58014276168a37ba'),
+
+ # test django 1.4 alphanumeric salt
+ ("test", 'sha1$bcwHF9Hy8lxS$6b4cfa0651b43161c6f1471ce9523acf1f751ba3'),
+
+ # ensures utf-8 used for unicode
+ (UPASS_USD, 'sha1$c2e86$0f75c5d7fbd100d587c127ef0b693cde611b4ada'),
+ (UPASS_TABLE, 'sha1$6d853$ef13a4d8fb57aed0cb573fe9c82e28dc7fd372d4'),
+
+ # generic password
+ ("MyPassword", 'sha1$54123$893cf12e134c3c215f3a76bd50d13f92404a54d3'),
+ ]
+
+ known_unidentified_hashes = [
+ 'md5$aa$bb',
+ ]
+
+ known_malformed_hashes = [
+ # checksum too short
+ 'sha1$c2e86$0f75',
+ ]
+
+ fuzz_setting_salt_size = get_method_function(django_salted_md5_test.fuzz_setting_salt_size)
+
+class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper):
+ "test django_pbkdf2_sha256"
+ handler = hash.django_pbkdf2_sha256
+ requires14 = True
+
+ known_correct_hashes = [
+ #
+ # custom - generated via django 1.4 hasher
+ #
+ ('not a password',
+ 'pbkdf2_sha256$10000$kjVJaVz6qsnJ$5yPHw3rwJGECpUf70daLGhOrQ5+AMxIJdz1c3bqK1Rs='),
+ (UPASS_TABLE,
+ 'pbkdf2_sha256$10000$bEwAfNrH1TlQ$OgYUblFNUX1B8GfMqaCYUK/iHyO0pa7STTDdaEJBuY0='),
+ ]
+
+class django_pbkdf2_sha1_test(HandlerCase, _DjangoHelper):
+ "test django_pbkdf2_sha1"
+ handler = hash.django_pbkdf2_sha1
+ requires14 = True
+
+ known_correct_hashes = [
+ #
+ # custom - generated via django 1.4 hashers
+ #
+ ('not a password',
+ 'pbkdf2_sha1$10000$wz5B6WkasRoF$atJmJ1o+XfJxKq1+Nu1f1i57Z5I='),
+ (UPASS_TABLE,
+ 'pbkdf2_sha1$10000$KZKWwvqb8BfL$rw5pWsxJEU4JrZAQhHTCO+u0f5Y='),
+ ]
+
+class django_bcrypt_test(HandlerCase, _DjangoHelper):
+ "test django_bcrypt"
+ handler = hash.django_bcrypt
+ secret_size = 72
+ requires14 = True
+
+ known_correct_hashes = [
+ #
+ # just copied and adapted a few test vectors from bcrypt (above),
+ # since django_bcrypt is just a wrapper for the real bcrypt class.
+ #
+ ('', 'bcrypt$$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.'),
+ ('abcdefghijklmnopqrstuvwxyz',
+ 'bcrypt$$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq'),
+ (UPASS_TABLE,
+ 'bcrypt$$2a$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG'),
+ ]
+
+ # NOTE: the following have been cloned from _bcrypt_test()
+
+ def populate_settings(self, kwds):
+ # speed up test w/ lower rounds
+ kwds.setdefault("rounds", 4)
+ super(django_bcrypt_test, self).populate_settings(kwds)
+
+ def fuzz_setting_rounds(self):
+ # decrease default rounds for fuzz testing to speed up volume.
+ return randintgauss(5, 8, 6, 1)
+
+ def fuzz_setting_ident(self):
+ # omit multi-ident tests, only $2a$ counts for this class
+ return None
+
+django_bcrypt_test = skipUnless(hash.bcrypt.has_backend(),
+ "no bcrypt backends available")(django_bcrypt_test)
+
+#=============================================================================
+# eof
+#=============================================================================
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index 29254c8..2965689 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -203,6 +203,22 @@ def quicksleep(delay):
#=============================================================================
# custom test harness
#=============================================================================
+
+def patchAttr(test, obj, attr, value):
+ """monkeypatch object value, restoring original on cleanup"""
+ try:
+ orig = getattr(obj, attr)
+ except AttributeError:
+ def cleanup():
+ try:
+ delattr(obj, attr)
+ except AttributeError:
+ pass
+ test.addCleanup(cleanup)
+ else:
+ test.addCleanup(setattr, obj, attr, orig)
+ setattr(obj, attr, value)
+
class TestCase(_TestCase):
"""passlib-specific test case class
diff --git a/tox.ini b/tox.ini
index 016e41b..c89f5ac 100644
--- a/tox.ini
+++ b/tox.ini
@@ -23,9 +23,8 @@
# testing of m2crypto integration - done in py27 test
#
# testing of django integration - split across various cpython tests:
-# py25 - tests django 1.3
-# py26 - tests no django
-# py27 - tests latest django
+# py27,py33 - tests latest django
+# djangoXX - tests specific django versions
#
# testing of bcrypt backends - split across various cpython tests:
# py25 - tests builtin bcrypt
@@ -36,8 +35,8 @@
# global config
#===========================================================================
[tox]
-minversion=1.3
-envlist = py27,py32,py25,py26,py31,py33,pypy1.5,pypy16,pypy17,pypy18,pypy19,jython,gae25,gae27
+minversion=1.4
+envlist = py27,py33,py25,py26,py31,py32,pypy,pypy3,django12,django13,django14,django15,jython,gae25,gae27
#===========================================================================
# stock CPython VMs
@@ -54,13 +53,14 @@ deps =
unittest2
[testenv:py25]
+# NOTE: unittest2 omitted, to test unittest backport code
deps =
nose
coverage
- # unittest2 omitted, to test backport code
- django<1.4
[testenv:py27]
+# NOTE: M2Crypto requires swig & libssl-dev,
+# a number of packages required C compiler & python-dev
deps =
nose
coverage
@@ -82,28 +82,75 @@ deps =
unittest2py3k
[testenv:py33]
+# TODO: test bcrypt library w/ py3 compatibility
deps =
nose
coverage
unittest2py3k
+ django
#===========================================================================
-# PyPy VM - all releases currently target Python 2.7
+# django integration testing
#===========================================================================
-[testenv:pypy15]
-basepython = pypy1.5
+[testenv:django12]
+deps =
+ nose
+ unittest2
+ django<1.3
+commands =
+ nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-[testenv:pypy16]
-basepython = pypy1.6
+[testenv:django13]
+deps =
+ nose
+ unittest2
+ django<1.4
+commands =
+ nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-[testenv:pypy17]
-basepython = pypy1.7
+[testenv:django14]
+deps =
+ nose
+ unittest2
+ django<1.5
+commands =
+ nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-[testenv:pypy18]
-basepython = pypy1.8
+[testenv:django15]
+deps =
+ nose
+ unittest2
+ django<1.6
+commands =
+ nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django}
+
+[testenv:django]
+deps =
+ nose
+ unittest2
+ django
+commands =
+ nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django}
+
+[testenv:django-py3]
+basepython = python3
+deps =
+ nose
+ unittest2py3k
+ django
+commands =
+ nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django}
-[testenv:pypy19]
-basepython = pypy1.9
+#===========================================================================
+# PyPy VM - all releases currently target Python 2.7
+#===========================================================================
+[testenv:pypy]
+# pypy (as of v1.6 - v2.2) targets Python 2.7
+basepython = pypy
+
+[testenv:pypy3]
+# pypy3 (as of v2.1b1) targets Python 3.2
+basepython = pypy3
#===========================================================================
# Jython - no special directives, currently same as py25
@@ -112,7 +159,11 @@ basepython = pypy1.9
#===========================================================================
# Google App Engine integration
#===========================================================================
+
[testenv:gae25]
+# NOTE: google is deprecating py25 support, per
+# https://developers.google.com/appengine/docs/python/python25/diff27
+# and so this test can probably be removed sometime after 2014-01-01
basepython = python2.5
deps =
# FIXME: getting all kinds of errors when using nosegae 0.2.0 :(