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',
|
'django.contrib.auth.hashers.CryptPasswordHasher',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
###########
|
###########
|
||||||
# SIGNING #
|
# 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
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
|
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
|
||||||
|
|
||||||
|
|
Binary file not shown.
|
@ -1,7 +1,9 @@
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django import forms
|
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 (
|
from django.contrib.auth.hashers import (
|
||||||
UNUSABLE_PASSWORD_PREFIX, identify_hasher,
|
UNUSABLE_PASSWORD_PREFIX, identify_hasher,
|
||||||
)
|
)
|
||||||
|
@ -263,7 +265,8 @@ class SetPasswordForm(forms.Form):
|
||||||
'password_mismatch': _("The two password fields didn't match."),
|
'password_mismatch': _("The two password fields didn't match."),
|
||||||
}
|
}
|
||||||
new_password1 = forms.CharField(label=_("New password"),
|
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"),
|
new_password2 = forms.CharField(label=_("New password confirmation"),
|
||||||
widget=forms.PasswordInput)
|
widget=forms.PasswordInput)
|
||||||
|
|
||||||
|
@ -280,10 +283,13 @@ class SetPasswordForm(forms.Form):
|
||||||
self.error_messages['password_mismatch'],
|
self.error_messages['password_mismatch'],
|
||||||
code='password_mismatch',
|
code='password_mismatch',
|
||||||
)
|
)
|
||||||
|
password_validation.validate_password(password2, self.user)
|
||||||
return password2
|
return password2
|
||||||
|
|
||||||
def save(self, commit=True):
|
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:
|
if commit:
|
||||||
self.user.save()
|
self.user.save()
|
||||||
return self.user
|
return self.user
|
||||||
|
@ -327,6 +333,7 @@ class AdminPasswordChangeForm(forms.Form):
|
||||||
password1 = forms.CharField(
|
password1 = forms.CharField(
|
||||||
label=_("Password"),
|
label=_("Password"),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput,
|
||||||
|
help_text=password_validation.password_validators_help_text_html(),
|
||||||
)
|
)
|
||||||
password2 = forms.CharField(
|
password2 = forms.CharField(
|
||||||
label=_("Password (again)"),
|
label=_("Password (again)"),
|
||||||
|
@ -347,13 +354,16 @@ class AdminPasswordChangeForm(forms.Form):
|
||||||
self.error_messages['password_mismatch'],
|
self.error_messages['password_mismatch'],
|
||||||
code='password_mismatch',
|
code='password_mismatch',
|
||||||
)
|
)
|
||||||
|
password_validation.validate_password(password2, self.user)
|
||||||
return password2
|
return password2
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
"""
|
"""
|
||||||
Saves the new password.
|
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:
|
if commit:
|
||||||
self.user.save()
|
self.user.save()
|
||||||
return self.user
|
return self.user
|
||||||
|
|
|
@ -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
|
from django.contrib.staticfiles.finders import get_finder
|
||||||
get_finder.cache_clear()
|
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.UnsaltedMD5PasswordHasher',
|
||||||
'django.contrib.auth.hashers.CryptPasswordHasher']
|
'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:
|
.. _settings-messages:
|
||||||
|
|
||||||
Messages
|
Messages
|
||||||
|
|
|
@ -25,7 +25,45 @@ Python 3.2 and added support for Python 3.5.
|
||||||
What's new in Django 1.9
|
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
|
Minor features
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -236,3 +236,217 @@ from the ``User`` model.
|
||||||
|
|
||||||
Checks if the given string is a hashed password that has a chance
|
Checks if the given string is a hashed password that has a chance
|
||||||
of being verified against :func:`check_password`.
|
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.
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
from-my-custom-list
|
|
@ -263,6 +263,24 @@ class SetPasswordFormTest(TestDataMixin, TestCase):
|
||||||
form = SetPasswordForm(user, data)
|
form = SetPasswordForm(user, data)
|
||||||
self.assertTrue(form.is_valid())
|
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'])
|
@override_settings(USE_TZ=False, PASSWORD_HASHERS=['django.contrib.auth.hashers.SHA1PasswordHasher'])
|
||||||
class PasswordChangeFormTest(TestDataMixin, TestCase):
|
class PasswordChangeFormTest(TestDataMixin, TestCase):
|
||||||
|
|
|
@ -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