From 526575c64150e10dd8666d1ed3f86eedd00df2ed Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 22 Apr 2016 19:39:13 +0200 Subject: [PATCH] Fixed #21379 -- Created auth-specific username validators Thanks Tim Graham for the review. --- .../contrib/auth/migrations/0001_initial.py | 8 +-- .../0004_alter_user_username_opts.py | 9 ++-- ...007_alter_validators_add_error_messages.py | 10 ++-- .../0008_alter_user_username_max_length.py | 10 ++-- django/contrib/auth/models.py | 13 ++--- django/contrib/auth/validators.py | 26 ++++++++++ docs/ref/contrib/auth.txt | 50 ++++++++++++++++++- docs/releases/1.10.txt | 16 ++++++ tests/auth_tests/test_basic.py | 5 ++ tests/auth_tests/test_forms.py | 26 +++++++++- tests/auth_tests/test_validators.py | 28 +++++++++++ 11 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 django/contrib/auth/validators.py diff --git a/django/contrib/auth/migrations/0001_initial.py b/django/contrib/auth/migrations/0001_initial.py index 7df5603879..65db802ed8 100644 --- a/django/contrib/auth/migrations/0001_initial.py +++ b/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)), diff --git a/django/contrib/auth/migrations/0004_alter_user_username_opts.py b/django/contrib/auth/migrations/0004_alter_user_username_opts.py index 151cda7b74..e7a521fd6d 100644 --- a/django/contrib/auth/migrations/0004_alter_user_username_opts.py +++ b/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' ), diff --git a/django/contrib/auth/migrations/0007_alter_validators_add_error_messages.py b/django/contrib/auth/migrations/0007_alter_validators_add_error_messages.py index 0d915b1b69..8e5b141503 100644 --- a/django/contrib/auth/migrations/0007_alter_validators_add_error_messages.py +++ b/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', ), ), diff --git a/django/contrib/auth/migrations/0008_alter_user_username_max_length.py b/django/contrib/auth/migrations/0008_alter_user_username_max_length.py index 2afbc30c2a..6349ad10c4 100644 --- a/django/contrib/auth/migrations/0008_alter_user_username_max_length.py +++ b/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', ), ), diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 2f3e297ba6..8fcdf9108c 100644 --- a/django/contrib/auth/models.py +++ b/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."), }, diff --git a/django/contrib/auth/validators.py b/django/contrib/auth/validators.py new file mode 100644 index 0000000000..0691d22a88 --- /dev/null +++ b/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 diff --git a/docs/ref/contrib/auth.txt b/docs/ref/contrib/auth.txt index 6e3b8892cf..5836cb329e 100644 --- a/docs/ref/contrib/auth.txt +++ b/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 diff --git a/docs/releases/1.10.txt b/docs/releases/1.10.txt index 35cf1d42c3..fa5d3ff108 100644 --- a/docs/releases/1.10.txt +++ b/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 -------------- diff --git a/tests/auth_tests/test_basic.py b/tests/auth_tests/test_basic.py index 818f6a6d53..da42f4055f 100644 --- a/tests/auth_tests/test_basic.py +++ b/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 ' diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index d77c9a976b..212dff1ab0 100644 --- a/tests/auth_tests/test_forms.py +++ b/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), '') + 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): diff --git a/tests/auth_tests/test_validators.py b/tests/auth_tests/test_validators.py index 5d758cd342..49a3c5395b 100644 --- a/tests/auth_tests/test_validators.py +++ b/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)