Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions django/contrib/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.template import loader
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.text import capfirst
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -478,7 +476,7 @@ def save(
"email": user_email,
"domain": domain,
"site_name": site_name,
"uid": urlsafe_base64_encode(force_bytes(user.pk)),
"uid": token_generator.encrypt_uid(user.pk),
"user": user,
"token": token_generator.make_token(user),
"protocol": "https" if use_https else "http",
Expand Down
44 changes: 43 additions & 1 deletion django/contrib/auth/tokens.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import hashlib
from datetime import datetime

from django.conf import settings
from django.utils.crypto import constant_time_compare, salted_hmac
from django.utils.http import base36_to_int, int_to_base36
from django.utils.encoding import force_bytes, force_str
from django.utils.http import (
base36_to_int,
int_to_base36,
urlsafe_base64_decode,
urlsafe_base64_encode,
)


class PasswordResetTokenGenerator:
Expand Down Expand Up @@ -128,5 +135,40 @@ def _now(self):
# Used for mocking in tests
return datetime.now()

def _xor_encrypt_decrypt(self, uid):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two "public" APIs being added to this method should be fully tested, including the corner cases described in the docstring (thanks for the thorough details).

"""
Performs XOR encryption/decryption on a uid to obfuscate
its value in the reset link.
This approach avoids adding a new dependency for encryption,
which is not natively part of Python's standard library.
The cypher key is a salted hash of the SECRET_KEY.
It is important that the cipher key is at least as long as the uid.
We use the SHA-512 hash algorithm to ensure a long enough cipher key (64 bytes)
to encrypt UUID4s (36 bytes) that might be used as primary key.
BigAutoField (the default primary key) is also supported since
it has a maximum size of 19 bytes. We cycle the key to also support the
unlikely scenario that the uid is longer than 64 bytes.
"""
key = hashlib.sha512(force_bytes(f"{self.key_salt}{self.secret}")).digest()
uid_bytes = force_bytes(uid)
xor_ciphertext = bytes(
a ^ b for a, b in zip(uid_bytes, (key * (len(uid_bytes) // len(key) + 1)))
)
return xor_ciphertext

def encrypt_uid(self, uid):
"""
Returns a XOR-encrypted user id for use in the password reset mechanism.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstrings should follow PEP-8 and PEP-257, specifically verbs should be written as a command. An alternative proposal would be:

Suggested change
Returns a XOR-encrypted user id for use in the password reset mechanism.
XOR-encrypt the provided user ID and return its base64-encoded representation.

"""
xor_ciphertext = self._xor_encrypt_decrypt(uid)
return urlsafe_base64_encode(xor_ciphertext)

def decrypt_uid(self, encrypted_uidb64):
"""
Returns the decrypted user id given the base64-encoded encrypted user id.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Returns the decrypted user id given the base64-encoded encrypted user id.
Return the user id given its base64-encoded XOR-encrypted representation.

"""
xor_ciphertext = urlsafe_base64_decode(encrypted_uidb64)
return force_str(self._xor_encrypt_decrypt(xor_ciphertext))


default_token_generator = PasswordResetTokenGenerator()
21 changes: 18 additions & 3 deletions django/contrib/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,9 @@ def dispatch(self, *args, **kwargs):
return self.render_to_response(self.get_context_data())

def get_user(self, uidb64):
user = None
try:
# urlsafe_base64_decode() decodes to bytestring
uid = urlsafe_base64_decode(uidb64).decode()
uid = self.token_generator.decrypt_uid(uidb64)
user = UserModel._default_manager.get(pk=uid)
except (
TypeError,
Expand All @@ -309,7 +309,22 @@ def get_user(self, uidb64):
UserModel.DoesNotExist,
ValidationError,
):
user = None
pass

# Temporarily support the old format of uid base64-encoded as plain text
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good, supporting old links. But we can't have this code forever in Django, we need a clear and documented deprecation path. My advice is to follow the deprecating a feature docs and raise a deprecation warning for each successful hit for old-style links.

I do wonder if we need a more clever approach here to handle the deprecation... There is an ongoing conversation about signing CSRF cookies which has some common points with this. I will reference this PR in the forum topic but at this point I think we need to involve the community to design a good plan for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, whatever we end up doing for the deprecation/transition of link and token obfuscation, we need to add tests for it.

if user is None:
try:
uid = urlsafe_base64_decode(uidb64).decode()
user = UserModel._default_manager.get(pk=uid)
except (
TypeError,
ValueError,
OverflowError,
UserModel.DoesNotExist,
ValidationError,
):
pass

return user

def get_form_kwargs(self):
Expand Down
2 changes: 2 additions & 0 deletions docs/releases/5.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Minor features
* The default iteration count for the PBKDF2 password hasher is increased from
870,000 to 1,000,000.

* The ``uid`` parameter in password reset emails is now obfuscated to prevent potential leakage of user count. This change hides the user's primary key (``user.pk``), which was previously only base64-encoded. The obfuscation is achieved using a simple XOR cipher.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the docs and docstrings lines should be wrapped at 79cols. Could you please summarize this a bit and ensure it wraps at that line length?


:mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
7 changes: 3 additions & 4 deletions tests/auth_tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
)
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from django.utils.http import urlsafe_base64_encode

from .client import PasswordResetConfirmClient
from .models import CustomUser
Expand Down Expand Up @@ -67,7 +66,7 @@ def test_password_reset_confirm_view_valid_token(self):
client = PasswordResetConfirmClient()
default_token_generator = PasswordResetTokenGenerator()
token = default_token_generator.make_token(self.user)
uidb64 = urlsafe_base64_encode(str(self.user.pk).encode())
uidb64 = default_token_generator.encrypt_uid(self.user.pk)
url = reverse(
"password_reset_confirm", kwargs={"uidb64": uidb64, "token": token}
)
Expand All @@ -87,7 +86,7 @@ def test_password_reset_confirm_view_error_title(self):
client = PasswordResetConfirmClient()
default_token_generator = PasswordResetTokenGenerator()
token = default_token_generator.make_token(self.user)
uidb64 = urlsafe_base64_encode(str(self.user.pk).encode())
uidb64 = default_token_generator.encrypt_uid(self.user.pk)
url = reverse(
"password_reset_confirm", kwargs={"uidb64": uidb64, "token": token}
)
Expand All @@ -106,7 +105,7 @@ def test_password_reset_confirm_view_custom_username_hint(self):
client = PasswordResetConfirmClient()
default_token_generator = PasswordResetTokenGenerator()
token = default_token_generator.make_token(custom_user)
uidb64 = urlsafe_base64_encode(str(custom_user.pk).encode())
uidb64 = default_token_generator.encrypt_uid(custom_user.pk)
url = reverse(
"password_reset_confirm", kwargs={"uidb64": uidb64, "token": token}
)
Expand Down
7 changes: 4 additions & 3 deletions tests/auth_tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SetPasswordForm,
)
from django.contrib.auth.models import Permission, User
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.contrib.auth.views import (
INTERNAL_RESET_SESSION_TOKEN,
LoginView,
Expand All @@ -35,7 +36,6 @@
from django.test import Client, TestCase, modify_settings, override_settings
from django.test.client import RedirectCycleError
from django.urls import NoReverseMatch, reverse, reverse_lazy
from django.utils.http import urlsafe_base64_encode

from .client import PasswordResetConfirmClient
from .models import CustomUser, UUIDUser
Expand Down Expand Up @@ -552,9 +552,10 @@ def _test_confirm_start(self):
return super()._test_confirm_start()

def test_confirm_invalid_uuid(self):
"""A uidb64 that decodes to a non-UUID doesn't crash."""
"""An encrypted non-UUID doesn't crash."""
_, path = self._test_confirm_start()
invalid_uidb64 = urlsafe_base64_encode(b"INVALID_UUID")
default_token_generator = PasswordResetTokenGenerator()
invalid_uidb64 = default_token_generator.encrypt_uid("INVALID_UUID")
first, _uuidb64_, second = path.strip("/").split("/")
response = self.client.get(
"/" + "/".join((first, invalid_uidb64, second)) + "/"
Expand Down