summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude Paroz <claude@2xlibre.net>2016-04-22 21:17:42 +0200
committerClaude Paroz <claude@2xlibre.net>2016-05-16 19:38:02 +0200
commit9935f97cd203bdcc722bc3d4e96858e221d96ff8 (patch)
treebdc45bf056fc8ab8ff8bfeadf403d215aee699fb
parent526575c64150e10dd8666d1ed3f86eedd00df2ed (diff)
Refs #21379 -- Normalized unicode username inputs
-rw-r--r--django/contrib/auth/base_user.py8
-rw-r--r--django/contrib/auth/forms.py11
-rw-r--r--django/contrib/auth/models.py1
-rw-r--r--docs/topics/auth/customizing.txt8
-rw-r--r--tests/auth_tests/test_basic.py7
-rw-r--r--tests/auth_tests/test_forms.py23
6 files changed, 56 insertions, 2 deletions
diff --git a/django/contrib/auth/base_user.py b/django/contrib/auth/base_user.py
index c8f748cf02..d168262bb0 100644
--- a/django/contrib/auth/base_user.py
+++ b/django/contrib/auth/base_user.py
@@ -4,6 +4,8 @@ not in INSTALLED_APPS.
"""
from __future__ import unicode_literals
+import unicodedata
+
from django.contrib.auth import password_validation
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
@@ -11,7 +13,7 @@ from django.contrib.auth.hashers import (
from django.db import models
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.deprecation import CallableFalse, CallableTrue
-from django.utils.encoding import python_2_unicode_compatible
+from django.utils.encoding import force_text, python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
@@ -31,6 +33,10 @@ 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'
diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py
index 692cdb5dab..f279f5e893 100644
--- a/django/contrib/auth/forms.py
+++ b/django/contrib/auth/forms.py
@@ -1,5 +1,7 @@
from __future__ import unicode_literals
+import unicodedata
+
from django import forms
from django.contrib.auth import (
authenticate, get_user_model, password_validation,
@@ -60,6 +62,11 @@ class ReadOnlyPasswordHashField(forms.Field):
return False
+class UsernameField(forms.CharField):
+ def to_python(self, value):
+ return unicodedata.normalize('NFKC', super(UsernameField, self).to_python(value))
+
+
class UserCreationForm(forms.ModelForm):
"""
A form that creates a user, with no privileges, from the given username and
@@ -83,6 +90,7 @@ class UserCreationForm(forms.ModelForm):
class Meta:
model = User
fields = ("username",)
+ field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
super(UserCreationForm, self).__init__(*args, **kwargs)
@@ -121,6 +129,7 @@ class UserChangeForm(forms.ModelForm):
class Meta:
model = User
fields = '__all__'
+ field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
super(UserChangeForm, self).__init__(*args, **kwargs)
@@ -140,7 +149,7 @@ class AuthenticationForm(forms.Form):
Base class for authenticating users. Extend this to get a form that accepts
username/password logins.
"""
- username = forms.CharField(
+ username = UsernameField(
max_length=254,
widget=forms.TextInput(attrs={'autofocus': ''}),
)
diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py
index 8fcdf9108c..87c3adf36b 100644
--- a/django/contrib/auth/models.py
+++ b/django/contrib/auth/models.py
@@ -145,6 +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)
user = self.model(username=username, email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
diff --git a/docs/topics/auth/customizing.txt b/docs/topics/auth/customizing.txt
index 9120c6a1f0..8dd938e59a 100644
--- a/docs/topics/auth/customizing.txt
+++ b/docs/topics/auth/customizing.txt
@@ -726,6 +726,14 @@ 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_basic.py b/tests/auth_tests/test_basic.py
index da42f4055f..87122d9229 100644
--- a/tests/auth_tests/test_basic.py
+++ b/tests/auth_tests/test_basic.py
@@ -7,6 +7,7 @@ from django.apps import apps
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.core.exceptions import ImproperlyConfigured
+from django.db import IntegrityError
from django.dispatch import receiver
from django.test import TestCase, override_settings
from django.test.signals import setting_changed
@@ -60,6 +61,12 @@ class BasicTestCase(TestCase):
def test_unicode_username(self):
User.objects.create_user('jörg')
User.objects.create_user('Григорий')
+ # Two equivalent unicode normalized usernames should be duplicates
+ omega_username = 'iamtheΩ' # U+03A9 GREEK CAPITAL LETTER OMEGA
+ ohm_username = 'iamtheΩ' # U+2126 OHM SIGN
+ User.objects.create_user(ohm_username)
+ with self.assertRaises(IntegrityError):
+ User.objects.create_user(omega_username)
def test_is_anonymous_authenticated_method_deprecation(self):
deprecation_message = (
diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py
index 212dff1ab0..d2ce828eb8 100644
--- a/tests/auth_tests/test_forms.py
+++ b/tests/auth_tests/test_forms.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
import datetime
import re
+from unittest import skipIf
from django import forms
from django.contrib.auth.forms import (
@@ -118,6 +119,28 @@ class UserCreationFormTest(TestDataMixin, TestCase):
else:
self.assertFalse(form.is_valid())
+ @skipIf(six.PY2, "Python 2 doesn't support unicode usernames by default.")
+ def test_duplicate_normalized_unicode(self):
+ """
+ To prevent almost identical usernames, visually identical but differing
+ by their unicode code points only, Unicode NFKC normalization should
+ make appear them equal to Django.
+ """
+ omega_username = 'iamtheΩ' # U+03A9 GREEK CAPITAL LETTER OMEGA
+ ohm_username = 'iamtheΩ' # U+2126 OHM SIGN
+ self.assertNotEqual(omega_username, ohm_username)
+ User.objects.create_user(username=omega_username, password='pwd')
+ data = {
+ 'username': ohm_username,
+ 'password1': 'pwd2',
+ 'password2': 'pwd2',
+ }
+ form = UserCreationForm(data)
+ self.assertFalse(form.is_valid())
+ self.assertEqual(
+ form.errors['username'], ["A user with that username already exists."]
+ )
+
@override_settings(AUTH_PASSWORD_VALIDATORS=[
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {