瀏覽代碼

Refs #21379 -- Normalized unicode username inputs

Claude Paroz 9 年之前
父節點
當前提交
9935f97cd2

+ 7 - 1
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'

+ 10 - 1
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': ''}),
     )

+ 1 - 0
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)

+ 8 - 0
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

+ 7 - 0
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 = (

+ 23 - 0
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': {