diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
index d817ea8e94..e92f8f5e82 100644
--- a/django/conf/global_settings.py
+++ b/django/conf/global_settings.py
@@ -534,6 +534,8 @@ PASSWORD_HASHERS = [
'django.contrib.auth.hashers.CryptPasswordHasher',
]
+AUTH_PASSWORD_VALIDATORS = []
+
###########
# SIGNING #
###########
diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py
index 3eaf05cfc0..e58e229c8a 100644
--- a/django/conf/project_template/project_name/settings.py
+++ b/django/conf/project_template/project_name/settings.py
@@ -82,6 +82,25 @@ DATABASES = {
}
+# Password validation
+# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
# Internationalization
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
diff --git a/django/contrib/auth/common-passwords.txt.gz b/django/contrib/auth/common-passwords.txt.gz
new file mode 100644
index 0000000000..78d490df5a
Binary files /dev/null and b/django/contrib/auth/common-passwords.txt.gz differ
diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py
index daf46e4a81..0d7954a548 100644
--- a/django/contrib/auth/forms.py
+++ b/django/contrib/auth/forms.py
@@ -1,7 +1,9 @@
from __future__ import unicode_literals
from django import forms
-from django.contrib.auth import authenticate, get_user_model
+from django.contrib.auth import (
+ authenticate, get_user_model, password_validation,
+)
from django.contrib.auth.hashers import (
UNUSABLE_PASSWORD_PREFIX, identify_hasher,
)
@@ -263,7 +265,8 @@ class SetPasswordForm(forms.Form):
'password_mismatch': _("The two password fields didn't match."),
}
new_password1 = forms.CharField(label=_("New password"),
- widget=forms.PasswordInput)
+ widget=forms.PasswordInput,
+ help_text=password_validation.password_validators_help_text_html())
new_password2 = forms.CharField(label=_("New password confirmation"),
widget=forms.PasswordInput)
@@ -280,10 +283,13 @@ class SetPasswordForm(forms.Form):
self.error_messages['password_mismatch'],
code='password_mismatch',
)
+ password_validation.validate_password(password2, self.user)
return password2
def save(self, commit=True):
- self.user.set_password(self.cleaned_data['new_password1'])
+ password = self.cleaned_data["new_password1"]
+ self.user.set_password(password)
+ password_validation.password_changed(password, self.user)
if commit:
self.user.save()
return self.user
@@ -327,6 +333,7 @@ class AdminPasswordChangeForm(forms.Form):
password1 = forms.CharField(
label=_("Password"),
widget=forms.PasswordInput,
+ help_text=password_validation.password_validators_help_text_html(),
)
password2 = forms.CharField(
label=_("Password (again)"),
@@ -347,13 +354,16 @@ class AdminPasswordChangeForm(forms.Form):
self.error_messages['password_mismatch'],
code='password_mismatch',
)
+ password_validation.validate_password(password2, self.user)
return password2
def save(self, commit=True):
"""
Saves the new password.
"""
- self.user.set_password(self.cleaned_data["password1"])
+ password = self.cleaned_data["password1"]
+ self.user.set_password(password)
+ password_validation.password_changed(password, self.user)
if commit:
self.user.save()
return self.user
diff --git a/django/contrib/auth/password_validation.py b/django/contrib/auth/password_validation.py
new file mode 100644
index 0000000000..a44bf57875
--- /dev/null
+++ b/django/contrib/auth/password_validation.py
@@ -0,0 +1,174 @@
+from __future__ import unicode_literals
+
+import gzip
+import os
+import re
+from difflib import SequenceMatcher
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured, ValidationError
+from django.utils import lru_cache
+from django.utils.encoding import force_text
+from django.utils.html import format_html
+from django.utils.module_loading import import_string
+from django.utils.six import string_types
+from django.utils.translation import ugettext as _
+
+
+@lru_cache.lru_cache(maxsize=None)
+def get_default_password_validators():
+ return get_password_validators(settings.AUTH_PASSWORD_VALIDATORS)
+
+
+def get_password_validators(validator_config):
+ validators = []
+ for validator in validator_config:
+ try:
+ klass = import_string(validator['NAME'])
+ except ImportError:
+ msg = "The module in NAME could not be imported: %s. Check your AUTH_PASSWORD_VALIDATORS setting."
+ raise ImproperlyConfigured(msg % validator['NAME'])
+ validators.append(klass(**validator.get('OPTIONS', {})))
+
+ return validators
+
+
+def validate_password(password, user=None, password_validators=None):
+ """
+ Validate whether the password meets all validator requirements.
+
+ If the password is valid, return ``None``.
+ If the password is invalid, raise ValidationError with all error messages.
+ """
+ errors = []
+ if password_validators is None:
+ password_validators = get_default_password_validators()
+ for validator in password_validators:
+ try:
+ validator.validate(password, user)
+ except ValidationError as error:
+ errors += error.messages
+ if errors:
+ raise ValidationError(errors)
+
+
+def password_changed(password, user=None, password_validators=None):
+ """
+ Inform all validators that have implemented a password_changed() method
+ that the password has been changed.
+ """
+ if password_validators is None:
+ password_validators = get_default_password_validators()
+ for validator in password_validators:
+ password_changed = getattr(validator, 'password_changed', lambda *a: None)
+ password_changed(password, user)
+
+
+def password_validators_help_texts(password_validators=None):
+ """
+ Return a list of all help texts of all configured validators.
+ """
+ help_texts = []
+ if password_validators is None:
+ password_validators = get_default_password_validators()
+ for validator in password_validators:
+ help_texts.append(validator.get_help_text())
+ return help_texts
+
+
+def password_validators_help_text_html(password_validators=None):
+ """
+ Return an HTML string with all help texts of all configured validators
+ in an
.
+ """
+ help_texts = password_validators_help_texts(password_validators)
+ help_items = [format_html('- {}
', help_text) for help_text in help_texts]
+ return '' % ''.join(help_items)
+
+
+class MinimumLengthValidator(object):
+ """
+ Validate whether the password is of a minimum length.
+ """
+ def __init__(self, min_length=8):
+ self.min_length = min_length
+
+ def validate(self, password, user=None):
+ if len(password) < self.min_length:
+ msg = _("This password is too short. It must contain at least %(min_length)d characters.")
+ raise ValidationError(msg % {'min_length': self.min_length})
+
+ def get_help_text(self):
+ return _("Your password must contain at least %(min_length)d characters.") % {'min_length': self.min_length}
+
+
+class UserAttributeSimilarityValidator(object):
+ """
+ Validate whether the password is sufficiently different from the user's
+ attributes.
+
+ If no specific attributes are provided, look at a sensible list of
+ defaults. Attributes that don't exist are ignored. Comparison is made to
+ not only the full attribute value, but also its components, so that, for
+ example, a password is validated against either part of an email address,
+ as well as the full address.
+ """
+ DEFAULT_USER_ATTRIBUTES = ('username', 'first_name', 'last_name', 'email')
+
+ def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
+ self.user_attributes = user_attributes
+ self.max_similarity = max_similarity
+
+ def validate(self, password, user=None):
+ if not user:
+ return
+
+ for attribute_name in self.user_attributes:
+ value = getattr(user, attribute_name, None)
+ if not value or not isinstance(value, string_types):
+ continue
+ value_parts = re.split('\W+', value) + [value]
+ for value_part in value_parts:
+ 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)
+ raise ValidationError(_("The password is too similar to the %s." % verbose_name))
+
+ def get_help_text(self):
+ return _("Your password can't be too similar to your other personal information.")
+
+
+class CommonPasswordValidator(object):
+ """
+ Validate whether the password is a common password.
+
+ The password is rejected if it occurs in a provided list, which may be gzipped.
+ The list Django ships with contains 1000 common passwords, created by Mark Burnett:
+ https://xato.net/passwords/more-top-worst-passwords/
+ """
+ DEFAULT_PASSWORD_LIST_PATH = os.path.dirname(os.path.realpath(__file__)) + '/common-passwords.txt.gz'
+
+ def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
+ try:
+ common_passwords_lines = gzip.open(password_list_path).read().decode('utf-8').splitlines()
+ except IOError:
+ common_passwords_lines = open(password_list_path).readlines()
+ self.passwords = {p.strip() for p in common_passwords_lines}
+
+ def validate(self, password, user=None):
+ if password.lower().strip() in self.passwords:
+ raise ValidationError(_("This password is too common."))
+
+ def get_help_text(self):
+ return _("Your password can't be a commonly used password.")
+
+
+class NumericPasswordValidator(object):
+ """
+ Validate whether the password is alphanumeric.
+ """
+ def validate(self, password, user=None):
+ if password.isdigit():
+ raise ValidationError(_("This password is entirely numeric."))
+
+ def get_help_text(self):
+ return _("Your password can't be entirely numeric.")
diff --git a/django/test/signals.py b/django/test/signals.py
index 6d8a914ad7..f7836637e8 100644
--- a/django/test/signals.py
+++ b/django/test/signals.py
@@ -175,3 +175,10 @@ def static_finders_changed(**kwargs):
}:
from django.contrib.staticfiles.finders import get_finder
get_finder.cache_clear()
+
+
+@receiver(setting_changed)
+def auth_password_validators_changed(**kwargs):
+ if kwargs['setting'] == 'AUTH_PASSWORD_VALIDATORS':
+ from django.contrib.auth.password_validation import get_default_password_validators
+ get_default_password_validators.cache_clear()
diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
index c745ad519b..a0c3e69877 100644
--- a/docs/ref/settings.txt
+++ b/docs/ref/settings.txt
@@ -2767,6 +2767,19 @@ Default::
'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
'django.contrib.auth.hashers.CryptPasswordHasher']
+.. setting:: AUTH_PASSWORD_VALIDATORS
+
+AUTH_PASSWORD_VALIDATORS
+------------------------
+
+.. versionadded:: 1.9
+
+Default: ``[]``
+
+Sets the validators that are used to check the strength of user's passwords.
+See :ref:`password-validation` for more details.
+By default, no validation is performed and all passwords are accepted.
+
.. _settings-messages:
Messages
diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt
index afa624e295..ffa17391ec 100644
--- a/docs/releases/1.9.txt
+++ b/docs/releases/1.9.txt
@@ -25,7 +25,45 @@ Python 3.2 and added support for Python 3.5.
What's new in Django 1.9
========================
-...
+Password validation
+~~~~~~~~~~~~~~~~~~~
+
+Django now offers password validation, to help prevent the usage of weak
+passwords by users. The validation is integrated in the included password
+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
+:setting:`AUTH_PASSWORD_VALIDATORS` setting.
+
+Four validators are included in Django, which can enforce a minimum length,
+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. You can combine multiple validators, and some validators have
+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
+requirements to the user.
+
+By default, no validation is performed and all passwords are accepted, so if
+you don't set :setting:`AUTH_PASSWORD_VALIDATORS`, you will not see any
+change. In new projects created with the default :djadmin:`startproject`
+template, a simple set of validators is enabled. To enable basic validation in
+the included auth forms for your project, you could set, for example::
+
+ AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+ ]
+
+See :ref:`password-validation` for more details.
Minor features
~~~~~~~~~~~~~~
diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt
index 5f7bece6ee..090c9196b6 100644
--- a/docs/topics/auth/passwords.txt
+++ b/docs/topics/auth/passwords.txt
@@ -236,3 +236,217 @@ from the ``User`` model.
Checks if the given string is a hashed password that has a chance
of being verified against :func:`check_password`.
+
+.. _password-validation:
+
+Password validation
+===================
+
+Users often choose poor passwords. To help mitigate this problem, Django
+offers pluggable password validation. You can configure multiple password
+validators at the same time. A few validators are included in Django, but it's
+simple to write your own as well.
+
+Each password validator must provide a help text to explain the requirements to
+the user, validate a given password and return an error message if it does not
+meet the requirements, and optionally receive passwords that have been set.
+Validators can also have optional settings to fine tune their behavior.
+
+Validation is controlled by the :setting:`AUTH_PASSWORD_VALIDATORS` setting.
+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
+applied. In new projects created with the default :djadmin:`startproject`
+template, a simple set of validators is enabled.
+
+.. note::
+
+ Password validation can prevent the use of many types of weak passwords.
+ 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
+ weaken a password that are not detectable by even the most advanced
+ password validators.
+
+Enabling password validation
+----------------------------
+
+Password validation is configured in the
+:setting:`AUTH_PASSWORD_VALIDATORS` setting::
+
+ AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ 'OPTIONS': {
+ 'min_length': 9,
+ }
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+ ]
+
+This example enables all four included validators:
+
+* ``UserAttributeSimilarityValidator``, which checks the similarity between
+ the password and a set of attributes of the user.
+* ``MinimumLengthValidator``, which simply checks whether the password meets a
+ minimum length. This validator is configured with a custom option: it now
+ requires the minimum length to be nine characters, instead of the default
+ eight.
+* ``CommonPasswordValidator``, which checks whether the password occurs in a
+ list of common passwords. By default, it compares to an included list of
+ 1000 common passwords.
+* ``NumericPasswordValidator``, which checks whether the password isn't
+ entirely numeric.
+
+For ``UserAttributeSimilarityValidator`` and ``CommonPasswordValidator``,
+we're simply using the default settings in this example.
+``NumericPasswordValidator`` has no settings.
+
+The help texts and any errors from password validators are always returned in
+the order they are listed in :setting:`AUTH_PASSWORD_VALIDATORS`.
+
+Included validators
+-------------------
+
+Django includes four validators:
+
+.. class:: MinimumLengthValidator(min_length=8)
+
+ Validates whether the password meets a minimum length.
+ The minimum length can be customized with the ``min_length`` parameter.
+
+.. class:: UserAttributeSimilarityValidator(user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7)
+
+ Validates whether the password is sufficiently different from certain
+ attributes of the user.
+
+ The ``user_attributes`` parameter should be an iterable of names of user
+ attributes to compare to. If this argument is not provided, the default
+ is used: ``'username', 'first_name', 'last_name', 'email'``.
+ Attributes that don't exist are ignored.
+
+ The maximum similarity the password can have, before it is rejected, can
+ be set with the ``max_similarity`` parameter, on a scale of 0 to 1.
+ A setting of 0 will cause all passwords to be rejected, whereas a setting
+ of 1 will cause it to only reject passwords that are identical to an
+ attribute's value.
+
+.. class:: CommonPasswordValidator(password_list_path=DEFAULT_PASSWORD_LIST_PATH)
+
+ Validates whether the password is not a common password. By default, this
+ checks against a list of 1000 common password created by
+ `Mark Burnett `_.
+
+ 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
+ may be plain text or gzipped.
+
+.. class:: NumericPasswordValidator()
+
+ Validates whether the password is not entirely numeric.
+
+Integrating validation
+-----------------------
+
+.. module:: django.contrib.auth.password_validation
+
+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
+validation. This can be useful if you use custom forms for password setting,
+or if you have API calls that allow passwords to be set, for example.
+
+.. function:: validate_password(password, user=None, password_validators=None)
+
+ Validates a password. If all validators find the password valid, returns
+ ``None``. If one or more validators reject the password, raises a
+ :exc:`~django.core.exceptions.ValidationError` with all the error messages
+ from the validators.
+
+ The user object is optional: if it's not provided, some validators may not
+ be able to perform any validation and will accept any password.
+
+.. function:: password_changed(password, user=None, password_validators=None)
+
+ 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
+ should be called once the password has been successfully changed.
+
+.. function:: password_validators_help_texts(password_validators=None)
+
+ Returns a list of the help texts of all validators. These explain the
+ password requirements to the user.
+
+.. function:: password_validators_help_text_html(password_validators=None)
+
+ Returns an HTML string with all help texts in an ````. This is
+ helpful when adding password validation to forms, as you can pass the
+ output directly to the ``help_text`` parameter of a form field.
+
+.. function:: get_password_validators(validator_config)
+
+ Returns a set of validator objects based on the ``validator_config``
+ parameter. By default, all functions use the validators defined in
+ :setting:`AUTH_PASSWORD_VALIDATORS`, but by calling this function with an
+ alternate set of validators and then passing the result into the
+ ``password_validators`` parameter of the other functions, your custom set
+ of validators will be used instead. This is useful when you have a typical
+ set of validators to use for most scenarios, but also have a special
+ situation that requires a custom set. If you always use the same set
+ of validators, there is no need to use this function, as the configuration
+ from :setting:`AUTH_PASSWORD_VALIDATORS` is used by default.
+
+ The structure of ``validator_config`` is identical to the
+ structure of :setting:`AUTH_PASSWORD_VALIDATORS`. The return value of
+ this function can be passed into the ``password_validators`` parameter
+ of the functions listed above.
+
+Note that where the password is passed to one of these functions, this should
+always be the clear text password - not a hashed password.
+
+Writing your own validator
+--------------------------
+
+If Django's built-in validators are not sufficient, you can write your own
+password validators. Validators are fairly simple classes. They must implement
+two methods:
+
+* ``validate(self, password, user=None)``: validate a password. Return
+ ``None`` if the password is valid, or raise a
+ :exc:`~django.core.exceptions.ValidationError` with an error message if the
+ password is not valid. You must be able to deal with ``user`` being
+ ``None`` - if that means your validator can't run, simply return ``None``
+ for no error.
+* ``get_help_text()``: provide a help text to explain the requirements to
+ the user.
+
+Any items in the ``OPTIONS`` in :setting:`AUTH_PASSWORD_VALIDATORS` for your
+validator will be passed to the constructor. All constructor arguments should
+have a default value.
+
+Here's a basic example of a validator, with one optional setting::
+
+ from django.core.exceptions import ValidationError
+ from django.utils.translation import ugettext as _
+
+ class MinimumLengthValidator(object):
+ def __init__(self, min_length=8):
+ self.min_length = min_length
+
+ def validate(self, password, user=None):
+ if len(password) < self.min_length:
+ raise ValidationError(_("This password is too short."))
+
+ def get_help_text(self):
+ return _("Your password must contain at least %(min_length)d characters.")
+ % {'min_length': self.min_length}
+
+You can also implement ``password_changed(password, user=None``), which will
+be called after a successful password change. That can be used to prevent
+password reuse, for example. However, if you decide to store a user's previous
+passwords, you should never do so in clear text.
diff --git a/tests/auth_tests/common-passwords-custom.txt b/tests/auth_tests/common-passwords-custom.txt
new file mode 100644
index 0000000000..67a21ccde9
--- /dev/null
+++ b/tests/auth_tests/common-passwords-custom.txt
@@ -0,0 +1 @@
+from-my-custom-list
diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py
index 2595cd7540..c469201408 100644
--- a/tests/auth_tests/test_forms.py
+++ b/tests/auth_tests/test_forms.py
@@ -263,6 +263,24 @@ class SetPasswordFormTest(TestDataMixin, TestCase):
form = SetPasswordForm(user, data)
self.assertTrue(form.is_valid())
+ @override_settings(AUTH_PASSWORD_VALIDATORS=[
+ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {
+ 'min_length': 12,
+ }},
+ ])
+ def test_validates_password(self):
+ user = User.objects.get(username='testclient')
+ data = {
+ 'new_password1': 'testclient',
+ 'new_password2': 'testclient',
+ }
+ form = SetPasswordForm(user, data)
+ self.assertFalse(form.is_valid())
+ self.assertEqual(len(form["new_password2"].errors), 2)
+ self.assertTrue('The password is too similar to the username.' in form["new_password2"].errors)
+ self.assertTrue('This password is too short. It must contain at least 12 characters.' in form["new_password2"].errors)
+
@override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
class PasswordChangeFormTest(TestDataMixin, TestCase):
diff --git a/tests/auth_tests/test_validators.py b/tests/auth_tests/test_validators.py
new file mode 100644
index 0000000000..543e520e8f
--- /dev/null
+++ b/tests/auth_tests/test_validators.py
@@ -0,0 +1,162 @@
+from __future__ import unicode_literals
+
+import os
+
+from django.contrib.auth.models import User
+from django.contrib.auth.password_validation import (
+ CommonPasswordValidator, MinimumLengthValidator, NumericPasswordValidator,
+ UserAttributeSimilarityValidator, get_default_password_validators,
+ get_password_validators, password_changed,
+ password_validators_help_text_html, password_validators_help_texts,
+ validate_password,
+)
+from django.core.exceptions import ValidationError
+from django.test import TestCase, override_settings
+
+
+@override_settings(AUTH_PASSWORD_VALIDATORS=[
+ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {
+ 'min_length': 12,
+ }},
+])
+class PasswordValidationTest(TestCase):
+ def test_get_default_password_validators(self):
+ validators = get_default_password_validators()
+ self.assertEqual(len(validators), 2)
+ self.assertEqual(validators[0].__class__.__name__, 'CommonPasswordValidator')
+ self.assertEqual(validators[1].__class__.__name__, 'MinimumLengthValidator')
+ self.assertEqual(validators[1].min_length, 12)
+
+ def test_get_password_validators_custom(self):
+ validator_config = [{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}]
+ validators = get_password_validators(validator_config)
+ self.assertEqual(len(validators), 1)
+ self.assertEqual(validators[0].__class__.__name__, 'CommonPasswordValidator')
+
+ self.assertEqual(get_password_validators([]), [])
+
+ def test_validate_password(self):
+ self.assertIsNone(validate_password('sufficiently-long'))
+ msg_too_short = 'This password is too short. It must contain at least 12 characters.'
+
+ with self.assertRaises(ValidationError, args=['This password is too short.']) as cm:
+ validate_password('django4242')
+ self.assertEqual(cm.exception.messages, [msg_too_short])
+
+ with self.assertRaises(ValidationError) as cm:
+ validate_password('password')
+ self.assertEqual(cm.exception.messages, ['This password is too common.', msg_too_short])
+
+ self.assertIsNone(validate_password('password', password_validators=[]))
+
+ def test_password_changed(self):
+ self.assertIsNone(password_changed('password'))
+
+ def test_password_validators_help_texts(self):
+ help_texts = password_validators_help_texts()
+ self.assertEqual(len(help_texts), 2)
+ self.assertTrue('12 characters' in help_texts[1])
+
+ self.assertEqual(password_validators_help_texts(password_validators=[]), [])
+
+ def test_password_validators_help_text_html(self):
+ help_text = password_validators_help_text_html()
+ self.assertEqual(help_text.count('- '), 2)
+ self.assertTrue('12 characters' in help_text)
+
+
+class MinimumLengthValidatorTest(TestCase):
+ def test_validate(self):
+ expected_error = "This password is too short. It must contain at least %d characters."
+ self.assertIsNone(MinimumLengthValidator().validate('12345678'))
+ self.assertIsNone(MinimumLengthValidator(min_length=3).validate('123'))
+
+ with self.assertRaises(ValidationError) as cm:
+ MinimumLengthValidator().validate('1234567')
+ self.assertEqual(cm.exception.messages, [expected_error % 8])
+
+ with self.assertRaises(ValidationError) as cm:
+ MinimumLengthValidator(min_length=3).validate('12')
+ self.assertEqual(cm.exception.messages, [expected_error % 3])
+
+ def test_help_text(self):
+ self.assertEqual(
+ MinimumLengthValidator().get_help_text(),
+ "Your password must contain at least 8 characters."
+ )
+
+
+class UserAttributeSimilarityValidatorTest(TestCase):
+ def test_validate(self):
+ user = User.objects.create(
+ username='testclient', first_name='Test', last_name='Client', email='testclient@example.com',
+ password='sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161',
+ )
+ expected_error = "The password is too similar to the %s."
+
+ self.assertIsNone(UserAttributeSimilarityValidator().validate('testclient'))
+
+ with self.assertRaises(ValidationError) as cm:
+ UserAttributeSimilarityValidator().validate('testclient', user=user),
+ self.assertEqual(cm.exception.messages, [expected_error % "username"])
+
+ with self.assertRaises(ValidationError) as cm:
+ UserAttributeSimilarityValidator().validate('example.com', user=user),
+ self.assertEqual(cm.exception.messages, [expected_error % "email address"])
+
+ with self.assertRaises(ValidationError) as cm:
+ UserAttributeSimilarityValidator(user_attributes=['first_name'], max_similarity=0.3).validate('testclient', user=user),
+ self.assertEqual(cm.exception.messages, [expected_error % "first name"])
+
+ self.assertIsNone(
+ UserAttributeSimilarityValidator(user_attributes=['first_name']).validate('testclient', user=user)
+ )
+
+ def test_help_text(self):
+ self.assertEqual(
+ UserAttributeSimilarityValidator().get_help_text(),
+ "Your password can't be too similar to your other personal information."
+ )
+
+
+class CommonPasswordValidatorTest(TestCase):
+ def test_validate(self):
+ expected_error = "This password is too common."
+ self.assertIsNone(CommonPasswordValidator().validate('a-safe-password'))
+
+ with self.assertRaises(ValidationError) as cm:
+ CommonPasswordValidator().validate('godzilla')
+ self.assertEqual(cm.exception.messages, [expected_error])
+
+ def test_validate_custom_list(self):
+ path = os.path.dirname(os.path.realpath(__file__)) + '/common-passwords-custom.txt'
+ validator = CommonPasswordValidator(password_list_path=path)
+ expected_error = "This password is too common."
+ self.assertIsNone(validator.validate('a-safe-password'))
+
+ with self.assertRaises(ValidationError) as cm:
+ validator.validate('from-my-custom-list')
+ self.assertEqual(cm.exception.messages, [expected_error])
+
+ def test_help_text(self):
+ self.assertEqual(
+ CommonPasswordValidator().get_help_text(),
+ "Your password can't be a commonly used password."
+ )
+
+
+class NumericPasswordValidatorTest(TestCase):
+ def test_validate(self):
+ expected_error = "This password is entirely numeric."
+ self.assertIsNone(NumericPasswordValidator().validate('a-safe-password'))
+
+ with self.assertRaises(ValidationError) as cm:
+ NumericPasswordValidator().validate('42424242')
+ self.assertEqual(cm.exception.messages, [expected_error])
+
+ def test_help_text(self):
+ self.assertEqual(
+ NumericPasswordValidator().get_help_text(),
+ "Your password can't be entirely numeric."
+ )