diff options
| author | Erik Romijn <eromijn@solidlinks.nl> | 2015-03-08 15:07:57 +0100 |
|---|---|---|
| committer | Erik Romijn <eromijn@solidlinks.nl> | 2015-06-07 19:31:20 +0200 |
| commit | 1daae25bdcd735151de394a5578c22257e3e5dc7 (patch) | |
| tree | 5d03536fe9cf69bf0fcf1a1997db713ce52a880b | |
| parent | f4416b1a8b92e492707a6261b7a1132f8550457f (diff) | |
Fixed #16860 -- Added password validation to django.contrib.auth.
| -rw-r--r-- | django/conf/global_settings.py | 2 | ||||
| -rw-r--r-- | django/conf/project_template/project_name/settings.py | 19 | ||||
| -rw-r--r-- | django/contrib/auth/common-passwords.txt.gz | bin | 0 -> 3876 bytes | |||
| -rw-r--r-- | django/contrib/auth/forms.py | 18 | ||||
| -rw-r--r-- | django/contrib/auth/password_validation.py | 174 | ||||
| -rw-r--r-- | django/test/signals.py | 7 | ||||
| -rw-r--r-- | docs/ref/settings.txt | 13 | ||||
| -rw-r--r-- | docs/releases/1.9.txt | 40 | ||||
| -rw-r--r-- | docs/topics/auth/passwords.txt | 214 | ||||
| -rw-r--r-- | tests/auth_tests/common-passwords-custom.txt | 1 | ||||
| -rw-r--r-- | tests/auth_tests/test_forms.py | 18 | ||||
| -rw-r--r-- | tests/auth_tests/test_validators.py | 162 |
12 files changed, 663 insertions, 5 deletions
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 Binary files differnew file mode 100644 index 0000000000..78d490df5a --- /dev/null +++ b/django/contrib/auth/common-passwords.txt.gz 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 <ul>. + """ + help_texts = password_validators_help_texts(password_validators) + help_items = [format_html('<li>{}</li>', help_text) for help_text in help_texts] + return '<ul>%s</ul>' % ''.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 <https://xato.net/passwords/more-top-worst-passwords/>`_. + + 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 ``<ul>``. 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('<li>'), 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." + ) |
