From 4a1033898636f8c2cafc74c7934fdf7411716fdf Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Sun, 26 Jun 2011 16:51:46 +0000 Subject: [PATCH] Fixed #14390 and #16262 -- Moved password related functions from auth models to utils module and stopped check_password from throwing an exception. Thanks, subsume and lrekucki. git-svn-id: http://code.djangoproject.com/svn/django/trunk@16456 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/auth/models.py | 72 ++++----------------------- django/contrib/auth/tests/__init__.py | 2 +- django/contrib/auth/tests/basic.py | 34 +++++++++++++ django/contrib/auth/utils.py | 63 +++++++++++++++++++++++ docs/releases/1.4.txt | 5 ++ docs/topics/auth.txt | 37 +++++++++++--- 6 files changed, 143 insertions(+), 70 deletions(-) create mode 100644 django/contrib/auth/utils.py diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 01c2e43697..e9a6cd2e47 100644 --- a/django/contrib/auth/models.py +++ b/django/contrib/auth/models.py @@ -1,63 +1,19 @@ import datetime -import hashlib -import random import urllib -from django.contrib import auth -from django.contrib.auth.signals import user_logged_in from django.core.exceptions import ImproperlyConfigured from django.core.mail import send_mail from django.db import models from django.db.models.manager import EmptyManager -from django.contrib.contenttypes.models import ContentType from django.utils.encoding import smart_str from django.utils.translation import ugettext_lazy as _ -from django.utils.crypto import constant_time_compare - -UNUSABLE_PASSWORD = '!' # This will never be a valid hash - -def get_hexdigest(algorithm, salt, raw_password): - """ - Returns a string of the hexdigest of the given plaintext password and salt - using the given algorithm ('md5', 'sha1' or 'crypt'). - """ - raw_password, salt = smart_str(raw_password), smart_str(salt) - if algorithm == 'crypt': - try: - import crypt - except ImportError: - raise ValueError('"crypt" password algorithm not supported in this environment') - return crypt.crypt(raw_password, salt) - - if algorithm == 'md5': - return hashlib.md5(salt + raw_password).hexdigest() - elif algorithm == 'sha1': - return hashlib.sha1(salt + raw_password).hexdigest() - raise ValueError("Got unknown password algorithm type in password.") - -def get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'): - """ - Returns a random string of length characters from the set of a-z, A-Z, 0-9 - for use as a salt. - - The default length of 12 with the a-z, A-Z, 0-9 character set returns - a 71-bit salt. log_2((26+26+10)^12) =~ 71 bits - """ - import random - try: - random = random.SystemRandom() - except NotImplementedError: - pass - return ''.join([random.choice(allowed_chars) for i in range(length)]) - -def check_password(raw_password, enc_password): - """ - Returns a boolean of whether the raw_password was correct. Handles - encryption formats behind the scenes. - """ - algo, salt, hsh = enc_password.split('$') - return constant_time_compare(hsh, get_hexdigest(algo, salt, raw_password)) +from django.contrib import auth +from django.contrib.auth.signals import user_logged_in +from django.contrib.auth.utils import (get_hexdigest, make_password, + check_password, is_password_usable, + get_random_string, UNUSABLE_PASSWORD) +from django.contrib.contenttypes.models import ContentType def update_last_login(sender, user, **kwargs): """ @@ -270,13 +226,7 @@ class User(models.Model): return full_name.strip() def set_password(self, raw_password): - if raw_password is None: - self.set_unusable_password() - else: - algo = 'sha1' - salt = get_random_string() - hsh = get_hexdigest(algo, salt, raw_password) - self.password = '%s$%s$%s' % (algo, salt, hsh) + self.password = make_password('sha1', raw_password) def check_password(self, raw_password): """ @@ -296,14 +246,10 @@ class User(models.Model): def set_unusable_password(self): # Sets a value that will never be a valid hash - self.password = UNUSABLE_PASSWORD + self.password = make_password('sha1', None) def has_usable_password(self): - if self.password is None \ - or self.password == UNUSABLE_PASSWORD: - return False - else: - return True + return is_password_usable(self.password) def get_group_permissions(self, obj=None): """ diff --git a/django/contrib/auth/tests/__init__.py b/django/contrib/auth/tests/__init__.py index a7cc004507..ca1ee0f50b 100644 --- a/django/contrib/auth/tests/__init__.py +++ b/django/contrib/auth/tests/__init__.py @@ -1,7 +1,7 @@ from django.contrib.auth.tests.auth_backends import (BackendTest, RowlevelBackendTest, AnonymousUserBackendTest, NoAnonymousUserBackendTest, NoBackendsTest, InActiveUserBackendTest, NoInActiveUserBackendTest) -from django.contrib.auth.tests.basic import BasicTestCase +from django.contrib.auth.tests.basic import BasicTestCase, PasswordUtilsTestCase from django.contrib.auth.tests.context_processors import AuthContextProcessorTests from django.contrib.auth.tests.decorators import LoginRequiredTestCase from django.contrib.auth.tests.forms import (UserCreationFormTest, diff --git a/django/contrib/auth/tests/basic.py b/django/contrib/auth/tests/basic.py index 6a3b656850..44738f7d07 100644 --- a/django/contrib/auth/tests/basic.py +++ b/django/contrib/auth/tests/basic.py @@ -1,8 +1,16 @@ from django.test import TestCase +from django.utils.unittest import skipUnless from django.contrib.auth.models import User, AnonymousUser +from django.contrib.auth import utils from django.core.management import call_command from StringIO import StringIO +try: + import crypt as crypt_module +except ImportError: + crypt_module = None + + class BasicTestCase(TestCase): def test_user(self): "Check that users can be created and can set their password" @@ -93,3 +101,29 @@ class BasicTestCase(TestCase): self.assertEqual(u.email, 'joe@somewhere.org') self.assertFalse(u.has_usable_password()) + +class PasswordUtilsTestCase(TestCase): + + def _test_make_password(self, algo): + password = utils.make_password(algo, "foobar") + self.assertTrue(utils.is_password_usable(password)) + self.assertTrue(utils.check_password("foobar", password)) + + def test_make_unusable(self): + "Check that you can create an unusable password." + password = utils.make_password("any", None) + self.assertFalse(utils.is_password_usable(password)) + self.assertFalse(utils.check_password("foobar", password)) + + def test_make_password_sha1(self): + "Check creating passwords with SHA1 algorithm." + self._test_make_password("sha1") + + def test_make_password_md5(self): + "Check creating passwords with MD5 algorithm." + self._test_make_password("md5") + + @skipUnless(crypt_module, "no crypt module to generate password.") + def test_make_password_crypt(self): + "Check creating passwords with CRYPT algorithm." + self._test_make_password("crypt") diff --git a/django/contrib/auth/utils.py b/django/contrib/auth/utils.py new file mode 100644 index 0000000000..57c693f879 --- /dev/null +++ b/django/contrib/auth/utils.py @@ -0,0 +1,63 @@ +import hashlib +from django.utils.encoding import smart_str +from django.utils.crypto import constant_time_compare + +UNUSABLE_PASSWORD = '!' # This will never be a valid hash + +def get_hexdigest(algorithm, salt, raw_password): + """ + Returns a string of the hexdigest of the given plaintext password and salt + using the given algorithm ('md5', 'sha1' or 'crypt'). + """ + raw_password, salt = smart_str(raw_password), smart_str(salt) + if algorithm == 'crypt': + try: + import crypt + except ImportError: + raise ValueError('"crypt" password algorithm not supported in this environment') + return crypt.crypt(raw_password, salt) + + if algorithm == 'md5': + return hashlib.md5(salt + raw_password).hexdigest() + elif algorithm == 'sha1': + return hashlib.sha1(salt + raw_password).hexdigest() + raise ValueError("Got unknown password algorithm type in password.") + +def get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'): + """ + Returns a random string of length characters from the set of a-z, A-Z, 0-9 + for use as a salt. + + The default length of 12 with the a-z, A-Z, 0-9 character set returns + a 71-bit salt. log_2((26+26+10)^12) =~ 71 bits + """ + import random + try: + random = random.SystemRandom() + except NotImplementedError: + pass + return ''.join([random.choice(allowed_chars) for i in range(length)]) + +def check_password(raw_password, enc_password): + """ + Returns a boolean of whether the raw_password was correct. Handles + encryption formats behind the scenes. + """ + parts = enc_password.split('$') + if len(parts) != 3: + return False + algo, salt, hsh = parts + return constant_time_compare(hsh, get_hexdigest(algo, salt, raw_password)) + +def is_password_usable(encoded_password): + return encoded_password is not None and encoded_password != UNUSABLE_PASSWORD + +def make_password(algo, raw_password): + """ + Produce a new password string in this format: algorithm$salt$hash + """ + if raw_password is None: + return UNUSABLE_PASSWORD + salt = get_random_string() + hsh = get_hexdigest(algo, salt, raw_password) + return '%s$%s$%s' % (algo, salt, hsh) diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 0635ecefee..acaf3bf5f3 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -192,6 +192,11 @@ Django 1.4 also includes several smaller improvements worth noting: * In the documentation, a helpful :doc:`security overview ` page. +* Function :func:`django.contrib.auth.models.check_password` has been moved + to the :mod:`django.contrib.auth.utils` module. Importing it from the old + location will still work, but you should update your imports. + + .. _backwards-incompatible-changes-1.4: Backwards incompatible changes in 1.4 diff --git a/docs/topics/auth.txt b/docs/topics/auth.txt index e7f606b3d0..c1947f61e1 100644 --- a/docs/topics/auth.txt +++ b/docs/topics/auth.txt @@ -627,19 +627,44 @@ Django provides two functions in :mod:`django.contrib.auth`: .. _backends documentation: #other-authentication-sources -Manually checking a user's password +Manually managing a user's password ----------------------------------- -.. currentmodule:: django.contrib.auth.models +.. currentmodule:: django.contrib.auth.utils + +.. versionadded:: 1.4 + + The :mod:`django.contrib.auth.utils` module provides a set of functions + to create and validate hashed password. You can use them independently + from the ``User`` model. .. function:: check_password() If you'd like to manually authenticate a user by comparing a plain-text password to the hashed password in the database, use the convenience - function :func:`django.contrib.auth.models.check_password`. It takes two - arguments: the plain-text password to check, and the full value of a user's - ``password`` field in the database to check against, and returns ``True`` - if they match, ``False`` otherwise. + function :func:`django.contrib.auth.utils.check_password`. It takes two + arguments: the plain-text password to check, and the full value of a + user's ``password`` field in the database to check against, and returns + ``True`` if they match, ``False`` otherwise. + +.. function:: make_password() + + .. versionadded:: 1.4 + + Creates a hashed password in the format used by this application. It takes + two arguments: hashing algorithm to use and the password in plain-text. + Currently supported algorithms are: ``'sha1'``, ``'md5'`` and ``'crypt'`` + if you have the ``crypt`` library installed. If the second argument is + ``None``, an unusable password is returned (a one that will be never + accepted by :func:`django.contrib.auth.utils.check_password`). + +.. function:: is_password_usable() + + .. versionadded:: 1.4 + + Checks if the given string is a hashed password that has a chance + of being verified against :func:`django.contrib.auth.utils.check_password`. + How to log a user out ---------------------