Browse Source

Fixed #25617 -- Added case-insensitive unique username validation in UserCreationForm.

Co-Authored-By: Neven Mundar <nmundar@gmail.com>
Paul Schilling 2 years ago
parent
commit
298d02a77a
4 changed files with 79 additions and 28 deletions
  1. 16 1
      django/contrib/auth/forms.py
  2. 7 0
      docs/releases/4.2.txt
  3. 13 2
      docs/topics/auth/default.txt
  4. 43 25
      tests/auth_tests/test_forms.py

+ 16 - 1
django/contrib/auth/forms.py

@@ -81,7 +81,7 @@ class UsernameField(forms.CharField):
         }
 
 
-class UserCreationForm(forms.ModelForm):
+class BaseUserCreationForm(forms.ModelForm):
     """
     A form that creates a user, with no privileges, from the given username and
     password.
@@ -146,6 +146,21 @@ class UserCreationForm(forms.ModelForm):
         return user
 
 
+class UserCreationForm(BaseUserCreationForm):
+    error_messages = {
+        **BaseUserCreationForm.error_messages,
+        "unique": _("A user with that username already exists."),
+    }
+
+    def clean_username(self):
+        """Reject usernames that differ only in case."""
+        username = self.cleaned_data.get("username")
+        if username and User.objects.filter(username__iexact=username).exists():
+            raise forms.ValidationError(self.error_messages["unique"], code="unique")
+        else:
+            return username
+
+
 class UserChangeForm(forms.ModelForm):
     password = ReadOnlyPasswordHashField(
         label=_("Password"),

+ 7 - 0
docs/releases/4.2.txt

@@ -127,6 +127,9 @@ Minor features
 * :class:`~django.contrib.auth.forms.UserCreationForm` now saves many-to-many
   form fields for a custom user model.
 
+* The new :class:`~django.contrib.auth.forms.BaseUserCreationForm` is now the
+  recommended base class for customizing the user creation form.
+
 :mod:`django.contrib.contenttypes`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -484,6 +487,10 @@ Miscellaneous
 * The minimum supported version of ``asgiref`` is increased from 3.5.2 to
   3.6.0.
 
+* :class:`~django.contrib.auth.forms.UserCreationForm` now rejects usernames
+  that differ only in case. If you need the previous behavior, use
+  :class:`~django.contrib.auth.forms.BaseUserCreationForm` instead.
+
 .. _deprecated-features-4.2:
 
 Features deprecated in 4.2

+ 13 - 2
docs/topics/auth/default.txt

@@ -1654,9 +1654,12 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`:
     A form used in the admin interface to change a user's information and
     permissions.
 
-.. class:: UserCreationForm
+.. class:: BaseUserCreationForm
+
+    .. versionadded:: 4.2
 
-    A :class:`~django.forms.ModelForm` for creating a new user.
+    A :class:`~django.forms.ModelForm` for creating a new user. This is the
+    recommended base class if you need to customize the user creation form.
 
     It has three fields: ``username`` (from the user model), ``password1``,
     and ``password2``. It verifies that ``password1`` and ``password2`` match,
@@ -1665,11 +1668,19 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`:
     sets the user's password using
     :meth:`~django.contrib.auth.models.User.set_password()`.
 
+.. class:: UserCreationForm
+
+    Inherits from :class:`BaseUserCreationForm`. To help prevent confusion with
+    similar usernames, the form doesn't allow usernames that differ only in
+    case.
+
     .. versionchanged:: 4.2
 
         In older versions, :class:`UserCreationForm` didn't save many-to-many
         form fields for a custom user model.
 
+        In older versions, usernames that differ only in case are allowed.
+
 .. currentmodule:: django.contrib.auth
 
 Authentication data in templates

+ 43 - 25
tests/auth_tests/test_forms.py

@@ -6,6 +6,7 @@ from unittest import mock
 from django.contrib.auth.forms import (
     AdminPasswordChangeForm,
     AuthenticationForm,
+    BaseUserCreationForm,
     PasswordChangeForm,
     PasswordResetForm,
     ReadOnlyPasswordHashField,
@@ -54,14 +55,14 @@ class TestDataMixin:
         cls.u6 = User.objects.create(username="unknown_password", password="foo$bar")
 
 
-class UserCreationFormTest(TestDataMixin, TestCase):
+class BaseUserCreationFormTest(TestDataMixin, TestCase):
     def test_user_already_exists(self):
         data = {
             "username": "testclient",
             "password1": "test123",
             "password2": "test123",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertFalse(form.is_valid())
         self.assertEqual(
             form["username"].errors,
@@ -74,7 +75,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "test123",
             "password2": "test123",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertFalse(form.is_valid())
         validator = next(
             v
@@ -90,7 +91,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "test123",
             "password2": "test",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertFalse(form.is_valid())
         self.assertEqual(
             form["password2"].errors, [str(form.error_messages["password_mismatch"])]
@@ -99,7 +100,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
     def test_both_passwords(self):
         # One (or both) passwords weren't given
         data = {"username": "jsmith"}
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         required_error = [str(Field.default_error_messages["required"])]
         self.assertFalse(form.is_valid())
         self.assertEqual(form["password1"].errors, required_error)
@@ -119,7 +120,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "test123",
             "password2": "test123",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertTrue(form.is_valid())
         form.save(commit=False)
         self.assertEqual(password_changed.call_count, 0)
@@ -133,7 +134,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "test123",
             "password2": "test123",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertTrue(form.is_valid())
         u = form.save()
         self.assertEqual(u.username, "宝")
@@ -147,7 +148,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "pwd2",
             "password2": "pwd2",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertTrue(form.is_valid())
         user = form.save()
         self.assertNotEqual(user.username, ohm_username)
@@ -168,7 +169,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "pwd2",
             "password2": "pwd2",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertFalse(form.is_valid())
         self.assertEqual(
             form.errors["username"], ["A user with that username already exists."]
@@ -198,7 +199,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "testclient",
             "password2": "testclient",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertFalse(form.is_valid())
         self.assertEqual(len(form["password2"].errors), 2)
         self.assertIn(
@@ -210,8 +211,8 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         )
 
     def test_custom_form(self):
-        class CustomUserCreationForm(UserCreationForm):
-            class Meta(UserCreationForm.Meta):
+        class CustomUserCreationForm(BaseUserCreationForm):
+            class Meta(BaseUserCreationForm.Meta):
                 model = ExtensionUser
                 fields = UserCreationForm.Meta.fields + ("date_of_birth",)
 
@@ -225,8 +226,8 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         self.assertTrue(form.is_valid())
 
     def test_custom_form_with_different_username_field(self):
-        class CustomUserCreationForm(UserCreationForm):
-            class Meta(UserCreationForm.Meta):
+        class CustomUserCreationForm(BaseUserCreationForm):
+            class Meta(BaseUserCreationForm.Meta):
                 model = CustomUser
                 fields = ("email", "date_of_birth")
 
@@ -240,8 +241,8 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         self.assertTrue(form.is_valid())
 
     def test_custom_form_hidden_username_field(self):
-        class CustomUserCreationForm(UserCreationForm):
-            class Meta(UserCreationForm.Meta):
+        class CustomUserCreationForm(BaseUserCreationForm):
+            class Meta(BaseUserCreationForm.Meta):
                 model = CustomUserWithoutIsActiveField
                 fields = ("email",)  # without USERNAME_FIELD
 
@@ -254,8 +255,8 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         self.assertTrue(form.is_valid())
 
     def test_custom_form_saves_many_to_many_field(self):
-        class CustomUserCreationForm(UserCreationForm):
-            class Meta(UserCreationForm.Meta):
+        class CustomUserCreationForm(BaseUserCreationForm):
+            class Meta(BaseUserCreationForm.Meta):
                 model = CustomUserWithM2M
                 fields = UserCreationForm.Meta.fields + ("orgs",)
 
@@ -278,7 +279,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
             "password1": "   testpassword   ",
             "password2": "   testpassword   ",
         }
-        form = UserCreationForm(data)
+        form = BaseUserCreationForm(data)
         self.assertTrue(form.is_valid())
         self.assertEqual(form.cleaned_data["password1"], data["password1"])
         self.assertEqual(form.cleaned_data["password2"], data["password2"])
@@ -294,7 +295,7 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         ]
     )
     def test_password_help_text(self):
-        form = UserCreationForm()
+        form = BaseUserCreationForm()
         self.assertEqual(
             form.fields["password1"].help_text,
             "<ul><li>"
@@ -313,10 +314,12 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         ]
     )
     def test_user_create_form_validates_password_with_all_data(self):
-        """UserCreationForm password validation uses all of the form's data."""
+        """
+        BaseUserCreationForm password validation uses all of the form's data.
+        """
 
