Refs #21379 -- Normalized unicode username inputs

This commit is contained in:
Claude Paroz 2016-04-22 21:17:42 +02:00
parent 526575c641
commit 9935f97cd2
6 changed files with 56 additions and 2 deletions

View File

@ -4,6 +4,8 @@ not in INSTALLED_APPS.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
import unicodedata
from django.contrib.auth import password_validation from django.contrib.auth import password_validation
from django.contrib.auth.hashers import ( from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password, check_password, is_password_usable, make_password,
@ -11,7 +13,7 @@ from django.contrib.auth.hashers import (
from django.db import models from django.db import models
from django.utils.crypto import get_random_string, salted_hmac from django.utils.crypto import get_random_string, salted_hmac
from django.utils.deprecation import CallableFalse, CallableTrue 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 _ from django.utils.translation import ugettext_lazy as _
@ -31,6 +33,10 @@ class BaseUserManager(models.Manager):
email = '@'.join([email_name, domain_part.lower()]) email = '@'.join([email_name, domain_part.lower()])
return email return email
@classmethod
def normalize_username(cls, username):
return unicodedata.normalize('NFKC', force_text(username))
def make_random_password(self, length=10, def make_random_password(self, length=10,
allowed_chars='abcdefghjkmnpqrstuvwxyz' allowed_chars='abcdefghjkmnpqrstuvwxyz'
'ABCDEFGHJKLMNPQRSTUVWXYZ' 'ABCDEFGHJKLMNPQRSTUVWXYZ'

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import unicodedata
from django import forms from django import forms
from django.contrib.auth import ( from django.contrib.auth import (
authenticate, get_user_model, password_validation, authenticate, get_user_model, password_validation,
@ -60,6 +62,11 @@ class ReadOnlyPasswordHashField(forms.Field):
return False 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): class UserCreationForm(forms.ModelForm):
""" """
A form that creates a user, with no privileges, from the given username and A form that creates a user, with no privileges, from the given username and
@ -83,6 +90,7 @@ class UserCreationForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ("username",) fields = ("username",)
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(UserCreationForm, self).__init__(*args, **kwargs) super(UserCreationForm, self).__init__(*args, **kwargs)
@ -121,6 +129,7 @@ class UserChangeForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = '__all__' fields = '__all__'
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(UserChangeForm, self).__init__(*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 Base class for authenticating users. Extend this to get a form that accepts
username/password logins. username/password logins.
""" """
username = forms.CharField( username = UsernameField(
max_length=254, max_length=254,
widget=forms.TextInput(attrs={'autofocus': ''}), widget=forms.TextInput(attrs={'autofocus': ''}),
) )

View File

@ -145,6 +145,7 @@ class UserManager(BaseUserManager):
if not username: if not username:
raise ValueError('The given username must be set') raise ValueError('The given username must be set')
email = self.normalize_email(email) email = self.normalize_email(email)
username = self.normalize_username(username)
user = self.model(username=username, email=email, **extra_fields) user = self.model(username=username, email=email, **extra_fields)
user.set_password(password) user.set_password(password)
user.save(using=self._db) user.save(using=self._db)

View File

@ -726,6 +726,14 @@ utility methods:
Normalizes email addresses by lowercasing the domain portion of the Normalizes email addresses by lowercasing the domain portion of the
email address. 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) .. method:: models.BaseUserManager.get_by_natural_key(username)
Retrieves a user instance using the contents of the field Retrieves a user instance using the contents of the field

View File

@ -7,6 +7,7 @@ from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User from django.contrib.auth.models import AnonymousUser, User
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import IntegrityError
from django.dispatch import receiver from django.dispatch import receiver
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.signals import setting_changed from django.test.signals import setting_changed
@ -60,6 +61,12 @@ class BasicTestCase(TestCase):
def test_unicode_username(self): def test_unicode_username(self):
User.objects.create_user('jörg') User.objects.create_user('jörg')
User.objects.create_user('Григорий') 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): def test_is_anonymous_authenticated_method_deprecation(self):
deprecation_message = ( deprecation_message = (

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals
import datetime import datetime
import re import re
from unittest import skipIf
from django import forms from django import forms
from django.contrib.auth.forms import ( from django.contrib.auth.forms import (
@ -118,6 +119,28 @@ class UserCreationFormTest(TestDataMixin, TestCase):
else: else:
self.assertFalse(form.is_valid()) 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=[ @override_settings(AUTH_PASSWORD_VALIDATORS=[
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': { {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {