Skip to content

Commit

Permalink
Refs #21379, #26719 -- Moved username normalization to AbstractBaseUser.
Browse files Browse the repository at this point in the history
Thanks Huynh Thanh Tam for the initial patch and Claude Paroz for review.
  • Loading branch information
timgraham committed Jun 21, 2016
1 parent 5ce660c commit 3980568
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 13 deletions.
11 changes: 7 additions & 4 deletions django/contrib/auth/base_user.py
Expand Up @@ -33,10 +33,6 @@ def normalize_email(cls, email):
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'
Expand Down Expand Up @@ -77,6 +73,9 @@ def __init__(self, *args, **kwargs):
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:
Expand Down Expand Up @@ -137,3 +136,7 @@ def get_session_auth_hash(self):
"""
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))
2 changes: 1 addition & 1 deletion django/contrib/auth/models.py
Expand Up @@ -145,7 +145,7 @@ def _create_user(self, username, email, password, **extra_fields):
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)
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/1.10.txt
Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions docs/topics/auth/customizing.txt
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/auth_tests/test_forms.py
Expand Up @@ -119,6 +119,22 @@ def test_unicode_username(self):
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):
"""
Expand Down
18 changes: 18 additions & 0 deletions 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
Expand Down Expand Up @@ -143,6 +146,21 @@ def test_create_superuser_raises_error_on_false_is_staff(self):
)


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
Expand Down

0 comments on commit 3980568

Please sign in to comment.