-        class CustomUserCreationForm(UserCreationForm):
-            class Meta(UserCreationForm.Meta):
+        class CustomUserCreationForm(BaseUserCreationForm):
+            class Meta(BaseUserCreationForm.Meta):
                 model = User
                 fields = ("username", "email", "first_name", "last_name")
 
@@ -336,13 +339,13 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         )
 
     def test_username_field_autocapitalize_none(self):
-        form = UserCreationForm()
+        form = BaseUserCreationForm()
         self.assertEqual(
             form.fields["username"].widget.attrs.get("autocapitalize"), "none"
         )
 
     def test_html_autocomplete_attributes(self):
-        form = UserCreationForm()
+        form = BaseUserCreationForm()
         tests = (
             ("username", "username"),
             ("password1", "new-password"),
@@ -355,6 +358,21 @@ class UserCreationFormTest(TestDataMixin, TestCase):
                 )
 
 
+class UserCreationFormTest(TestDataMixin, TestCase):
+    def test_case_insensitive_username(self):
+        data = {
+            "username": "TeStClIeNt",
+            "password1": "test123",
+            "password2": "test123",
+        }
+        form = UserCreationForm(data)
+        self.assertFalse(form.is_valid())
+        self.assertEqual(
+            form["username"].errors,
+            ["A user with that username already exists."],
+        )
+
+
 # To verify that the login form rejects inactive users, use an authentication
 # backend that allows them.
 @override_settings(