Kaydet (Commit) f5e9d679 authored tarafından Tim Graham's avatar Tim Graham

Refs #16860 -- Moved password_changed() logic to AbstractBaseUser.

Thanks Carl Meyer for review.
üst d7848c11
...@@ -4,6 +4,7 @@ not in INSTALLED_APPS. ...@@ -4,6 +4,7 @@ not in INSTALLED_APPS.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib.auth import password_validation
from django.contrib.auth.hashers import ( from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password, check_password, is_password_usable, make_password,
) )
...@@ -60,9 +61,21 @@ class AbstractBaseUser(models.Model): ...@@ -60,9 +61,21 @@ class AbstractBaseUser(models.Model):
"Return the identifying username for this User" "Return the identifying username for this User"
return getattr(self, self.USERNAME_FIELD) return getattr(self, self.USERNAME_FIELD)
def __init__(self, *args, **kwargs):
super(AbstractBaseUser, self).__init__(*args, **kwargs)
# Stores the raw password if set_password() is called so that it can
# be passed to password_changed() after the model is saved.
self._password = None
def __str__(self): def __str__(self):
return self.get_username() return self.get_username()
def save(self, *args, **kwargs):
super(AbstractBaseUser, self).save(*args, **kwargs)
if self._password is not None:
password_validation.password_changed(self._password, self)
self._password = None
def natural_key(self): def natural_key(self):
return (self.get_username(),) return (self.get_username(),)
...@@ -82,6 +95,7 @@ class AbstractBaseUser(models.Model): ...@@ -82,6 +95,7 @@ class AbstractBaseUser(models.Model):
def set_password(self, raw_password): def set_password(self, raw_password):
self.password = make_password(raw_password) self.password = make_password(raw_password)
self._password = raw_password
def check_password(self, raw_password): def check_password(self, raw_password):
""" """
...@@ -90,6 +104,8 @@ class AbstractBaseUser(models.Model): ...@@ -90,6 +104,8 @@ class AbstractBaseUser(models.Model):
""" """
def setter(raw_password): def setter(raw_password):
self.set_password(raw_password) self.set_password(raw_password)
# Password hash upgrades shouldn't be considered password changes.
self._password = None
self.save(update_fields=["password"]) self.save(update_fields=["password"])
return check_password(raw_password, self.password, setter) return check_password(raw_password, self.password, setter)
......
...@@ -289,7 +289,6 @@ class SetPasswordForm(forms.Form): ...@@ -289,7 +289,6 @@ class SetPasswordForm(forms.Form):
def save(self, commit=True): def save(self, commit=True):
password = self.cleaned_data["new_password1"] password = self.cleaned_data["new_password1"]
self.user.set_password(password) self.user.set_password(password)
password_validation.password_changed(password, self.user)
if commit: if commit:
self.user.save() self.user.save()
return self.user return self.user
...@@ -363,7 +362,6 @@ class AdminPasswordChangeForm(forms.Form): ...@@ -363,7 +362,6 @@ class AdminPasswordChangeForm(forms.Form):
""" """
password = self.cleaned_data["password1"] password = self.cleaned_data["password1"]
self.user.set_password(password) self.user.set_password(password)
password_validation.password_changed(password, self.user)
if commit: if commit:
self.user.save() self.user.save()
return self.user return self.user
......
...@@ -379,6 +379,11 @@ or if you have API calls that allow passwords to be set, for example. ...@@ -379,6 +379,11 @@ or if you have API calls that allow passwords to be set, for example.
by validators such as one that prevents password reuse. This should be by validators such as one that prevents password reuse. This should be
called once the password has been successfully changed. called once the password has been successfully changed.
For subclasses of :class:`~django.contrib.auth.models.AbstractBaseUser`,
the password field will be marked as "dirty" when calling
:meth:`~django.contrib.auth.models.AbstractBaseUser.set_password` which
triggers a call to ``password_changed()`` after the user is saved.
.. function:: password_validators_help_texts(password_validators=None) .. function:: password_validators_help_texts(password_validators=None)
Returns a list of the help texts of all validators. These explain the Returns a list of the help texts of all validators. These explain the
......
...@@ -5,16 +5,16 @@ import re ...@@ -5,16 +5,16 @@ import re
from django import forms from django import forms
from django.contrib.auth.forms import ( from django.contrib.auth.forms import (
AuthenticationForm, PasswordChangeForm, PasswordResetForm, AdminPasswordChangeForm, AuthenticationForm, PasswordChangeForm,
ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget, SetPasswordForm, PasswordResetForm, ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget,
UserChangeForm, UserCreationForm, SetPasswordForm, UserChangeForm, UserCreationForm,
) )
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core import mail from django.core import mail
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.forms.fields import CharField, Field from django.forms.fields import CharField, Field
from django.test import SimpleTestCase, TestCase, override_settings from django.test import SimpleTestCase, TestCase, mock, override_settings
from django.utils import translation from django.utils import translation
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.text import capfirst from django.utils.text import capfirst
...@@ -116,7 +116,8 @@ class UserCreationFormTest(TestDataMixin, TestCase): ...@@ -116,7 +116,8 @@ class UserCreationFormTest(TestDataMixin, TestCase):
self.assertEqual(form['password1'].errors, required_error) self.assertEqual(form['password1'].errors, required_error)
self.assertEqual(form['password2'].errors, []) self.assertEqual(form['password2'].errors, [])
def test_success(self): @mock.patch('django.contrib.auth.password_validation.password_changed')
def test_success(self, password_changed):
# The success case. # The success case.
data = { data = {
'username': 'jsmith@example.com', 'username': 'jsmith@example.com',
...@@ -125,7 +126,10 @@ class UserCreationFormTest(TestDataMixin, TestCase): ...@@ -125,7 +126,10 @@ class UserCreationFormTest(TestDataMixin, TestCase):
} }
form = UserCreationForm(data) form = UserCreationForm(data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
form.save(commit=False)
self.assertEqual(password_changed.call_count, 0)
u = form.save() u = form.save()
self.assertEqual(password_changed.call_count, 1)
self.assertEqual(repr(u), '<User: jsmith@example.com>') self.assertEqual(repr(u), '<User: jsmith@example.com>')
...@@ -254,7 +258,8 @@ class SetPasswordFormTest(TestDataMixin, TestCase): ...@@ -254,7 +258,8 @@ class SetPasswordFormTest(TestDataMixin, TestCase):
self.assertEqual(form["new_password2"].errors, self.assertEqual(form["new_password2"].errors,
[force_text(form.error_messages['password_mismatch'])]) [force_text(form.error_messages['password_mismatch'])])
def test_success(self): @mock.patch('django.contrib.auth.password_validation.password_changed')
def test_success(self, password_changed):
user = User.objects.get(username='testclient') user = User.objects.get(username='testclient')
data = { data = {
'new_password1': 'abc123', 'new_password1': 'abc123',
...@@ -262,6 +267,10 @@ class SetPasswordFormTest(TestDataMixin, TestCase): ...@@ -262,6 +267,10 @@ class SetPasswordFormTest(TestDataMixin, TestCase):
} }
form = SetPasswordForm(user, data) form = SetPasswordForm(user, data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
form.save(commit=False)
self.assertEqual(password_changed.call_count, 0)
form.save()
self.assertEqual(password_changed.call_count, 1)
@override_settings(AUTH_PASSWORD_VALIDATORS=[ @override_settings(AUTH_PASSWORD_VALIDATORS=[
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
...@@ -313,7 +322,8 @@ class PasswordChangeFormTest(TestDataMixin, TestCase): ...@@ -313,7 +322,8 @@ class PasswordChangeFormTest(TestDataMixin, TestCase):
self.assertEqual(form["new_password2"].errors, self.assertEqual(form["new_password2"].errors,
[force_text(form.error_messages['password_mismatch'])]) [force_text(form.error_messages['password_mismatch'])])
def test_success(self): @mock.patch('django.contrib.auth.password_validation.password_changed')
def test_success(self, password_changed):
# The success case. # The success case.
user = User.objects.get(username='testclient') user = User.objects.get(username='testclient')
data = { data = {
...@@ -323,6 +333,10 @@ class PasswordChangeFormTest(TestDataMixin, TestCase): ...@@ -323,6 +333,10 @@ class PasswordChangeFormTest(TestDataMixin, TestCase):
} }
form = PasswordChangeForm(user, data) form = PasswordChangeForm(user, data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
form.save(commit=False)
self.assertEqual(password_changed.call_count, 0)
form.save()
self.assertEqual(password_changed.call_count, 1)
def test_field_order(self): def test_field_order(self):
# Regression test - check the order of fields: # Regression test - check the order of fields:
...@@ -586,3 +600,21 @@ class ReadOnlyPasswordHashTest(SimpleTestCase): ...@@ -586,3 +600,21 @@ class ReadOnlyPasswordHashTest(SimpleTestCase):
def test_readonly_field_has_changed(self): def test_readonly_field_has_changed(self):
field = ReadOnlyPasswordHashField() field = ReadOnlyPasswordHashField()
self.assertFalse(field.has_changed('aaa', 'bbb')) self.assertFalse(field.has_changed('aaa', 'bbb'))
@override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
class AdminPasswordChangeFormTest(TestDataMixin, TestCase):
@mock.patch('django.contrib.auth.password_validation.password_changed')
def test_success(self, password_changed):
user = User.objects.get(username='testclient')
data = {
'password1': 'test123',
'password2': 'test123',
}
form = AdminPasswordChangeForm(user, data)
self.assertTrue(form.is_valid())
form.save(commit=False)
self.assertEqual(password_changed.call_count, 0)
form.save()
self.assertEqual(password_changed.call_count, 1)
import datetime import datetime
from django.conf.global_settings import PASSWORD_HASHERS
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import get_hasher
from django.contrib.auth.models import ( from django.contrib.auth.models import (
AbstractUser, Group, Permission, User, UserManager, AbstractUser, Group, Permission, User, UserManager,
) )
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import mail from django.core import mail
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.test import TestCase, override_settings from django.test import TestCase, mock, override_settings
@override_settings(USE_TZ=False) @override_settings(USE_TZ=False)
...@@ -216,6 +218,41 @@ class AbstractUserTestCase(TestCase): ...@@ -216,6 +218,41 @@ class AbstractUserTestCase(TestCase):
user2 = User.objects.create_user(username='user2') user2 = User.objects.create_user(username='user2')
self.assertIsNone(user2.last_login) self.assertIsNone(user2.last_login)
def test_user_double_save(self):
"""
Calling user.save() twice should trigger password_changed() once.
"""
user = User.objects.create_user(username='user', password='foo')
user.set_password('bar')
with mock.patch('django.contrib.auth.password_validation.password_changed') as pw_changed:
user.save()
self.assertEqual(pw_changed.call_count, 1)
user.save()
self.assertEqual(pw_changed.call_count, 1)
@override_settings(PASSWORD_HASHERS=PASSWORD_HASHERS)
def test_check_password_upgrade(self):
"""
password_changed() shouldn't be called if User.check_password()
triggers a hash iteration upgrade.
"""
user = User.objects.create_user(username='user', password='foo')
initial_password = user.password
self.assertTrue(user.check_password('foo'))
hasher = get_hasher('default')
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
old_iterations = hasher.iterations
try:
# Upgrade the password iterations
hasher.iterations = old_iterations + 1
with mock.patch('django.contrib.auth.password_validation.password_changed') as pw_changed:
user.check_password('foo')
self.assertEqual(pw_changed.call_count, 0)
self.assertNotEqual(initial_password, user.password)
finally:
hasher.iterations = old_iterations
class IsActiveTestCase(TestCase): class IsActiveTestCase(TestCase):
""" """
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment