Browse Source

Fixed #26615 -- Made password reset token invalidate when changing email.

Co-Authored-By: Silas Barta <sbarta@gmail.com>
Jacob Walls 8 years ago
parent
commit
0362b0e986

+ 6 - 4
django/contrib/auth/tokens.py

@@ -78,9 +78,9 @@ class PasswordResetTokenGenerator:
 
     def _make_hash_value(self, user, timestamp):
         """
-        Hash the user's primary key and some user state that's sure to change
-        after a password reset to produce a token that invalidated when it's
-        used:
+        Hash the user's primary key, email (if available), and some user state
+        that's sure to change after a password reset to produce a token that is
+        invalidated when it's used:
         1. The password field will change upon a password reset (even if the
            same password is chosen, due to password salting).
         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
         # database doesn't support microseconds.
         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):
         return int((dt - datetime(2001, 1, 1)).total_seconds())

+ 3 - 0
docs/releases/3.2.txt

@@ -552,6 +552,9 @@ Miscellaneous
   ``False`` if the file cannot be locked, instead of raising
   :exc:`BlockingIOError`.
 
+* The password reset mechanism now invalidates tokens when the user email is
+  changed.
+
 .. _deprecated-features-3.2:
 
 Features deprecated in 3.2

+ 5 - 4
tests/auth_tests/models/__init__.py

@@ -8,6 +8,7 @@ from .minimal import MinimalUser
 from .no_password import NoPasswordUser
 from .proxy import Proxy, UserProxy
 from .uuid_pk import UUIDUser
+from .with_custom_email_field import CustomEmailField
 from .with_foreign_key import CustomUserWithFK, Email
 from .with_integer_username import IntegerUsernameUser
 from .with_last_login_attr import UserWithDisabledLastLoginField
@@ -16,10 +17,10 @@ from .with_many_to_many import (
 )
 
 __all__ = (
-    'CustomPermissionsUser', 'CustomUser', 'CustomUserNonUniqueUsername',
-    'CustomUserWithFK', 'CustomUserWithM2M', 'CustomUserWithM2MThrough',
-    'CustomUserWithoutIsActiveField', 'Email', 'ExtensionUser',
-    'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser',
+    'CustomEmailField', 'CustomPermissionsUser', 'CustomUser',
+    'CustomUserNonUniqueUsername', 'CustomUserWithFK', 'CustomUserWithM2M',
+    'CustomUserWithM2MThrough', 'CustomUserWithoutIsActiveField', 'Email',
+    'ExtensionUser', 'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser',
     'NoPasswordUser', 'Organization', 'Proxy', 'UUIDUser', 'UserProxy',
     'UserWithDisabledLastLoginField',
 )

+ 1 - 1
tests/auth_tests/models/with_custom_email_field.py

@@ -15,7 +15,7 @@ class CustomEmailFieldUserManager(BaseUserManager):
 class CustomEmailField(AbstractBaseUser):
     username = 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)
 
     EMAIL_FIELD = 'email_address'

+ 1 - 2
tests/auth_tests/test_models.py

@@ -17,8 +17,7 @@ from django.test import (
     SimpleTestCase, TestCase, TransactionTestCase, override_settings,
 )
 
-from .models import IntegerUsernameUser
-from .models.with_custom_email_field import CustomEmailField
+from .models import CustomEmailField, IntegerUsernameUser
 
 
 class NaturalKeysTestCase(TestCase):

+ 23 - 0
tests/auth_tests/test_tokens.py

@@ -7,6 +7,8 @@ from django.test import TestCase
 from django.test.utils import ignore_warnings
 from django.utils.deprecation import RemovedInDjango40Warning
 
+from .models import CustomEmailField
+
 
 class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator):
     def __init__(self, now):
@@ -37,6 +39,27 @@ class TokenGeneratorTest(TestCase):
         tk2 = p0.make_token(user_reload)
         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):
         """The token is valid after n seconds, but no greater."""
         # Uses a mocked version of PasswordResetTokenGenerator so we can change