Refs #16860 -- Minor edits and fixes to password validation.
This commit is contained in:
parent
a0047c6242
commit
55b3bd8468
|
@ -8,6 +8,7 @@ from difflib import SequenceMatcher
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
from django.utils import lru_cache
|
from django.utils import lru_cache
|
||||||
|
from django.utils._os import upath
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
@ -47,7 +48,7 @@ def validate_password(password, user=None, password_validators=None):
|
||||||
try:
|
try:
|
||||||
validator.validate(password, user)
|
validator.validate(password, user)
|
||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
errors += error.messages
|
errors.append(error)
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
@ -95,8 +96,11 @@ class MinimumLengthValidator(object):
|
||||||
|
|
||||||
def validate(self, password, user=None):
|
def validate(self, password, user=None):
|
||||||
if len(password) < self.min_length:
|
if len(password) < self.min_length:
|
||||||
msg = _("This password is too short. It must contain at least %(min_length)d characters.")
|
raise ValidationError(
|
||||||
raise ValidationError(msg % {'min_length': self.min_length})
|
_("This password is too short. It must contain at least %(min_length)d characters."),
|
||||||
|
code='password_too_short',
|
||||||
|
params={'min_length': self.min_length},
|
||||||
|
)
|
||||||
|
|
||||||
def get_help_text(self):
|
def get_help_text(self):
|
||||||
return _("Your password must contain at least %(min_length)d characters.") % {'min_length': self.min_length}
|
return _("Your password must contain at least %(min_length)d characters.") % {'min_length': self.min_length}
|
||||||
|
@ -131,7 +135,11 @@ class UserAttributeSimilarityValidator(object):
|
||||||
for value_part in value_parts:
|
for value_part in value_parts:
|
||||||
if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() > self.max_similarity:
|
if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() > self.max_similarity:
|
||||||
verbose_name = force_text(user._meta.get_field(attribute_name).verbose_name)
|
verbose_name = force_text(user._meta.get_field(attribute_name).verbose_name)
|
||||||
raise ValidationError(_("The password is too similar to the %s." % verbose_name))
|
raise ValidationError(
|
||||||
|
_("The password is too similar to the %(verbose_name)s."),
|
||||||
|
code='password_too_similar',
|
||||||
|
params={'verbose_name': verbose_name},
|
||||||
|
)
|
||||||
|
|
||||||
def get_help_text(self):
|
def get_help_text(self):
|
||||||
return _("Your password can't be too similar to your other personal information.")
|
return _("Your password can't be too similar to your other personal information.")
|
||||||
|
@ -145,7 +153,9 @@ class CommonPasswordValidator(object):
|
||||||
The list Django ships with contains 1000 common passwords, created by Mark Burnett:
|
The list Django ships with contains 1000 common passwords, created by Mark Burnett:
|
||||||
https://xato.net/passwords/more-top-worst-passwords/
|
https://xato.net/passwords/more-top-worst-passwords/
|
||||||
"""
|
"""
|
||||||
DEFAULT_PASSWORD_LIST_PATH = os.path.dirname(os.path.realpath(__file__)) + '/common-passwords.txt.gz'
|
DEFAULT_PASSWORD_LIST_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(upath(__file__))), 'common-passwords.txt.gz'
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
|
def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
|
||||||
try:
|
try:
|
||||||
|
@ -156,7 +166,10 @@ class CommonPasswordValidator(object):
|
||||||
|
|
||||||
def validate(self, password, user=None):
|
def validate(self, password, user=None):
|
||||||
if password.lower().strip() in self.passwords:
|
if password.lower().strip() in self.passwords:
|
||||||
raise ValidationError(_("This password is too common."))
|
raise ValidationError(
|
||||||
|
_("This password is too common."),
|
||||||
|
code='password_too_common',
|
||||||
|
)
|
||||||
|
|
||||||
def get_help_text(self):
|
def get_help_text(self):
|
||||||
return _("Your password can't be a commonly used password.")
|
return _("Your password can't be a commonly used password.")
|
||||||
|
@ -168,7 +181,10 @@ class NumericPasswordValidator(object):
|
||||||
"""
|
"""
|
||||||
def validate(self, password, user=None):
|
def validate(self, password, user=None):
|
||||||
if password.isdigit():
|
if password.isdigit():
|
||||||
raise ValidationError(_("This password is entirely numeric."))
|
raise ValidationError(
|
||||||
|
_("This password is entirely numeric."),
|
||||||
|
code='password_entirely_numeric',
|
||||||
|
)
|
||||||
|
|
||||||
def get_help_text(self):
|
def get_help_text(self):
|
||||||
return _("Your password can't be entirely numeric.")
|
return _("Your password can't be entirely numeric.")
|
||||||
|
|
|
@ -2774,11 +2774,11 @@ AUTH_PASSWORD_VALIDATORS
|
||||||
|
|
||||||
.. versionadded:: 1.9
|
.. versionadded:: 1.9
|
||||||
|
|
||||||
Default: ``[]``
|
Default: ``[]`` (Empty list)
|
||||||
|
|
||||||
Sets the validators that are used to check the strength of user's passwords.
|
The list of validators that are used to check the strength of user's passwords.
|
||||||
See :ref:`password-validation` for more details.
|
See :ref:`password-validation` for more details. By default, no validation is
|
||||||
By default, no validation is performed and all passwords are accepted.
|
performed and all passwords are accepted.
|
||||||
|
|
||||||
.. _settings-messages:
|
.. _settings-messages:
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ What's new in Django 1.9
|
||||||
Password validation
|
Password validation
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Django now offers password validation, to help prevent the usage of weak
|
Django now offers password validation to help prevent the usage of weak
|
||||||
passwords by users. The validation is integrated in the included password
|
passwords by users. The validation is integrated in the included password
|
||||||
change and reset forms and is simple to integrate in any other code.
|
change and reset forms and is simple to integrate in any other code.
|
||||||
Validation is performed by one or more validators, configured in the new
|
Validation is performed by one or more validators, configured in the new
|
||||||
|
@ -36,10 +36,10 @@ Validation is performed by one or more validators, configured in the new
|
||||||
|
|
||||||
Four validators are included in Django, which can enforce a minimum length,
|
Four validators are included in Django, which can enforce a minimum length,
|
||||||
compare the password to the user's attributes like their name, ensure
|
compare the password to the user's attributes like their name, ensure
|
||||||
passwords aren't entirely numeric or check against an included list of common
|
passwords aren't entirely numeric, or check against an included list of common
|
||||||
passwords. You can combine multiple validators, and some validators have
|
passwords. You can combine multiple validators, and some validators have
|
||||||
custom configuration options. For example, you can choose to provide a custom
|
custom configuration options. For example, you can choose to provide a custom
|
||||||
list of common passwords. Each validator provides a help text to explain their
|
list of common passwords. Each validator provides a help text to explain its
|
||||||
requirements to the user.
|
requirements to the user.
|
||||||
|
|
||||||
By default, no validation is performed and all passwords are accepted, so if
|
By default, no validation is performed and all passwords are accepted, so if
|
||||||
|
|
|
@ -242,6 +242,10 @@ from the ``User`` model.
|
||||||
Password validation
|
Password validation
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
.. module:: django.contrib.auth.password_validation
|
||||||
|
|
||||||
|
.. versionadded:: 1.9
|
||||||
|
|
||||||
Users often choose poor passwords. To help mitigate this problem, Django
|
Users often choose poor passwords. To help mitigate this problem, Django
|
||||||
offers pluggable password validation. You can configure multiple password
|
offers pluggable password validation. You can configure multiple password
|
||||||
validators at the same time. A few validators are included in Django, but it's
|
validators at the same time. A few validators are included in Django, but it's
|
||||||
|
@ -254,14 +258,14 @@ Validators can also have optional settings to fine tune their behavior.
|
||||||
|
|
||||||
Validation is controlled by the :setting:`AUTH_PASSWORD_VALIDATORS` setting.
|
Validation is controlled by the :setting:`AUTH_PASSWORD_VALIDATORS` setting.
|
||||||
By default, validators are used in the forms to reset or change passwords.
|
By default, validators are used in the forms to reset or change passwords.
|
||||||
The default for setting is an empty list, which means no validators are
|
The default for the setting is an empty list, which means no validators are
|
||||||
applied. In new projects created with the default :djadmin:`startproject`
|
applied. In new projects created with the default :djadmin:`startproject`
|
||||||
template, a simple set of validators is enabled.
|
template, a simple set of validators is enabled.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Password validation can prevent the use of many types of weak passwords.
|
Password validation can prevent the use of many types of weak passwords.
|
||||||
However, the fact that a password passes all the validators, doesn't
|
However, the fact that a password passes all the validators doesn't
|
||||||
guarantee that it is a strong password. There are many factors that can
|
guarantee that it is a strong password. There are many factors that can
|
||||||
weaken a password that are not detectable by even the most advanced
|
weaken a password that are not detectable by even the most advanced
|
||||||
password validators.
|
password validators.
|
||||||
|
@ -344,7 +348,7 @@ Django includes four validators:
|
||||||
`Mark Burnett <https://xato.net/passwords/more-top-worst-passwords/>`_.
|
`Mark Burnett <https://xato.net/passwords/more-top-worst-passwords/>`_.
|
||||||
|
|
||||||
The ``password_list_path`` can be set to the path of a custom file of
|
The ``password_list_path`` can be set to the path of a custom file of
|
||||||
common passwords. This file should contain one password per line, and
|
common passwords. This file should contain one password per line and
|
||||||
may be plain text or gzipped.
|
may be plain text or gzipped.
|
||||||
|
|
||||||
.. class:: NumericPasswordValidator()
|
.. class:: NumericPasswordValidator()
|
||||||
|
@ -354,8 +358,6 @@ Django includes four validators:
|
||||||
Integrating validation
|
Integrating validation
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
.. module:: django.contrib.auth.password_validation
|
|
||||||
|
|
||||||
There are a few functions in ``django.contrib.auth.password_validation`` that
|
There are a few functions in ``django.contrib.auth.password_validation`` that
|
||||||
you can call from your own forms or other code to integrate password
|
you can call from your own forms or other code to integrate password
|
||||||
validation. This can be useful if you use custom forms for password setting,
|
validation. This can be useful if you use custom forms for password setting,
|
||||||
|
@ -368,14 +370,14 @@ or if you have API calls that allow passwords to be set, for example.
|
||||||
:exc:`~django.core.exceptions.ValidationError` with all the error messages
|
:exc:`~django.core.exceptions.ValidationError` with all the error messages
|
||||||
from the validators.
|
from the validators.
|
||||||
|
|
||||||
The user object is optional: if it's not provided, some validators may not
|
The ``user`` object is optional: if it's not provided, some validators may
|
||||||
be able to perform any validation and will accept any password.
|
not be able to perform any validation and will accept any password.
|
||||||
|
|
||||||
.. function:: password_changed(password, user=None, password_validators=None)
|
.. function:: password_changed(password, user=None, password_validators=None)
|
||||||
|
|
||||||
Informs all validators that the password has been changed. This can be used
|
Informs all validators that the password has been changed. This can be used
|
||||||
by some validators, e.g. a validator that prevents password reuse. This
|
by validators such as one that prevents password reuse. This should be
|
||||||
should be called once the password has been successfully changed.
|
called once the password has been successfully changed.
|
||||||
|
|
||||||
.. function:: password_validators_help_texts(password_validators=None)
|
.. function:: password_validators_help_texts(password_validators=None)
|
||||||
|
|
||||||
|
@ -440,11 +442,17 @@ Here's a basic example of a validator, with one optional setting::
|
||||||
|
|
||||||
def validate(self, password, user=None):
|
def validate(self, password, user=None):
|
||||||
if len(password) < self.min_length:
|
if len(password) < self.min_length:
|
||||||
raise ValidationError(_("This password is too short."))
|
raise ValidationError(
|
||||||
|
_("This password must contain at least %(min_length)d characters."),
|
||||||
|
code='password_too_short',
|
||||||
|
params={'min_length': self.min_length},
|
||||||
|
)
|
||||||
|
|
||||||
def get_help_text(self):
|
def get_help_text(self):
|
||||||
return _("Your password must contain at least %(min_length)d characters.")
|
return _(
|
||||||
|
"Your password must contain at least %(min_length)d characters."
|
||||||
% {'min_length': self.min_length}
|
% {'min_length': self.min_length}
|
||||||
|
)
|
||||||
|
|
||||||
You can also implement ``password_changed(password, user=None``), which will
|
You can also implement ``password_changed(password, user=None``), which will
|
||||||
be called after a successful password change. That can be used to prevent
|
be called after a successful password change. That can be used to prevent
|
||||||
|
|
|
@ -278,8 +278,11 @@ class SetPasswordFormTest(TestDataMixin, TestCase):
|
||||||
form = SetPasswordForm(user, data)
|
form = SetPasswordForm(user, data)
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertEqual(len(form["new_password2"].errors), 2)
|
self.assertEqual(len(form["new_password2"].errors), 2)
|
||||||
self.assertTrue('The password is too similar to the username.' in form["new_password2"].errors)
|
self.assertIn('The password is too similar to the username.', form["new_password2"].errors)
|
||||||
self.assertTrue('This password is too short. It must contain at least 12 characters.' in form["new_password2"].errors)
|
self.assertIn(
|
||||||
|
'This password is too short. It must contain at least 12 characters.',
|
||||||
|
form["new_password2"].errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
|
@override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
|
||||||
|
|
|
@ -12,6 +12,7 @@ from django.contrib.auth.password_validation import (
|
||||||
)
|
)
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils._os import upath
|
||||||
|
|
||||||
|
|
||||||
@override_settings(AUTH_PASSWORD_VALIDATORS=[
|
@override_settings(AUTH_PASSWORD_VALIDATORS=[
|
||||||
|
@ -43,10 +44,12 @@ class PasswordValidationTest(TestCase):
|
||||||
with self.assertRaises(ValidationError, args=['This password is too short.']) as cm:
|
with self.assertRaises(ValidationError, args=['This password is too short.']) as cm:
|
||||||
validate_password('django4242')
|
validate_password('django4242')
|
||||||
self.assertEqual(cm.exception.messages, [msg_too_short])
|
self.assertEqual(cm.exception.messages, [msg_too_short])
|
||||||
|
self.assertEqual(cm.exception.error_list[0].code, 'password_too_short')
|
||||||
|
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
validate_password('password')
|
validate_password('password')
|
||||||
self.assertEqual(cm.exception.messages, ['This password is too common.', msg_too_short])
|
self.assertEqual(cm.exception.messages, ['This password is too common.', msg_too_short])
|
||||||
|
self.assertEqual(cm.exception.error_list[0].code, 'password_too_common')
|
||||||
|
|
||||||
self.assertIsNone(validate_password('password', password_validators=[]))
|
self.assertIsNone(validate_password('password', password_validators=[]))
|
||||||
|
|
||||||
|
@ -56,14 +59,14 @@ class PasswordValidationTest(TestCase):
|
||||||
def test_password_validators_help_texts(self):
|
def test_password_validators_help_texts(self):
|
||||||
help_texts = password_validators_help_texts()
|
help_texts = password_validators_help_texts()
|
||||||
self.assertEqual(len(help_texts), 2)
|
self.assertEqual(len(help_texts), 2)
|
||||||
self.assertTrue('12 characters' in help_texts[1])
|
self.assertIn('12 characters', help_texts[1])
|
||||||
|
|
||||||
self.assertEqual(password_validators_help_texts(password_validators=[]), [])
|
self.assertEqual(password_validators_help_texts(password_validators=[]), [])
|
||||||
|
|
||||||
def test_password_validators_help_text_html(self):
|
def test_password_validators_help_text_html(self):
|
||||||
help_text = password_validators_help_text_html()
|
help_text = password_validators_help_text_html()
|
||||||
self.assertEqual(help_text.count('<li>'), 2)
|
self.assertEqual(help_text.count('<li>'), 2)
|
||||||
self.assertTrue('12 characters' in help_text)
|
self.assertIn('12 characters', help_text)
|
||||||
|
|
||||||
|
|
||||||
class MinimumLengthValidatorTest(TestCase):
|
class MinimumLengthValidatorTest(TestCase):
|
||||||
|
@ -75,6 +78,7 @@ class MinimumLengthValidatorTest(TestCase):
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
MinimumLengthValidator().validate('1234567')
|
MinimumLengthValidator().validate('1234567')
|
||||||
self.assertEqual(cm.exception.messages, [expected_error % 8])
|
self.assertEqual(cm.exception.messages, [expected_error % 8])
|
||||||
|
self.assertEqual(cm.exception.error_list[0].code, 'password_too_short')
|
||||||
|
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
MinimumLengthValidator(min_length=3).validate('12')
|
MinimumLengthValidator(min_length=3).validate('12')
|
||||||
|
@ -100,13 +104,17 @@ class UserAttributeSimilarityValidatorTest(TestCase):
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
UserAttributeSimilarityValidator().validate('testclient', user=user),
|
UserAttributeSimilarityValidator().validate('testclient', user=user),
|
||||||
self.assertEqual(cm.exception.messages, [expected_error % "username"])
|
self.assertEqual(cm.exception.messages, [expected_error % "username"])
|
||||||
|
self.assertEqual(cm.exception.error_list[0].code, 'password_too_similar')
|
||||||
|
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
UserAttributeSimilarityValidator().validate('example.com', user=user),
|
UserAttributeSimilarityValidator().validate('example.com', user=user),
|
||||||
self.assertEqual(cm.exception.messages, [expected_error % "email address"])
|
self.assertEqual(cm.exception.messages, [expected_error % "email address"])
|
||||||
|
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
UserAttributeSimilarityValidator(user_attributes=['first_name'], max_similarity=0.3).validate('testclient', user=user),
|
UserAttributeSimilarityValidator(
|
||||||
|
user_attributes=['first_name'],
|
||||||
|
max_similarity=0.3,
|
||||||
|
).validate('testclient', user=user)
|
||||||
self.assertEqual(cm.exception.messages, [expected_error % "first name"])
|
self.assertEqual(cm.exception.messages, [expected_error % "first name"])
|
||||||
|
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
|
@ -130,7 +138,7 @@ class CommonPasswordValidatorTest(TestCase):
|
||||||
self.assertEqual(cm.exception.messages, [expected_error])
|
self.assertEqual(cm.exception.messages, [expected_error])
|
||||||
|
|
||||||
def test_validate_custom_list(self):
|
def test_validate_custom_list(self):
|
||||||
path = os.path.dirname(os.path.realpath(__file__)) + '/common-passwords-custom.txt'
|
path = os.path.join(os.path.dirname(os.path.realpath(upath(__file__))), 'common-passwords-custom.txt')
|
||||||
validator = CommonPasswordValidator(password_list_path=path)
|
validator = CommonPasswordValidator(password_list_path=path)
|
||||||
expected_error = "This password is too common."
|
expected_error = "This password is too common."
|
||||||
self.assertIsNone(validator.validate('a-safe-password'))
|
self.assertIsNone(validator.validate('a-safe-password'))
|
||||||
|
@ -138,6 +146,7 @@ class CommonPasswordValidatorTest(TestCase):
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
validator.validate('from-my-custom-list')
|
validator.validate('from-my-custom-list')
|
||||||
self.assertEqual(cm.exception.messages, [expected_error])
|
self.assertEqual(cm.exception.messages, [expected_error])
|
||||||
|
self.assertEqual(cm.exception.error_list[0].code, 'password_too_common')
|
||||||
|
|
||||||
def test_help_text(self):
|
def test_help_text(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -154,6 +163,7 @@ class NumericPasswordValidatorTest(TestCase):
|
||||||
with self.assertRaises(ValidationError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
NumericPasswordValidator().validate('42424242')
|
NumericPasswordValidator().validate('42424242')
|
||||||
self.assertEqual(cm.exception.messages, [expected_error])
|
self.assertEqual(cm.exception.messages, [expected_error])
|
||||||
|
self.assertEqual(cm.exception.error_list[0].code, 'password_entirely_numeric')
|
||||||
|
|
||||||
def test_help_text(self):
|
def test_help_text(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
Loading…
Reference in New Issue