Pārlūkot izejas kodu

Fixed #21379 -- Created auth-specific username validators

Thanks Tim Graham for the review.
Claude Paroz 9 gadi atpakaļ
vecāks
revīzija
526575c641

+ 5 - 3
django/contrib/auth/migrations/0001_initial.py

@@ -2,9 +2,9 @@
 from __future__ import unicode_literals
 
 import django.contrib.auth.models
-from django.core import validators
+from django.contrib.auth import validators
 from django.db import migrations, models
-from django.utils import timezone
+from django.utils import six, timezone
 
 
 class Migration(migrations.Migration):
@@ -66,7 +66,9 @@ class Migration(migrations.Migration):
                 ('username', models.CharField(
                     help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True,
                     max_length=30, verbose_name='username',
-                    validators=[validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username.', 'invalid')]
+                    validators=[
+                        validators.UnicodeUsernameValidator() if six.PY3 else validators.ASCIIUsernameValidator()
+                    ],
                 )),
                 ('first_name', models.CharField(max_length=30, verbose_name='first name', blank=True)),
                 ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)),

+ 3 - 6
django/contrib/auth/migrations/0004_alter_user_username_opts.py

@@ -1,8 +1,9 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import django.core.validators
+from django.contrib.auth import validators
 from django.db import migrations, models
+from django.utils import six
 
 
 class Migration(migrations.Migration):
@@ -18,11 +19,7 @@ class Migration(migrations.Migration):
             name='username',
             field=models.CharField(
                 error_messages={'unique': 'A user with that username already exists.'}, max_length=30,
-                validators=[django.core.validators.RegexValidator(
-                    '^[\\w.@+-]+$',
-                    'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.',
-                    'invalid'
-                )],
+                validators=[validators.UnicodeUsernameValidator() if six.PY3 else validators.ASCIIUsernameValidator()],
                 help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.',
                 unique=True, verbose_name='username'
             ),

+ 3 - 7
django/contrib/auth/migrations/0007_alter_validators_add_error_messages.py

@@ -1,8 +1,9 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import django.core.validators
+from django.contrib.auth import validators
 from django.db import migrations, models
+from django.utils import six
 
 
 class Migration(migrations.Migration):
@@ -20,12 +21,7 @@ class Migration(migrations.Migration):
                 help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.',
                 max_length=30,
                 unique=True,
-                validators=[
-                    django.core.validators.RegexValidator(
-                        '^[\\w.@+-]+$', 'Enter a valid username. '
-                        'This value may contain only letters, numbers and @/./+/-/_ characters.'
-                    ),
-                ],
+                validators=[validators.UnicodeUsernameValidator() if six.PY3 else validators.ASCIIUsernameValidator()],
                 verbose_name='username',
             ),
         ),

+ 3 - 7
django/contrib/auth/migrations/0008_alter_user_username_max_length.py

@@ -1,8 +1,9 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import django.core.validators
+from django.contrib.auth import validators
 from django.db import migrations, models
+from django.utils import six
 
 
 class Migration(migrations.Migration):
@@ -20,12 +21,7 @@ class Migration(migrations.Migration):
                 help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
                 max_length=150,
                 unique=True,
-                validators=[
-                    django.core.validators.RegexValidator(
-                        '^[\\w.@+-]+$', 'Enter a valid username. '
-                        'This value may contain only letters, numbers and @/./+/-/_ characters.'
-                    ),
-                ],
+                validators=[validators.UnicodeUsernameValidator() if six.PY3 else validators.ASCIIUsernameValidator()],
                 verbose_name='username',
             ),
         ),

+ 5 - 8
django/contrib/auth/models.py

@@ -4,7 +4,6 @@ from django.contrib import auth
 from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
 from django.contrib.auth.signals import user_logged_in
 from django.contrib.contenttypes.models import ContentType
-from django.core import validators
 from django.core.exceptions import PermissionDenied
 from django.core.mail import send_mail
 from django.db import models
@@ -14,6 +13,8 @@ from django.utils.deprecation import CallableFalse, CallableTrue
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
+from .validators import ASCIIUsernameValidator, UnicodeUsernameValidator
+
 
 def update_last_login(sender, user, **kwargs):
     """
@@ -302,18 +303,14 @@ class AbstractUser(AbstractBaseUser, PermissionsMixin):
 
     Username and password are required. Other fields are optional.
     """
+    username_validator = UnicodeUsernameValidator() if six.PY3 else ASCIIUsernameValidator()
+
     username = models.CharField(
         _('username'),
         max_length=150,
         unique=True,
         help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'),
-        validators=[
-            validators.RegexValidator(
-                r'^[\w.@+-]+$',
-                _('Enter a valid username. This value may contain only '
-                  'letters, numbers ' 'and @/./+/-/_ characters.')
-            ),
-        ],
+        validators=[username_validator],
         error_messages={
             'unique': _("A user with that username already exists."),
         },

+ 26 - 0
django/contrib/auth/validators.py

@@ -0,0 +1,26 @@
+import re
+
+from django.core import validators
+from django.utils import six
+from django.utils.deconstruct import deconstructible
+from django.utils.translation import ugettext_lazy as _
+
+
+@deconstructible
+class ASCIIUsernameValidator(validators.RegexValidator):
+    regex = r'^[\w.@+-]+$'
+    message = _(
+        'Enter a valid username. This value may contain only English letters, '
+        'numbers, and @/./+/-/_ characters.'
+    )
+    flags = re.ASCII if six.PY3 else 0
+
+
+@deconstructible
+class UnicodeUsernameValidator(validators.RegexValidator):
+    regex = r'^[\w.@+-]+$'
+    message = _(
+        'Enter a valid username. This value may contain only letters, '
+        'numbers, and @/./+/-/_ characters.'
+    )
+    flags = re.UNICODE if six.PY2 else 0

+ 49 - 1
docs/ref/contrib/auth.txt

@@ -32,6 +32,15 @@ Fields
         ``max_length=191`` because MySQL can only create unique indexes with
         191 characters in that case by default.
 
+        .. admonition:: Usernames and Unicode
+
+            Django originally accepted only ASCII letters in usernames.
+            Although it wasn't a deliberate choice, Unicode characters have
+            always been accepted when using Python 3. Django 1.10 officially
+            added Unicode support in usernames, keeping the ASCII-only behavior
+            on Python 2, with the option to customize the behavior using
+            :attr:`.User.username_validator`.
+
         .. versionchanged:: 1.10
 
             The ``max_length`` increased from 30 to 150 characters.
@@ -146,6 +155,27 @@ Attributes
             In older versions, this was a method. Backwards-compatibility
             support for using it as a method will be removed in Django 2.0.
 
+    .. attribute:: username_validator
+
+        .. versionadded:: 1.10
+
+        Points to a validator instance used to validate usernames. Defaults to
+        :class:`validators.UnicodeUsernameValidator` on Python 3 and
+        :class:`validators.ASCIIUsernameValidator` on Python 2.
+
+        To change the default username validator, you can subclass the ``User``
+        model and set this attribute to a different validator instance. For
+        example, to use ASCII usernames on Python 3::
+
+            from django.contrib.auth.models import User
+            from django.contrib.auth.validators import ASCIIUsernameValidator
+
+            class CustomUser(User):
+                username_validator = ASCIIUsernameValidator()
+
+                class Meta:
+                    proxy = True  # If no new field is added.
+
 Methods
 -------
 
@@ -285,7 +315,6 @@ Manager methods
         Same as :meth:`create_user`, but sets :attr:`~models.User.is_staff` and
         :attr:`~models.User.is_superuser` to ``True``.
 
-
 ``AnonymousUser`` object
 ========================
 
@@ -378,6 +407,25 @@ Fields
             group.permissions.remove(permission, permission, ...)
             group.permissions.clear()
 
+Validators
+==========
+
+.. class:: validators.ASCIIUsernameValidator
+
+    .. versionadded:: 1.10
+
+    A field validator allowing only ASCII letters, in addition to ``@``, ``.``,
+    ``+``, ``-``, and ``_``. The default validator for ``User.username`` on
+    Python 2.
+
+.. class:: validators.UnicodeUsernameValidator
+
+    .. versionadded:: 1.10
+
+    A field validator allowing Unicode letters, in addition to ``@``, ``.``,
+    ``+``, ``-``, and ``_``. The default validator for ``User.username`` on
+    Python 3.
+
 .. _topics-auth-signals:
 
 Login and logout signals

+ 16 - 0
docs/releases/1.10.txt

@@ -37,6 +37,22 @@ It also now includes trigram support, using the :lookup:`trigram_similar`
 lookup, and the :class:`~django.contrib.postgres.search.TrigramSimilarity` and
 :class:`~django.contrib.postgres.search.TrigramDistance` expressions.
 
+Official support for Unicode usernames
+--------------------------------------
+
+The :class:`~django.contrib.auth.models.User` model in ``django.contrib.auth``
+originally only accepted ASCII letters in usernames. Although it wasn't a
+deliberate choice, Unicode characters have always been accepted when using
+Python 3.
+
+The username validator now explicitly accepts Unicode letters by
+default on Python 3 only. This default behavior can be overridden by changing
+the :attr:`~django.contrib.auth.models.User.username_validator` attribute of
+the ``User`` model, or to any proxy of that model, using either
+:class:`~django.contrib.auth.validators.ASCIIUsernameValidator` or
+:class:`~django.contrib.auth.validators.UnicodeUsernameValidator`. Custom user
+models may also use those validators.
+
 Minor features
 --------------
 

+ 5 - 0
tests/auth_tests/test_basic.py

@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
 import warnings
@@ -56,6 +57,10 @@ class BasicTestCase(TestCase):
         u2 = User.objects.create_user('testuser2', 'test2@example.com')
         self.assertFalse(u2.has_usable_password())
 
+    def test_unicode_username(self):
+        User.objects.create_user('jörg')
+        User.objects.create_user('Григорий')
+
     def test_is_anonymous_authenticated_method_deprecation(self):
         deprecation_message = (
             'Using user.is_authenticated() and user.is_anonymous() as a '

+ 25 - 1
tests/auth_tests/test_forms.py

@@ -16,7 +16,7 @@ from django.core import mail
 from django.core.mail import EmailMultiAlternatives
 from django.forms.fields import CharField, Field
 from django.test import SimpleTestCase, TestCase, mock, override_settings
-from django.utils import translation
+from django.utils import six, translation
 from django.utils.encoding import force_text
 from django.utils.text import capfirst
 from django.utils.translation import ugettext as _
@@ -104,6 +104,20 @@ class UserCreationFormTest(TestDataMixin, TestCase):
         self.assertEqual(password_changed.call_count, 1)
         self.assertEqual(repr(u), '<User: jsmith@example.com>')
 
+    def test_unicode_username(self):
+        data = {
+            'username': '宝',
+            'password1': 'test123',
+            'password2': 'test123',
+        }
+        form = UserCreationForm(data)
+        if six.PY3:
+            self.assertTrue(form.is_valid())
+            u = form.save()
+            self.assertEqual(u.username, '宝')
+        else:
+            self.assertFalse(form.is_valid())
+
     @override_settings(AUTH_PASSWORD_VALIDATORS=[
         {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
         {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {
@@ -254,6 +268,16 @@ class AuthenticationFormTest(TestDataMixin, TestCase):
         self.assertTrue(form.is_valid())
         self.assertEqual(form.non_field_errors(), [])
 
+    def test_unicode_username(self):
+        User.objects.create_user(username='Σαρα', password='pwd')
+        data = {
+            'username': 'Σαρα',
+            'password': 'pwd',
+        }
+        form = AuthenticationForm(None, data)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.non_field_errors(), [])
+
     def test_username_field_label(self):
 
         class CustomAuthenticationForm(AuthenticationForm):

+ 28 - 0
tests/auth_tests/test_validators.py

@@ -1,7 +1,9 @@
+# -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
 import os
 
+from django.contrib.auth import validators
 from django.contrib.auth.models import User
 from django.contrib.auth.password_validation import (
     CommonPasswordValidator, MinimumLengthValidator, NumericPasswordValidator,
@@ -174,3 +176,29 @@ class NumericPasswordValidatorTest(TestCase):
             NumericPasswordValidator().get_help_text(),
             "Your password can't be entirely numeric."
         )
+
+
+class UsernameValidatorsTests(TestCase):
+    def test_unicode_validator(self):
+        valid_usernames = ['joe', 'René', 'ᴮᴵᴳᴮᴵᴿᴰ', 'أحمد']
+        invalid_usernames = [
+            "o'connell", "عبد ال",
+            "zerowidth\u200Bspace", "nonbreaking\u00A0space",
+            "en\u2013dash",
+        ]
+        v = validators.UnicodeUsernameValidator()
+        for valid in valid_usernames:
+            v(valid)
+        for invalid in invalid_usernames:
+            with self.assertRaises(ValidationError):
+                v(invalid)
+
+    def test_ascii_validator(self):
+        valid_usernames = ['glenn', 'GLEnN', 'jean-marc']
+        invalid_usernames = ["o'connell", 'Éric', 'jean marc', "أحمد"]
+        v = validators.ASCIIUsernameValidator()
+        for valid in valid_usernames:
+            v(valid)
+        for invalid in invalid_usernames:
+            with self.assertRaises(ValidationError):
+                v(invalid)