Merge pull request #1280 from erikr/improve-password-reset2

Fixed #20079 -- Improved security of password reset tokens
This commit is contained in:
Aymeric Augustin 2013-06-18 11:28:21 -07:00
commit a01b1ee688
6 changed files with 27 additions and 19 deletions

View File

@ -14,7 +14,7 @@ from django.utils.translation import ugettext, ugettext_lazy as _
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD, identify_hasher from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.models import get_current_site from django.contrib.sites.models import get_current_site
@ -29,7 +29,7 @@ class ReadOnlyPasswordHashWidget(forms.Widget):
encoded = value encoded = value
final_attrs = self.build_attrs(attrs) final_attrs = self.build_attrs(attrs)
if not encoded or encoded == UNUSABLE_PASSWORD: if not encoded or encoded.startswith(UNUSABLE_PASSWORD_PREFIX):
summary = mark_safe("<strong>%s</strong>" % ugettext("No password set.")) summary = mark_safe("<strong>%s</strong>" % ugettext("No password set."))
else: else:
try: try:
@ -231,7 +231,7 @@ class PasswordResetForm(forms.Form):
for user in users: for user in users:
# Make sure that no email is sent to a user that actually has # Make sure that no email is sent to a user that actually has
# a password marked as unusable # a password marked as unusable
if user.password == UNUSABLE_PASSWORD: if not user.has_usable_password():
continue continue
if not domain_override: if not domain_override:
current_site = get_current_site(request) current_site = get_current_site(request)

View File

@ -17,7 +17,8 @@ from django.utils.module_loading import import_by_path
from django.utils.translation import ugettext_noop as _ from django.utils.translation import ugettext_noop as _
UNUSABLE_PASSWORD = '!' # This will never be a valid encoded hash UNUSABLE_PASSWORD_PREFIX = '!' # This will never be a valid encoded hash
UNUSABLE_PASSWORD_SUFFIX_LENGTH = 40 # number of random chars to add after UNUSABLE_PASSWORD_PREFIX
HASHERS = None # lazily loaded from PASSWORD_HASHERS HASHERS = None # lazily loaded from PASSWORD_HASHERS
PREFERRED_HASHER = None # defaults to first item in PASSWORD_HASHERS PREFERRED_HASHER = None # defaults to first item in PASSWORD_HASHERS
@ -30,7 +31,7 @@ def reset_hashers(**kwargs):
def is_password_usable(encoded): def is_password_usable(encoded):
if encoded is None or encoded == UNUSABLE_PASSWORD: if encoded is None or encoded.startswith(UNUSABLE_PASSWORD_PREFIX):
return False return False
try: try:
hasher = identify_hasher(encoded) hasher = identify_hasher(encoded)
@ -64,13 +65,15 @@ def make_password(password, salt=None, hasher='default'):
""" """
Turn a plain-text password into a hash for database storage Turn a plain-text password into a hash for database storage
Same as encode() but generates a new random salt. If Same as encode() but generates a new random salt.
password is None then UNUSABLE_PASSWORD will be If password is None then a concatenation of
returned which disallows logins. UNUSABLE_PASSWORD_PREFIX and a random string will be returned
which disallows logins. Additional random string reduces chances
of gaining access to staff or superuser accounts.
See ticket #20079 for more info.
""" """
if password is None: if password is None:
return UNUSABLE_PASSWORD return UNUSABLE_PASSWORD_PREFIX + get_random_string(UNUSABLE_PASSWORD_SUFFIX_LENGTH)
hasher = get_hasher(hasher) hasher = get_hasher(hasher)
if not salt: if not salt:

View File

@ -16,7 +16,7 @@ from django.utils import timezone
from django.contrib import auth from django.contrib import auth
# UNUSABLE_PASSWORD is still imported here for backwards compatibility # UNUSABLE_PASSWORD is still imported here for backwards compatibility
from django.contrib.auth.hashers import ( from django.contrib.auth.hashers import (
check_password, make_password, is_password_usable, UNUSABLE_PASSWORD) check_password, make_password, is_password_usable)
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible

View File

