From 1daae25bdcd735151de394a5578c22257e3e5dc7 Mon Sep 17 00:00:00 2001 From: Erik Romijn Date: Sun, 8 Mar 2015 15:07:57 +0100 Subject: [PATCH] Fixed #16860 -- Added password validation to django.contrib.auth. --- django/conf/global_settings.py | 2 + .../project_template/project_name/settings.py | 19 ++ django/contrib/auth/common-passwords.txt.gz | Bin 0 -> 3876 bytes django/contrib/auth/forms.py | 18 +- django/contrib/auth/password_validation.py | 174 ++++++++++++++ django/test/signals.py | 7 + docs/ref/settings.txt | 13 ++ docs/releases/1.9.txt | 40 +++- docs/topics/auth/passwords.txt | 214 ++++++++++++++++++ tests/auth_tests/common-passwords-custom.txt | 1 + tests/auth_tests/test_forms.py | 18 ++ tests/auth_tests/test_validators.py | 162 +++++++++++++ 12 files changed, 663 insertions(+), 5 deletions(-) create mode 100644 django/contrib/auth/common-passwords.txt.gz create mode 100644 django/contrib/auth/password_validation.py create mode 100644 tests/auth_tests/common-passwords-custom.txt create mode 100644 tests/auth_tests/test_validators.py 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 0000000000000000000000000000000000000000..78d490df5ae25f31d5efa1673280ec0b92699070 GIT binary patch literal 3876 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 m. + """ + help_texts = password_validators_help_texts(password_validators) + help_items = [format_html('
  • {}
  • ', help_text) for help_text in help_texts] + return '
      %s
    ' % ''.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." + )