mirror of https://github.com/django/django.git
Fixed #26615 -- Made password reset token invalidate when changing email.
Co-Authored-By: Silas Barta <sbarta@gmail.com>
This commit is contained in:
parent
7f9e4524d6
commit
0362b0e986
|
@ -78,9 +78,9 @@ class PasswordResetTokenGenerator:
|
||||||
|
|
||||||
def _make_hash_value(self, user, timestamp):
|
def _make_hash_value(self, user, timestamp):
|
||||||
"""
|
"""
|
||||||
Hash the user's primary key and some user state that's sure to change
|
Hash the user's primary key, email (if available), and some user state
|
||||||
after a password reset to produce a token that invalidated when it's
|
that's sure to change after a password reset to produce a token that is
|
||||||
used:
|
invalidated when it's used:
|
||||||
1. The password field will change upon a password reset (even if the
|
1. The password field will change upon a password reset (even if the
|
||||||
same password is chosen, due to password salting).
|
same password is chosen, due to password salting).
|
||||||
2. The last_login field will usually be updated very shortly after
|
2. The last_login field will usually be updated very shortly after
|
||||||
|
@ -94,7 +94,9 @@ class PasswordResetTokenGenerator:
|
||||||
# Truncate microseconds so that tokens are consistent even if the
|
# Truncate microseconds so that tokens are consistent even if the
|
||||||
# database doesn't support microseconds.
|
# database doesn't support microseconds.
|
||||||
login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None)
|
login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None)
|
||||||
return str(user.pk) + user.password + str(login_timestamp) + str(timestamp)
|
email_field = user.get_email_field_name()
|
||||||
|
email = getattr(user, email_field, '') or ''
|
||||||
|
return f'{user.pk}{user.password}{login_timestamp}{timestamp}{email}'
|
||||||
|
|
||||||
def _num_seconds(self, dt):
|
def _num_seconds(self, dt):
|
||||||
return int((dt - datetime(2001, 1, 1)).total_seconds())
|
return int((dt - datetime(2001, 1, 1)).total_seconds())
|
||||||
|
|
|
@ -552,6 +552,9 @@ Miscellaneous
|
||||||
``False`` if the file cannot be locked, instead of raising
|
``False`` if the file cannot be locked, instead of raising
|
||||||
:exc:`BlockingIOError`.
|
:exc:`BlockingIOError`.
|
||||||
|
|
||||||
|
* The password reset mechanism now invalidates tokens when the user email is
|
||||||
|
changed.
|
||||||
|
|
||||||
.. _deprecated-features-3.2:
|
.. _deprecated-features-3.2:
|
||||||
|
|
||||||
Features deprecated in 3.2
|
Features deprecated in 3.2
|
||||||
|
|
|
@ -8,6 +8,7 @@ from .minimal import MinimalUser
|
||||||
from .no_password import NoPasswordUser
|
from .no_password import NoPasswordUser
|
||||||
from .proxy import Proxy, UserProxy
|
from .proxy import Proxy, UserProxy
|
||||||
from .uuid_pk import UUIDUser
|
from .uuid_pk import UUIDUser
|
||||||
|
from .with_custom_email_field import CustomEmailField
|
||||||
from .with_foreign_key import CustomUserWithFK, Email
|
from .with_foreign_key import CustomUserWithFK, Email
|
||||||
from .with_integer_username import IntegerUsernameUser
|
from .with_integer_username import IntegerUsernameUser
|
||||||
from .with_last_login_attr import UserWithDisabledLastLoginField
|
from .with_last_login_attr import UserWithDisabledLastLoginField
|
||||||
|
@ -16,10 +17,10 @@ from .with_many_to_many import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CustomPermissionsUser', 'CustomUser', 'CustomUserNonUniqueUsername',
|
'CustomEmailField', 'CustomPermissionsUser', 'CustomUser',
|
||||||
'CustomUserWithFK', 'CustomUserWithM2M', 'CustomUserWithM2MThrough',
|
'CustomUserNonUniqueUsername', 'CustomUserWithFK', 'CustomUserWithM2M',
|
||||||
'CustomUserWithoutIsActiveField', 'Email', 'ExtensionUser',
|
'CustomUserWithM2MThrough', 'CustomUserWithoutIsActiveField', 'Email',
|
||||||
'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser',
|
'ExtensionUser', 'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser',
|
||||||
'NoPasswordUser', 'Organization', 'Proxy', 'UUIDUser', 'UserProxy',
|
'NoPasswordUser', 'Organization', 'Proxy', 'UUIDUser', 'UserProxy',
|
||||||
'UserWithDisabledLastLoginField',
|
'UserWithDisabledLastLoginField',
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,7 +15,7 @@ class CustomEmailFieldUserManager(BaseUserManager):
|
||||||
class CustomEmailField(AbstractBaseUser):
|
class CustomEmailField(AbstractBaseUser):
|
||||||
username = models.CharField(max_length=255)
|
username = models.CharField(max_length=255)
|
||||||
password = models.CharField(max_length=255)
|
password = models.CharField(max_length=255)
|
||||||
email_address = models.EmailField()
|
email_address = models.EmailField(null=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
EMAIL_FIELD = 'email_address'
|
EMAIL_FIELD = 'email_address'
|
||||||
|
|
|
@ -17,8 +17,7 @@ from django.test import (
|
||||||
SimpleTestCase, TestCase, TransactionTestCase, override_settings,
|
SimpleTestCase, TestCase, TransactionTestCase, override_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .models import IntegerUsernameUser
|
from .models import CustomEmailField, IntegerUsernameUser
|
||||||
from .models.with_custom_email_field import CustomEmailField
|
|
||||||
|
|
||||||
|
|
||||||
class NaturalKeysTestCase(TestCase):
|
class NaturalKeysTestCase(TestCase):
|
||||||
|
|
|
@ -7,6 +7,8 @@ from django.test import TestCase
|
||||||
from django.test.utils import ignore_warnings
|
from django.test.utils import ignore_warnings
|
||||||
from django.utils.deprecation import RemovedInDjango40Warning
|
from django.utils.deprecation import RemovedInDjango40Warning
|
||||||
|
|
||||||
|
from .models import CustomEmailField
|
||||||
|
|
||||||
|
|
||||||
class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator):
|
class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator):
|
||||||
def __init__(self, now):
|
def __init__(self, now):
|
||||||
|
@ -37,6 +39,27 @@ class TokenGeneratorTest(TestCase):
|
||||||
tk2 = p0.make_token(user_reload)
|
tk2 = p0.make_token(user_reload)
|
||||||
self.assertEqual(tk1, tk2)
|
self.assertEqual(tk1, tk2)
|
||||||
|
|
||||||
|
def test_token_with_different_email(self):
|
||||||
|
"""Updating the user email address invalidates the token."""
|
||||||
|
tests = [
|
||||||
|
(CustomEmailField, None),
|
||||||
|
(CustomEmailField, 'test4@example.com'),
|
||||||
|
(User, 'test4@example.com'),
|
||||||
|
]
|
||||||
|
for model, email in tests:
|
||||||
|
with self.subTest(model=model.__qualname__, email=email):
|
||||||
|
user = model.objects.create_user(
|
||||||
|
'changeemailuser',
|
||||||
|
email=email,
|
||||||
|
password='testpw',
|
||||||
|
)
|
||||||
|
p0 = PasswordResetTokenGenerator()
|
||||||
|
tk1 = p0.make_token(user)
|
||||||
|
self.assertIs(p0.check_token(user, tk1), True)
|
||||||
|
setattr(user, user.get_email_field_name(), 'test4new@example.com')
|
||||||
|
user.save()
|
||||||
|
self.assertIs(p0.check_token(user, tk1), False)
|
||||||
|
|
||||||
def test_timeout(self):
|
def test_timeout(self):
|
||||||
"""The token is valid after n seconds, but no greater."""
|
"""The token is valid after n seconds, but no greater."""
|
||||||
# Uses a mocked version of PasswordResetTokenGenerator so we can change
|
# Uses a mocked version of PasswordResetTokenGenerator so we can change
|
||||||
|
|
Loading…
Reference in New Issue