diff --git a/django/contrib/auth/base_user.py b/django/contrib/auth/base_user.py index d168262bb0..de069659e1 100644 --- a/django/contrib/auth/base_user.py +++ b/django/contrib/auth/base_user.py @@ -33,10 +33,6 @@ class BaseUserManager(models.Manager): email = '@'.join([email_name, domain_part.lower()]) return email - @classmethod - def normalize_username(cls, username): - return unicodedata.normalize('NFKC', force_text(username)) - def make_random_password(self, length=10, allowed_chars='abcdefghjkmnpqrstuvwxyz' 'ABCDEFGHJKLMNPQRSTUVWXYZ' @@ -77,6 +73,9 @@ class AbstractBaseUser(models.Model): def __str__(self): return self.get_username() + def clean(self): + setattr(self, self.USERNAME_FIELD, self.normalize_username(self.get_username())) + def save(self, *args, **kwargs): super(AbstractBaseUser, self).save(*args, **kwargs) if self._password is not None: @@ -137,3 +136,7 @@ class AbstractBaseUser(models.Model): """ key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash" return salted_hmac(key_salt, self.password).hexdigest() + + @classmethod + def normalize_username(cls, username): + return unicodedata.normalize('NFKC', force_text(username)) diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 87c3adf36b..4812546340 100644 --- a/django/contrib/auth/models.py +++ b/django/contrib/auth/models.py @@ -145,7 +145,7 @@ class UserManager(BaseUserManager): if not username: raise ValueError('The given username must be set') email = self.normalize_email(email) - username = self.normalize_username(username) + username = self.model.normalize_username(username) user = self.model(username=username, email=email, **extra_fields) user.set_password(password) user.save(using=self._db) diff --git a/docs/releases/1.10.txt b/docs/releases/1.10.txt index 752684e3fa..c56ac6efc6 100644 --- a/docs/releases/1.10.txt +++ b/docs/releases/1.10.txt @@ -887,6 +887,10 @@ Miscellaneous * Accessing a deleted field on a model instance, e.g. after ``del obj.field``, reloads the field's value instead of raising ``AttributeError``. +* If you subclass ``AbstractBaseUser`` and override ``clean()``, be sure it + calls ``super()``. :meth:`.AbstractBaseUser.normalize_username` is called in + a new :meth:`.AbstractBaseUser.clean` method. + .. _deprecated-features-1.10: Features deprecated in 1.10 diff --git a/docs/topics/auth/customizing.txt b/docs/topics/auth/customizing.txt index 511300b4ae..75dd4160d0 100644 --- a/docs/topics/auth/customizing.txt +++ b/docs/topics/auth/customizing.txt @@ -608,6 +608,22 @@ The following attributes and methods are available on any subclass of Returns the value of the field nominated by ``USERNAME_FIELD``. + .. method:: clean() + + .. versionadded:: 1.10 + + Normalizes the username by calling :meth:`normalize_username`. If you + override this method, be sure to call ``super()`` to retain the + normalization. + + .. classmethod:: normalize_username(username) + + .. versionadded:: 1.10 + + Applies NFKC Unicode normalization to usernames so that visually + identical characters with different Unicode code points are considered + identical. + .. attribute:: models.AbstractBaseUser.is_authenticated Read-only attribute which is always ``True`` (as opposed to @@ -722,14 +738,6 @@ utility methods: Normalizes email addresses by lowercasing the domain portion of the email address. - .. classmethod:: models.BaseUserManager.normalize_username(email) - - .. versionadded:: 1.10 - - Applies NFKC Unicode normalization to usernames so that visually - identical characters with different Unicode code points are considered - identical. - .. method:: models.BaseUserManager.get_by_natural_key(username) Retrieves a user instance using the contents of the field diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index d2ce828eb8..fe22d1e20b 100644 --- a/tests/auth_tests/test_forms.py +++ b/tests/auth_tests/test_forms.py @@ -119,6 +119,22 @@ class UserCreationFormTest(TestDataMixin, TestCase): else: self.assertFalse(form.is_valid()) + @skipIf(six.PY2, "Python 2 doesn't support unicode usernames by default.") + def test_normalize_username(self): + # The normalization happens in AbstractBaseUser.clean() and ModelForm + # validation calls Model.clean(). + ohm_username = 'testΩ' # U+2126 OHM SIGN + data = { + 'username': ohm_username, + 'password1': 'pwd2', + 'password2': 'pwd2', + } + form = UserCreationForm(data) + self.assertTrue(form.is_valid()) + user = form.save() + self.assertNotEqual(user.username, ohm_username) + self.assertEqual(user.username, 'testΩ') # U+03A9 GREEK CAPITAL LETTER OMEGA + @skipIf(six.PY2, "Python 2 doesn't support unicode usernames by default.") def test_duplicate_normalized_unicode(self): """ diff --git a/tests/auth_tests/test_models.py b/tests/auth_tests/test_models.py index 1f9edbc77c..7967306c43 100644 --- a/tests/auth_tests/test_models.py +++ b/tests/auth_tests/test_models.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + from django.conf.global_settings import PASSWORD_HASHERS from django.contrib.auth import get_user_model from django.contrib.auth.hashers import get_hasher @@ -143,6 +146,21 @@ class UserManagerTestCase(TestCase): ) +class AbstractBaseUserTests(TestCase): + + def test_clean_normalize_username(self): + # The normalization happens in AbstractBaseUser.clean() + ohm_username = 'iamtheΩ' # U+2126 OHM SIGN + for model in ('auth.User', 'auth_tests.CustomUser'): + with self.settings(AUTH_USER_MODEL=model): + User = get_user_model() + user = User(**{User.USERNAME_FIELD: ohm_username, 'password': 'foo'}) + user.clean() + username = user.get_username() + self.assertNotEqual(username, ohm_username) + self.assertEqual(username, 'iamtheΩ') # U+03A9 GREEK CAPITAL LETTER OMEGA + + class AbstractUserTestCase(TestCase): def test_email_user(self): # valid send_mail parameters