Fixed #21379 -- Created auth-specific username validators

Thanks Tim Graham for the review.
This commit is contained in:
Claude Paroz 2016-04-22 19:39:13 +02:00
parent 2265ff3710
commit 526575c641
11 changed files with 168 additions and 33 deletions

View File

@ -2,9 +2,9 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import django.contrib.auth.models import django.contrib.auth.models
from django.core import validators from django.contrib.auth import validators
from django.db import migrations, models from django.db import migrations, models
from django.utils import timezone from django.utils import six, timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -66,7 +66,9 @@ class Migration(migrations.Migration):
('username', models.CharField( ('username', models.CharField(
help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True,
max_length=30, verbose_name='username', 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)), ('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)), ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)),

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -32,6 +32,15 @@ Fields
``max_length=191`` because MySQL can only create unique indexes with ``max_length=191`` because MySQL can only create unique indexes with
191 characters in that case by default. 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 .. versionchanged:: 1.10
The ``max_length`` increased from 30 to 150 characters. The ``max_length`` increased from 30 to 150 characters.
@ -146,6 +155,27 @@ Attributes
In older versions, this was a method. Backwards-compatibility In older versions, this was a method. Backwards-compatibility
support for using it as a method will be removed in Django 2.0. 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 Methods
------- -------
@ -285,7 +315,6 @@ Manager methods
Same as :meth:`create_user`, but sets :attr:`~models.User.is_staff` and Same as :meth:`create_user`, but sets :attr:`~models.User.is_staff` and
:attr:`~models.User.is_superuser` to ``True``. :attr:`~models.User.is_superuser` to ``True``.
``AnonymousUser`` object ``AnonymousUser`` object
======================== ========================
@ -378,6 +407,25 @@ Fields
group.permissions.remove(permission, permission, ...) group.permissions.remove(permission, permission, ...)
group.permissions.clear() 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: .. _topics-auth-signals:
Login and logout signals Login and logout signals

View File

@ -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 lookup, and the :class:`~django.contrib.postgres.search.TrigramSimilarity` and
:class:`~django.contrib.postgres.search.TrigramDistance` expressions. :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 Minor features
-------------- --------------

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import warnings import warnings
@ -56,6 +57,10 @@ class BasicTestCase(TestCase):
u2 = User.objects.create_user('testuser2', 'test2@example.com') u2 = User.objects.create_user('testuser2', 'test2@example.com')
self.assertFalse(u2.has_usable_password()) 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): def test_is_anonymous_authenticated_method_deprecation(self):
deprecation_message = ( deprecation_message = (
'Using user.is_authenticated() and user.is_anonymous() as a ' 'Using user.is_authenticated() and user.is_anonymous() as a '

View File

@ -16,7 +16,7 @@ from django.core import mail
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.forms.fields import CharField, Field from django.forms.fields import CharField, Field
from django.test import SimpleTestCase, TestCase, mock, override_settings 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.encoding import force_text
from django.utils.text import capfirst from django.utils.text import capfirst
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -104,6 +104,20 @@ class UserCreationFormTest(TestDataMixin, TestCase):
self.assertEqual(password_changed.call_count, 1) self.assertEqual(password_changed.call_count, 1)
self.assertEqual(repr(u), '<User: jsmith@example.com>') 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=[ @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': {
@ -254,6 +268,16 @@ class AuthenticationFormTest(TestDataMixin, TestCase):
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
self.assertEqual(form.non_field_errors(), []) 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): def test_username_field_label(self):
class CustomAuthenticationForm(AuthenticationForm): class CustomAuthenticationForm(AuthenticationForm):

View File

@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
from django.contrib.auth import validators
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
CommonPasswordValidator, MinimumLengthValidator, NumericPasswordValidator, CommonPasswordValidator, MinimumLengthValidator, NumericPasswordValidator,
@ -174,3 +176,29 @@ class NumericPasswordValidatorTest(TestCase):
NumericPasswordValidator().get_help_text(), NumericPasswordValidator().get_help_text(),
"Your password can't be entirely numeric." "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)