summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude Paroz <claude@2xlibre.net>2016-04-22 19:39:13 +0200
committerClaude Paroz <claude@2xlibre.net>2016-05-16 19:37:57 +0200
commit526575c64150e10dd8666d1ed3f86eedd00df2ed (patch)
tree175e0889a9ef03f53ed9273fb659918eea01fe2d
parent2265ff3710ae244a6f01640972571979543282c6 (diff)
Fixed #21379 -- Created auth-specific username validators
Thanks Tim Graham for the review.
-rw-r--r--django/contrib/auth/migrations/0001_initial.py8
-rw-r--r--django/contrib/auth/migrations/0004_alter_user_username_opts.py9
-rw-r--r--django/contrib/auth/migrations/0007_alter_validators_add_error_messages.py10
-rw-r--r--django/contrib/auth/migrations/0008_alter_user_username_max_length.py10
-rw-r--r--django/contrib/auth/models.py13
-rw-r--r--django/contrib/auth/validators.py26
-rw-r--r--docs/ref/contrib/auth.txt50
-rw-r--r--docs/releases/1.10.txt16
-rw-r--r--tests/auth_tests/test_basic.py5
-rw-r--r--tests/auth_tests/test_forms.py26
-rw-r--r--tests/auth_tests/test_validators.py28
11 files changed, 168 insertions, 33 deletions
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), '<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):
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)