summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2011-09-14 14:42:39 -0400
committerEli Collins <elic@assurancetechnologies.com>2011-09-14 14:42:39 -0400
commitf21eb07e034690520afc8e844d91b14314f31a7e (patch)
treeeb6599776f73501ec366ad12770c7dc4e32ac38e
parentb130100d2f8e7d10e3c810b7a92375bd06af59b6 (diff)
downloadpasslib-f21eb07e034690520afc8e844d91b14314f31a7e.tar.gz
improvements to passlib.ext.django
even though it hasn't been officially documented, some people are using it, so... major ----- * DEFAULT_CTX now uses SHA512-Crypt instead of PBKDF2-HMAC-SHA256, this should be natively supported on a larger number of platforms. * added full unittest suite for passlib.ext.django: - checks monkeypatch implementation - checks full plugin behavior - STOCK_CTX is compared against official Django behavior minor ----- * ``set_django_password_context()`` now patches ``django.contrib.auth.models.check_password()`` as well as User methods. * now exposes active context as ``User.password_context`` when patch is enabled. * replacement ``User.check_password`` now handles None and unusable passwords explicitly, even if context doesn't include support for django_disabled.
-rw-r--r--CHANGES4
-rw-r--r--docs/lib/passlib.ext.django.rst70
-rw-r--r--passlib/ext/django/models.py2
-rw-r--r--passlib/ext/django/utils.py140
-rw-r--r--passlib/tests/test_drivers.py5
-rw-r--r--passlib/tests/test_ext_django.py538
-rw-r--r--passlib/tests/utils.py15
7 files changed, 706 insertions, 68 deletions
diff --git a/CHANGES b/CHANGES
index 67bd242..d4564a7 100644
--- a/CHANGES
+++ b/CHANGES
@@ -17,7 +17,7 @@ Release History
:mod:`Hash64 <passlib.utils.h64>` characters in it's salts;
previously it accepted only lower-case hexidecimal characters [issue 22].
- * additional unittests added for all
+ * Additional unittests added for all
standard :doc:`Django hashes </lib/passlib.hash.django_std>`.
* :class:`django_des_crypt` now rejects hashes where salt and checksum
@@ -30,7 +30,7 @@ Release History
* *bugfix:* fixed exception in :meth:`CryptPolicy.iter_config`
that occurred when iterating over deprecation options.
- * added documentation for the (mistakenly undocumented)
+ * Added documentation for the (mistakenly undocumented)
:meth:`CryptContext.verify_and_update` method.
**1.5.1** (2011-08-17)
diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst
index 69e8970..5d6a61c 100644
--- a/docs/lib/passlib.ext.django.rst
+++ b/docs/lib/passlib.ext.django.rst
@@ -26,9 +26,15 @@ It contains a Django app which allows you to override
Django's :doc:`default <passlib.hash.django_std>` password hash formats
with any passlib :doc:`CryptContext <passlib.context>`.
By default, it comes configured to add support for
-:class:`~passlib.hash.pbkdf2_sha256`, and will automatically
+:class:`~passlib.hash.sha512_crypt`, and will automatically
upgrade all existing Django passwords as your users log in.
+.. note::
+
+ SHA512-Crypt was chosen as probably the best choice for
+ the average Django deployment. Accelerated implementations
+ are available on most Linux systems, as well as Google App Engine.
+
Installation
=============
Installation is simple, just add ``passlib.ext.django`` to
@@ -58,21 +64,16 @@ You can set the following options in django ``settings.py``:
This is the default behavior if ``PASSLIB_CONTEXT`` is not set.
- The exact default policy can be found at
- :data:`passlib.ext.django.utils.DEFAULT_CTX`.
+ The exact default policy can be found in
+ :data:`~passlib.ext.django.utils.DEFAULT_CTX`.
* ``None``, in which case this app will do nothing when django is loaded.
- * A :class:`~passlib.context.CryptContext`
- instance which will be used in place of the normal Django password
- hash routines.
-
- It is *strongly* recommended to use a context which will support
- the existing Django hashes.
-
- * A multiline config string suitable for passing to
+ * A multiline configuration string suitable for passing to
:meth:`passlib.context.CryptPolicy.from_string`.
- This will be parsed and used much like a :class:`!CryptContext` instance.
+ It is *strongly* recommended to use a configuration which will support
+ the existing Django hashes
+ (see :data:`~passlib.ext.django.utils.STOCK_CTX`).
``PASSLIB_GET_CATEGORY``
@@ -102,28 +103,47 @@ Django's password hashes:
This is a string containing the default hashing policy
that will be used by this application if none is specified
- via ``settings.PASSLIB_CONTEXT``.
+ via ``settings.PASSLIB_CONTEXT``.
It defaults to the following::
-
+
[passlib]
schemes =
- pbkdf2_sha256,
+ sha512_crypt,
django_salted_sha1, django_salted_md5,
django_des_crypt, hex_md5,
django_disabled
-
- default = pbkdf2_sha256
-
+
+ default = sha512_crypt
+
deprecated =
django_salted_sha1, django_salted_md5,
django_des_crypt, hex_md5
-
+
all__vary_rounds = 5%%
-
- pbkdf2_sha256__default_rounds = 4000
- staff__pbkdf2_sha256__default_rounds = 8000
- superuser__pbkdf2_sha256__default_rounds = 10000
-
+
+ sha512_crypt__default_rounds = 15000
+ staff__sha512_crypt__default_rounds = 25000
+ superuser__sha512_crypt__default_rounds = 35000
+
+.. data:: STOCK_CTX
+
+ This is a string containing the a hashing policy
+ which should be exactly the same as Django's default behavior.
+ It is mainly useful as a template for building off of
+ when defining your own custom hashing policy
+ via ``settings.PASSLIB_CONTEXT``.
+ It defaults to the following::
+
+ [passlib]
+ schemes =
+ django_salted_sha1, django_salted_md5,
+ django_des_crypt, hex_md5,
+ django_disabled
+
+ default = django_salted_sha1
+
+ deprecated = hex_md5
+
.. autofunction:: get_category
-.. autofunction:: set_django_password_context \ No newline at end of file
+.. autofunction:: set_django_password_context
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py
index aaf13ee..76117bd 100644
--- a/passlib/ext/django/models.py
+++ b/passlib/ext/django/models.py
@@ -29,6 +29,8 @@ def patch():
#parse & validate input value
if not ctx:
+ # remove any patching that was already set, just in case.
+ set_django_password_context(None)
return
if ctx == "passlib-default":
ctx = DEFAULT_CTX
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 41d4196..200bfca 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -10,6 +10,7 @@
#imports
#===================================================================
#site
+from warnings import warn
#pkg
from passlib.utils import is_crypt_context, bytes
#local
@@ -19,61 +20,68 @@ __all__ = [
]
#===================================================================
-#lazy import
+#lazy imports
#===================================================================
-#NOTE: doing this lazily so sphinx can crawl module without
-# having django import problems.
-User = None #imported from django.contrib.auth.models
+_dam = None #django.contrib.auth.models reference
-def _lazy_import():
- global User
- if User is None:
- from django.contrib.auth.models import User
+def _import_django():
+ global _dam
+ if _dam is None:
+ import django.contrib.auth.models as _dam
+ return _dam
#===================================================================
#constants
#===================================================================
+#: base context mirroring django's setup
+STOCK_CTX = """
+[passlib]
+schemes =
+ django_salted_sha1, django_salted_md5,
+ django_des_crypt, hex_md5,
+ django_disabled
+
+default = django_salted_sha1
+
+deprecated = hex_md5
+"""
+
#: default context used by app
DEFAULT_CTX = """
[passlib]
schemes =
+ sha512_crypt,
pbkdf2_sha256,
django_salted_sha1, django_salted_md5,
django_des_crypt, hex_md5,
django_disabled
-default = pbkdf2_sha256
+default = sha512_crypt
deprecated =
+ pbkdf2_sha256,
django_salted_sha1, django_salted_md5,
django_des_crypt, hex_md5
all__vary_rounds = 5%%
-pbkdf2_sha256__default_rounds = 4000
-staff__pbkdf2_sha256__default_rounds = 8000
-superuser__pbkdf2_sha256__default_rounds = 10000
+sha512_crypt__default_rounds = 15000
+staff__sha512_crypt__default_rounds = 25000
+superuser__sha512_crypt__default_rounds = 35000
"""
#===================================================================
-#monkeypatch framework
+# helpers
#===================================================================
-# NOTE: this moneypatcher was written to be useful
-# outside of this module, and re-invokable,
-# which is why it tries so hard to maintain
-# sanity about it's patch state.
-
-_django_patch_state = None
-
def get_category(user):
"""default get_category() implementation used by set_django_password_context
-
+
this is the function used if ``settings.PASSLIB_GET_CONTEXT`` is not
specified.
-
+
it maps superusers to the ``"superuser"`` category,
staff to the ``"staff"`` category,
and all others to the default category.
@@ -88,12 +96,23 @@ def um(func):
"unwrap method (eg User.set_password -> orig func)"
return func.im_func
+#===================================================================
+# monkeypatch framework
+#===================================================================
+
+# NOTE: this moneypatcher was written to be useful
+# outside of this module, and re-invokable,
+# which is why it tries so hard to maintain
+# sanity about it's patch state.
+
+_django_patch_state = None #dict holding refs to undo patch
+
def set_django_password_context(context=None, get_category=get_category):
"""monkeypatches :mod:`!django.contrib.auth` to use specified password context.
:arg context:
Passlib context to use for Django password hashing.
- If ``None``, restores original django functions.
+ If ``None``, restores original Django functions.
In order to support existing hashes,
any context specified should include
@@ -109,28 +128,46 @@ def set_django_password_context(context=None, get_category=get_category):
By default, uses a function which returns ``"superuser"``
for superusers, and ``"staff"`` for staff.
+
+ This function monkeypatches the following parts of Django:
+
+ * :func:`!django.contrib.auth.models.check_password`
+ * :meth:`!django.contrib.auth.models.User.check_password`
+ * :meth:`!django.contrib.auth.models.User.set_password`
+
+ It also stores the provided context in
+ :data:`!django.contrib.auth.models.User.password_context`,
+ for easy access.
"""
- global _django_patch_state, User
+ global _django_patch_state, _dam
+ _import_django()
state = _django_patch_state
- _lazy_import()
+ User = _dam.User
# issue warning if something else monkeypatched User
# while our patch was applied.
if state is not None:
- if um(User.set_password) is not state['set_password']:
- warning("another library has patched django's User.set_password")
- if um(User.check_password) is not state['check_password']:
- warning("another library has patched django's User.check_password")
+ if um(User.set_password) is not state['user_set_password']:
+ warn("another library has patched "
+ "django.contrib.auth.models:User.set_password")
+ if um(User.check_password) is not state['user_check_password']:
+ warn("another library has patched"
+ "django.contrib.auth.models:User.check_password")
+ if _dam.check_password is not state['models_check_password']:
+ warn("another library has patched"
+ "django.contrib.auth.models:check_password")
#check if we should just restore original state
if context is None:
if state is not None:
- User.pwd_context = None
- User.set_password = state['orig_set_password']
- User.check_password = state['orig_check_password']
+ del User.password_context
+ _dam.check_password = state['orig_models_check_password']
+ User.set_password = state['orig_user_set_password']
+ User.check_password = state['orig_user_check_password']
_django_patch_state = None
return
+ #validate inputs
if not is_crypt_context(context):
raise TypeError("context must be CryptContext instance or None: %r" %
(type(context),))
@@ -138,8 +175,9 @@ def set_django_password_context(context=None, get_category=get_category):
#backup original state if this is first call
if state is None:
_django_patch_state = state = dict(
- orig_check_password = um(User.check_password),
- orig_set_password = um(User.set_password),
+ orig_user_check_password = um(User.check_password),
+ orig_user_set_password = um(User.set_password),
+ orig_models_check_password = _dam.check_password,
)
#prepare replacements
@@ -153,19 +191,45 @@ def set_django_password_context(context=None, get_category=get_category):
def check_password(user, raw_password):
"passlib replacement for User.check_password()"
+ if raw_password is None:
+ return False
hash = user.password
+ if not hash or hash == _dam.UNUSABLE_PASSWORD:
+ return False
cat = get_category(user) if get_category else None
ok, new_hash = context.verify_and_update(raw_password, hash,
category=cat)
- if ok and new_hash:
+ if ok and new_hash is not None:
user.password = new_hash
user.save()
return ok
+ def raw_check_password(raw_password, enc_password):
+ "passlib replacement for check_password()"
+ if not enc_password or enc_password == _dam.UNUSABLE_PASSWORD:
+ raise ValueError("no password hash specified")
+ return context.verify(raw_password, enc_password)
+
#set new state
- User.pwd_context = context #just to make it easy to get to.
- User.set_password = state['set_password'] = set_password
- User.check_password = state['check_password'] = check_password
+ User.password_context = context
+ User.set_password = state['user_set_password'] = set_password
+ User.check_password = state['user_check_password'] = check_password
+ _dam.check_password = state['models_check_password'] = raw_check_password
+ state['context' ] = context
+ state['get_category'] = get_category
+
+##def get_django_password_context():
+## """return current django password context
+##
+## This returns the current :class:`~passlib.context.CryptContext` instance
+## set by :func:`set_django_password_context`.
+## If not context has been set, returns ``None``.
+## """
+## global _django_patch_state
+## if _django_patch_state:
+## return _django_patch_state['context']
+## else:
+## return None
#===================================================================
#eof
diff --git a/passlib/tests/test_drivers.py b/passlib/tests/test_drivers.py
index 828f898..f39ef08 100644
--- a/passlib/tests/test_drivers.py
+++ b/passlib/tests/test_drivers.py
@@ -221,9 +221,10 @@ class _DjangoHelper(object):
from django.conf import settings
except ImportError:
return self.skipTest("Django not installed")
- settings.configure()
+ if not settings.configured:
+ settings.configure()
from django.contrib.auth.models import check_password
- for secret, hash in self.known_correct_hashes:
+ for secret, hash in self.all_correct_hashes:
self.assertTrue(check_password(secret, hash))
self.assertFalse(check_password('x' + secret, hash))
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
new file mode 100644
index 0000000..8a32664
--- /dev/null
+++ b/passlib/tests/test_ext_django.py
@@ -0,0 +1,538 @@
+"""test passlib.ext.django"""
+#=========================================================
+#imports
+#=========================================================
+from __future__ import with_statement
+#core
+import logging; log = logging.getLogger(__name__)
+import sys
+import warnings
+#site
+#pkg
+from passlib.context import CryptContext, CryptPolicy
+from passlib.apps import django_context
+from passlib.ext.django import utils
+from passlib.hash import sha256_crypt
+from passlib.tests.utils import TestCase, unittest, ut_version, catch_warnings
+import passlib.tests.test_drivers as td
+from passlib.utils import Undef
+from passlib.registry import get_crypt_handler
+#module
+
+#=========================================================
+# import & configure django settings,
+#=========================================================
+
+try:
+ from django.conf import settings, LazySettings
+ has_django = True
+except ImportError:
+ settings = None
+ has_django = False
+
+if has_django:
+ if not isinstance(settings, LazySettings):
+ #this could mean django has been configured somehow,
+ #which we don't want, since test cases reset and manipulate settings.
+ raise RuntimeError("expected django.conf.settings to be LazySettings: %r" % (settings,))
+
+ #else configure a blank settings instance for our unittests
+ settings.configure()
+
+def update_settings(**kwds):
+ for k,v in kwds.iteritems():
+ if v is Undef:
+ if hasattr(settings, k):
+ delattr(settings, k)
+ else:
+ setattr(settings, k, v)
+
+#=========================================================
+# and prepare helper to skip all relevant tests
+# if django isn't installed.
+#=========================================================
+def skipUnlessDjango(cls):
+ "helper to skip class if django not present"
+ if has_django:
+ return cls
+ if ut_version < 2:
+ return None
+ return unittest.skip("Django not installed")(cls)
+
+#=========================================================
+# mock user object
+#=========================================================
+if has_django:
+ import django.contrib.auth.models as dam
+
+ class FakeUser(dam.User):
+ "stub user object for testing"
+ #this mainly just overrides .save() to test commit behavior.
+
+ saved_password = None
+
+ def save(self):
+ self.saved_password = self.password
+
+#=========================================================
+# helper contexts
+#=========================================================
+
+# simple context which looks NOTHING like django,
+# so we can tell if patching worked.
+simple_context = CryptContext(
+ schemes = [ "md5_crypt", "des_crypt" ],
+ default = "md5_crypt",
+ deprecated = [ "des_crypt" ],
+)
+
+# some sample hashes
+sample1 = 'password'
+sample1_md5 = '$1$kAd49ifN$biuRAv1Tv0zGHyCv0uIqW.'
+sample1_des = 'PPPTDkiCeu/jM'
+sample1_sha1 = 'sha1$b215d$9ee0a66f84ef1ad99096355e788135f7e949bd41'
+
+# context for testing category funcs
+category_context = CryptContext(
+ schemes = [ "sha256_crypt" ],
+ sha256_crypt__rounds = 1000,
+ staff__sha256_crypt__rounds = 2000,
+ superuser__sha256_crypt__rounds = 3000,
+)
+
+def get_cc_rounds(**kwds):
+ "helper for testing category funcs"
+ user = FakeUser(**kwds)
+ user.set_password("placeholder")
+ return sha256_crypt.from_string(user.password).rounds
+
+#=========================================================
+# test utils
+#=========================================================
+class PatchTest(TestCase):
+ "test passlib.ext.django.utils:set_django_password_context"
+
+ case_prefix = "passlib.ext.django utils"
+
+ def assert_unpatched(self):
+ "helper to ensure django hasn't been patched"
+ state = utils._django_patch_state
+
+ #make sure we aren't currently patched
+ self.assertIs(state, None)
+
+ #make sure nothing else patches django
+ for func in [
+ dam.check_password,
+ dam.User.check_password,
+ dam.User.set_password,
+ ]:
+ self.assertEquals(func.__module__, "django.contrib.auth.models")
+ self.assertFalse(hasattr(dam.User, "password_context"))
+
+ def assert_patched(self, context=Undef):
+ "helper to ensure django HAS been patched"
+ state = utils._django_patch_state
+
+ #make sure we're patched
+ self.assertIsNot(state, None)
+
+ #make sure our methods are exposed
+ for func in [
+ dam.check_password,
+ dam.User.check_password,
+ dam.User.set_password,
+ ]:
+ self.assertEquals(func.__module__, "passlib.ext.django.utils")
+
+ #make sure methods match
+ self.assertIs(dam.check_password, state['models_check_password'])
+ self.assertIs(dam.User.check_password.im_func, state['user_check_password'])
+ self.assertIs(dam.User.set_password.im_func, state['user_set_password'])
+
+ #make sure context matches
+ obj = dam.User.password_context
+ self.assertIs(obj, state['context'])
+ if context is not Undef:
+ self.assertIs(obj, context)
+
+ #make sure old methods were stored
+ for key in [
+ "orig_models_check_password",
+ "orig_user_check_password",
+ "orig_user_set_password",
+ ]:
+ value = state[key]
+ self.assertEquals(value.__module__, "django.contrib.auth.models")
+
+ def setUp(self):
+ #reset to baseline, and verify
+ utils.set_django_password_context(None)
+ self.assert_unpatched()
+
+ def tearDown(self):
+ #reset to baseline, and verify
+ utils.set_django_password_context(None)
+ self.assert_unpatched()
+
+ def test_00_patch_control(self):
+ "test set_django_password_context patch/unpatch"
+
+ #check context=None has no effect
+ utils.set_django_password_context(None)
+ self.assert_unpatched()
+
+ #patch to use stock django context
+ utils.set_django_password_context(django_context)
+ self.assert_patched(context=django_context)
+
+ #try to remove patch
+ utils.set_django_password_context(None)
+ self.assert_unpatched()
+
+ def test_01_patch_control_detection(self):
+ "test set_django_password_context detection of foreign monkeypatches"
+ def dummy():
+ pass
+
+ with catch_warnings(record=True) as wlog:
+ warnings.simplefilter("always")
+
+ #patch to use stock django context
+ utils.set_django_password_context(django_context)
+ self.assert_patched(context=django_context)
+ self.assertEquals(len(wlog), 0)
+
+ #mess with User.set_password, make sure it's detected
+ dam.User.set_password = dummy
+ utils.set_django_password_context(django_context)
+ self.assert_patched(context=django_context)
+ self.assertEquals(len(wlog), 1)
+ self.assertWarningMatches(wlog.pop(),
+ message_re="^another library has patched.*User\.set_password$")
+
+ #mess with user.check_password, make sure it's detected
+ dam.User.check_password = dummy
+ utils.set_django_password_context(django_context)
+ self.assert_patched(context=django_context)
+ self.assertEquals(len(wlog), 1)
+ self.assertWarningMatches(wlog.pop(),
+ message_re="^another library has patched.*User\.check_password$")
+
+ #mess with user.check_password, make sure it's detected
+ dam.check_password = dummy
+ utils.set_django_password_context(django_context)
+ self.assert_patched(context=django_context)
+ self.assertEquals(len(wlog), 1)
+ self.assertWarningMatches(wlog.pop(),
+ message_re="^another library has patched.*models:check_password$")
+
+ def test_01_patch_bad_types(self):
+ "test set_django_password_context bad inputs"
+ set = utils.set_django_password_context
+ self.assertRaises(TypeError, set, CryptPolicy())
+ self.assertRaises(TypeError, set, "")
+
+ def test_02_models_check_password(self):
+ "test monkeypatched models.check_password()"
+
+ # patch to use simple context
+ utils.set_django_password_context(simple_context)
+ self.assert_patched(context=simple_context)
+
+ # check correct hashes pass
+ self.assertTrue(dam.check_password(sample1, sample1_des))
+ self.assertTrue(dam.check_password(sample1, sample1_md5))
+
+ # check bad password fail w/ false
+ self.assertFalse(dam.check_password('x', sample1_des))
+ self.assertFalse(dam.check_password('x', sample1_md5))
+
+ # and other hashes fail w/ error
+ self.assertRaises(ValueError, dam.check_password, sample1, sample1_sha1)
+ self.assertRaises(ValueError, dam.check_password, sample1, None)
+
+ def test_03_check_password(self):
+ "test monkeypatched User.check_password()"
+ # NOTE: using FakeUser so we can test .save()
+ user = FakeUser()
+
+ # patch to use simple context
+ utils.set_django_password_context(simple_context)
+ self.assert_patched(context=simple_context)
+
+ # test that blank hash is never accepted
+ self.assertIs(user.password, '')
+ self.assertIs(user.saved_password, None)
+ self.assertFalse(user.check_password('x'))
+
+ # check correct secrets pass, and wrong ones fail
+ user.password = sample1_md5
+ self.assertTrue(user.check_password(sample1))
+ self.assertFalse(user.check_password('x'))
+ self.assertFalse(user.check_password(None))
+
+ # none of that should have triggered update of password
+ self.assertIs(user.password, sample1_md5)
+ self.assertIs(user.saved_password, None)
+
+ #check unusable password
+ user.set_unusable_password()
+ self.assertFalse(user.has_usable_password())
+ self.assertFalse(user.check_password(None))
+ self.assertFalse(user.check_password(''))
+ self.assertFalse(user.check_password(sample1))
+
+ def test_04_check_password_migration(self):
+ "test User.check_password() hash migration"
+ # NOTE: using FakeUser so we can test .save()
+ user = FakeUser()
+
+ # patch to use simple context
+ utils.set_django_password_context(simple_context)
+ self.assert_patched(context=simple_context)
+
+ # set things up with a password that needs migration
+ user.password = sample1_des
+ self.assertIs(user.password, sample1_des)
+ self.assertIs(user.saved_password, None)
+
+ # run check with bad password...
+ # shouldn't have migrated
+ self.assertFalse(user.check_password('x'))
+ self.assertFalse(user.check_password(None))
+
+ self.assertIs(user.password, sample1_des)
+ self.assertIs(user.saved_password, None)
+
+ # run check with correct password...
+ # should have migrated to md5 and called save()
+ self.assertTrue(user.check_password(sample1))
+
+ self.assertTrue(user.password.startswith("$1$"))
+ self.assertIs(user.saved_password, user.password)
+
+ # check resave doesn't happen
+ user.saved_password = None
+ self.assertTrue(user.check_password(sample1))
+ self.assertIs(user.saved_password, None)
+
+ def test_05_set_password(self):
+ "test monkeypatched User.set_password()"
+ user = FakeUser()
+
+ # patch to use simple context
+ utils.set_django_password_context(simple_context)
+ self.assert_patched(context=simple_context)
+
+ # sanity check
+ self.assertIs(user.password, '')
+ self.assertIs(user.saved_password, None)
+ self.assertTrue(user.has_usable_password())
+
+ # set password
+ user.set_password(sample1)
+ self.assertTrue(user.check_password(sample1))
+ self.assertEquals(simple_context.identify(user.password), "md5_crypt")
+ self.assertIs(user.saved_password, None)
+
+ #check unusable password
+ user.set_password(None)
+ self.assertFalse(user.has_usable_password())
+ self.assertIs(user.saved_password, None)
+
+ def test_06_get_category(self):
+ "test default get_category function"
+ func = utils.get_category
+ self.assertIs(func(FakeUser()), None)
+ self.assertEquals(func(FakeUser(is_staff=True)), "staff")
+ self.assertEquals(func(FakeUser(is_superuser=True)), "superuser")
+ self.assertEquals(func(FakeUser(is_staff=True,
+ is_superuser=True)), "superuser")
+
+ def test_07_get_category(self):
+ "test set_django_password_context's get_category parameter"
+ # test patch uses default get_category
+ utils.set_django_password_context(category_context)
+ self.assertEquals(get_cc_rounds(), 1000)
+ self.assertEquals(get_cc_rounds(is_staff=True), 2000)
+ self.assertEquals(get_cc_rounds(is_superuser=True), 3000)
+
+ # test patch uses explicit get_category
+ def get_category(user):
+ return user.first_name or None
+ utils.set_django_password_context(category_context, get_category)
+ self.assertEquals(get_cc_rounds(), 1000)
+ self.assertEquals(get_cc_rounds(first_name='other'), 1000)
+ self.assertEquals(get_cc_rounds(first_name='staff'), 2000)
+ self.assertEquals(get_cc_rounds(first_name='superuser'), 3000)
+
+ # test patch can disable get_category
+ utils.set_django_password_context(category_context, None)
+ self.assertEquals(get_cc_rounds(), 1000)
+ self.assertEquals(get_cc_rounds(first_name='other'), 1000)
+ self.assertEquals(get_cc_rounds(first_name='staff', is_staff=True), 1000)
+ self.assertEquals(get_cc_rounds(first_name='superuser', is_superuser=True), 1000)
+
+PatchTest = skipUnlessDjango(PatchTest)
+
+#=========================================================
+# test django plugin
+#=========================================================
+
+django_hash_tests = [
+ td.HexMd5Test,
+ td.DjangoDesCryptTest,
+ td.DjangoSaltedMd5Test,
+ td.DjangoSaltedSha1Test,
+ ]
+
+default_hash_tests = django_hash_tests + [ td.Builtin_SHA512CryptTest ]
+
+class PluginTest(TestCase):
+ "test django plugin via settings"
+
+ case_prefix = "passlib.ext.django plugin"
+
+ def setUp(self):
+ #remove django patch
+ utils.set_django_password_context(None)
+
+ #ensure django settings are empty
+ update_settings(
+ PASSLIB_CONTEXT=Undef,
+ PASSLIB_GET_CATEGORY=Undef,
+ )
+
+ #unload module so it's re-run
+ sys.modules.pop("passlib.ext.django.models", None)
+
+ def tearDown(self):
+ #remove django patch
+ utils.set_django_password_context(None)
+
+ def check_hashes(self, tests, new_hash=None, deprecated=None):
+ u = FakeUser()
+ deprecated = None
+
+ # check new hash construction
+ if new_hash:
+ u.set_password("placeholder")
+ handler = get_crypt_handler(new_hash)
+ self.assertTrue(handler.identify(u.password))
+
+ # run against hashes from tests...
+ for test in tests:
+ for secret, hash in test.all_correct_hashes:
+
+ # check against valid password
+ u.password = hash
+ self.assertTrue(u.check_password(secret))
+ if new_hash and deprecated and test.handler.name in deprecated:
+ self.assertFalse(handler.identify(hash))
+ self.assertTrue(handler.identify(u.password))
+
+ # check against invalid password
+ u.password = hash
+ self.assertFalse(u.check_password('x'+secret))
+ if new_hash and deprecated and test.handler.name in deprecated:
+ self.assertFalse(handler.identify(hash))
+ self.assertEquals(u.password, hash)
+
+ # check disabled handling
+ u.set_password(None)
+ handler = get_crypt_handler("django_disabled")
+ self.assertTrue(handler.identify(u.password))
+ self.assertFalse(u.check_password('placeholder'))
+
+ def test_00_actual_django(self):
+ "test actual Django behavior has not changed"
+ #NOTE: if this test fails,
+ # probably means newer version of Django,
+ # and passlib's policies should be updated.
+ self.check_hashes(django_hash_tests,
+ "django_salted_sha1",
+ ["hex_md5"])
+
+ def test_01_explicit_unset(self, value=None):
+ "test PASSLIB_CONTEXT = None"
+ update_settings(
+ PASSLIB_CONTEXT=value,
+ )
+ import passlib.ext.django.models
+ self.check_hashes(django_hash_tests,
+ "django_salted_sha1",
+ ["hex_md5"])
+
+ def test_02_stock_ctx(self):
+ "test PASSLIB_CONTEXT = utils.STOCK_CTX"
+ self.test_01_explicit_unset(value=utils.STOCK_CTX)
+
+ def test_03_implicit_default_ctx(self):
+ "test PASSLIB_CONTEXT unset"
+ import passlib.ext.django.models
+ self.check_hashes(default_hash_tests,
+ "sha512_crypt",
+ ["hex_md5", "django_salted_sha1",
+ "django_salted_md5",
+ "django_des_crypt",
+ ])
+
+ def test_04_explicit_default_ctx(self):
+ "test PASSLIB_CONTEXT = utils.DEFAULT_CTX"
+ update_settings(
+ PASSLIB_CONTEXT=utils.DEFAULT_CTX,
+ )
+ self.test_03_implicit_default_ctx()
+
+ def test_05_default_ctx_alias(self):
+ "test PASSLIB_CONTEXT = 'passlib-default'"
+ update_settings(
+ PASSLIB_CONTEXT="passlib-default",
+ )
+ self.test_03_implicit_default_ctx()
+
+ def test_06_categories(self):
+ "test PASSLIB_GET_CATEGORY unset"
+ update_settings(
+ PASSLIB_CONTEXT=category_context.policy,
+ )
+ import passlib.ext.django.models
+
+ self.assertEquals(get_cc_rounds(), 1000)
+ self.assertEquals(get_cc_rounds(is_staff=True), 2000)
+ self.assertEquals(get_cc_rounds(is_superuser=True), 3000)
+
+ def test_07_categories_explicit(self):
+ "test PASSLIB_GET_CATEGORY = function"
+ def get_category(user):
+ return user.first_name or None
+ update_settings(
+ PASSLIB_CONTEXT = category_context.policy,
+ PASSLIB_GET_CATEGORY = get_category,
+ )
+ import passlib.ext.django.models
+
+ self.assertEquals(get_cc_rounds(), 1000)
+ self.assertEquals(get_cc_rounds(first_name='other'), 1000)
+ self.assertEquals(get_cc_rounds(first_name='staff'), 2000)
+ self.assertEquals(get_cc_rounds(first_name='superuser'), 3000)
+
+ def test_08_categories_disabled(self):
+ "test PASSLIB_GET_CATEGORY = None"
+ update_settings(
+ PASSLIB_CONTEXT = category_context.policy,
+ PASSLIB_GET_CATEGORY = None,
+ )
+ import passlib.ext.django.models
+
+ self.assertEquals(get_cc_rounds(), 1000)
+ self.assertEquals(get_cc_rounds(first_name='other'), 1000)
+ self.assertEquals(get_cc_rounds(first_name='staff', is_staff=True), 1000)
+ self.assertEquals(get_cc_rounds(first_name='superuser', is_superuser=True), 1000)
+
+PluginTest = skipUnlessDjango(PluginTest)
+
+#=========================================================
+#eof
+#=========================================================
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index 7aa9797..a2e6a98 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -294,7 +294,7 @@ class TestCase(unittest.TestCase):
raise self.failureException(msg)
if not hasattr(unittest.TestCase, "assertRegexpMatches"):
- #added in 2.7/UT2 and 3.1
+ #added in 2.7/UT2 and 3.1
def assertRegexpMatches(self, text, expected_regex, msg=None):
"""Fail the test unless the text matches the regular expression."""
if isinstance(expected_regex, basestring):
@@ -486,6 +486,19 @@ class HandlerCase(TestCase):
name += " (%s backend)" % (get_backend(),)
return name
+ @classproperty
+ def all_correct_hashes(cls):
+ hashes = cls.known_correct_hashes
+ configs = cls.known_correct_configs
+ if configs:
+ hashes = hashes + [
+ (secret,hash)
+ for config,secret,hash
+ in configs
+ if (secret,hash) not in hashes
+ ]
+ return hashes
+
#=========================================================
#setup / cleanup
#=========================================================