@ -3,8 +3,8 @@ from __future__ import unicode_literals
from django.conf.global_settings import PASSWORD_HASHERS as default_hashers from django.conf.global_settings import PASSWORD_HASHERS as default_hashers
from django.contrib.auth.hashers import (is_password_usable, BasePasswordHasher, from django.contrib.auth.hashers import (is_password_usable, BasePasswordHasher,
check_password, make_password, PBKDF2PasswordHasher, load_hashers, check_password, make_password, PBKDF2PasswordHasher, load_hashers, PBKDF2SHA1PasswordHasher,
PBKDF2SHA1PasswordHasher, get_hasher, identify_hasher, UNUSABLE_PASSWORD) get_hasher, identify_hasher, UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH)
from django.utils import six from django.utils import six
from django.utils import unittest from django.utils import unittest
from django.utils.unittest import skipUnless from django.utils.unittest import skipUnless
@ -173,13 +173,18 @@ class TestUtilsHashPass(unittest.TestCase):
def test_unusable(self): def test_unusable(self):
encoded = make_password(None) encoded = make_password(None)
self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH)
self.assertFalse(is_password_usable(encoded)) self.assertFalse(is_password_usable(encoded))
self.assertFalse(check_password(None, encoded)) self.assertFalse(check_password(None, encoded))
self.assertFalse(check_password(UNUSABLE_PASSWORD, encoded)) self.assertFalse(check_password(encoded, encoded))
self.assertFalse(check_password(UNUSABLE_PASSWORD_PREFIX, encoded))
self.assertFalse(check_password('', encoded)) self.assertFalse(check_password('', encoded))
self.assertFalse(check_password('lètmein', encoded)) self.assertFalse(check_password('lètmein', encoded))
self.assertFalse(check_password('lètmeinz', encoded)) self.assertFalse(check_password('lètmeinz', encoded))
self.assertRaises(ValueError, identify_hasher, encoded) self.assertRaises(ValueError, identify_hasher, encoded)
# Assert that the unusable passwords actually contain a random part.
# This might fail one day due to a hash collision.
self.assertNotEqual(encoded, make_password(None), "Random password collision?")
def test_bad_algorithm(self): def test_bad_algorithm(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):

View File

@ -87,7 +87,7 @@ class UserManagerTestCase(TestCase):
user = User.objects.create_user('user', email_lowercase) user = User.objects.create_user('user', email_lowercase)
self.assertEqual(user.email, email_lowercase) self.assertEqual(user.email, email_lowercase)
self.assertEqual(user.username, 'user') self.assertEqual(user.username, 'user')
self.assertEqual(user.password, '!') self.assertFalse(user.has_usable_password())
def test_create_user_email_domain_normalize_rfc3696(self): def test_create_user_email_domain_normalize_rfc3696(self):
# According to http://tools.ietf.org/html/rfc3696#section-3 # According to http://tools.ietf.org/html/rfc3696#section-3

View File

@ -22,7 +22,7 @@ from django.contrib.admin.util import quote
from django.contrib.admin.views.main import IS_POPUP_VAR from django.contrib.admin.views.main import IS_POPUP_VAR
from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase
from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.models import Group, User, Permission, UNUSABLE_PASSWORD from django.contrib.auth.models import Group, User, Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import connection from django.db import connection
@ -3632,7 +3632,7 @@ class UserAdminTest(TestCase):
new_user = User.objects.order_by('-id')[0] new_user = User.objects.order_by('-id')[0]
self.assertRedirects(response, '/test_admin/admin/auth/user/%s/' % new_user.pk) self.assertRedirects(response, '/test_admin/admin/auth/user/%s/' % new_user.pk)
self.assertEqual(User.objects.count(), user_count + 1) self.assertEqual(User.objects.count(), user_count + 1)
self.assertNotEqual(new_user.password, UNUSABLE_PASSWORD) self.assertTrue(new_user.has_usable_password())
def test_save_continue_editing_button(self): def test_save_continue_editing_button(self):
user_count = User.objects.count() user_count = User.objects.count()
@ -3645,7 +3645,7 @@ class UserAdminTest(TestCase):
new_user = User.objects.order_by('-id')[0] new_user = User.objects.order_by('-id')[0]
self.assertRedirects(response, '/test_admin/admin/auth/user/%s/' % new_user.pk) self.assertRedirects(response, '/test_admin/admin/auth/user/%s/' % new_user.pk)
self.assertEqual(User.objects.count(), user_count + 1) self.assertEqual(User.objects.count(), user_count + 1)
self.assertNotEqual(new_user.password, UNUSABLE_PASSWORD) self.assertTrue(new_user.has_usable_password())
def test_password_mismatch(self): def test_password_mismatch(self):
response = self.client.post('/test_admin/admin/auth/user/add/', { response = self.client.post('/test_admin/admin/auth/user/add/', {
@ -3691,7 +3691,7 @@ class UserAdminTest(TestCase):
new_user = User.objects.order_by('-id')[0] new_user = User.objects.order_by('-id')[0]
self.assertRedirects(response, '/test_admin/admin/auth/user/add/') self.assertRedirects(response, '/test_admin/admin/auth/user/add/')
self.assertEqual(User.objects.count(), user_count + 1) self.assertEqual(User.objects.count(), user_count + 1)
self.assertNotEqual(new_user.password, UNUSABLE_PASSWORD) self.assertTrue(new_user.has_usable_password())
def test_user_permission_performance(self): def test_user_permission_performance(self):
u = User.objects.all()[0] u = User.objects.all()[0]