mirror of
https://github.com/django/django.git
synced 2024-12-22 09:05:43 +00:00
Fixed #16860 -- Added password validation to django.contrib.auth.
This commit is contained in:
parent
f4416b1a8b
commit
1daae25bdc
@ -534,6 +534,8 @@ PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.CryptPasswordHasher',
|
||||
]
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
|
||||
###########
|
||||
# SIGNING #
|
||||
###########
|
||||
|
@ -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/
|
||||
|
||||
|
BIN
django/contrib/auth/common-passwords.txt.gz
Normal file
BIN
django/contrib/auth/common-passwords.txt.gz
Normal file
Binary file not shown.
@ -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
|
||||
|
174
django/contrib/auth/password_validation.py
Normal file
174
django/contrib/auth/password_validation.py
Normal file
@ -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.")
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
~~~~~~~~~~~~~~
|
||||
|
@ -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.
|
||||
|
1
tests/auth_tests/common-passwords-custom.txt
Normal file
1
tests/auth_tests/common-passwords-custom.txt
Normal file
@ -0,0 +1 @@
|
||||
from-my-custom-list
|
@ -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):
|
||||
|
162
tests/auth_tests/test_validators.py
Normal file
162
tests/auth_tests/test_validators.py
Normal file
@ -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."
|
||||
)
|
Loading…
Reference in New Issue
Block a user