summaryrefslogtreecommitdiff
path: root/django
diff options
context:
space:
mode:
authorErik Romijn <eromijn@solidlinks.nl>2015-03-08 15:07:57 +0100
committerErik Romijn <eromijn@solidlinks.nl>2015-06-07 19:31:20 +0200
commit1daae25bdcd735151de394a5578c22257e3e5dc7 (patch)
tree5d03536fe9cf69bf0fcf1a1997db713ce52a880b /django
parentf4416b1a8b92e492707a6261b7a1132f8550457f (diff)
Fixed #16860 -- Added password validation to django.contrib.auth.
Diffstat (limited to 'django')
-rw-r--r--django/conf/global_settings.py2
-rw-r--r--django/conf/project_template/project_name/settings.py19
-rw-r--r--django/contrib/auth/common-passwords.txt.gzbin0 -> 3876 bytes
-rw-r--r--django/contrib/auth/forms.py18
-rw-r--r--django/contrib/auth/password_validation.py174
-rw-r--r--django/test/signals.py7
6 files changed, 216 insertions, 4 deletions
diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
index d817ea8e94..e92f8f5e82 100644
--- a/django/conf/global_settings.py
+++ b/django/conf/global_settings.py
@@ -534,6 +534,8 @@ PASSWORD_HASHERS = [
'django.contrib.auth.hashers.CryptPasswordHasher',
]
+AUTH_PASSWORD_VALIDATORS = []
+
###########
# SIGNING #
###########
diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py
index 3eaf05cfc0..e58e229c8a 100644
--- a/django/conf/project_template/project_name/settings.py
+++ b/django/conf/project_template/project_name/settings.py
@@ -82,6 +82,25 @@ DATABASES = {
}
+# Password validation
+# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
# Internationalization
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
diff --git a/django/contrib/auth/common-passwords.txt.gz b/django/contrib/auth/common-passwords.txt.gz
new file mode 100644
index 0000000000..78d490df5a
--- /dev/null
+++ b/django/contrib/auth/common-passwords.txt.gz
Binary files differ
diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py
index daf46e4a81..0d7954a548 100644
--- a/django/contrib/auth/forms.py
+++ b/django/contrib/auth/forms.py
@@ -1,7 +1,9 @@
from __future__ import unicode_literals
from django import forms
-from django.contrib.auth import authenticate, get_user_model
+from django.contrib.auth import (
+ authenticate, get_user_model, password_validation,
+)
from django.contrib.auth.hashers import (
UNUSABLE_PASSWORD_PREFIX, identify_hasher,
)
@@ -263,7 +265,8 @@ class SetPasswordForm(forms.Form):
'password_mismatch': _("The two password fields didn't match."),
}
new_password1 = forms.CharField(label=_("New password"),
- widget=forms.PasswordInput)
+ widget=forms.PasswordInput,
+ help_text=password_validation.password_validators_help_text_html())
new_password2 = forms.CharField(label=_("New password confirmation"),
widget=forms.PasswordInput)
@@ -280,10 +283,13 @@ class SetPasswordForm(forms.Form):
self.error_messages['password_mismatch'],
code='password_mismatch',
)
+ password_validation.validate_password(password2, self.user)
return password2
def save(self, commit=True):
- self.user.set_password(self.cleaned_data['new_password1'])
+ password = self.cleaned_data["new_password1"]
+ self.user.set_password(password)
+ password_validation.password_changed(password, self.user)
if commit:
self.user.save()
return self.user
@@ -327,6 +333,7 @@ class AdminPasswordChangeForm(forms.Form):
password1 = forms.CharField(
label=_("Password"),
widget=forms.PasswordInput,
+ help_text=password_validation.password_validators_help_text_html(),
)
password2 = forms.CharField(
label=_("Password (again)"),
@@ -347,13 +354,16 @@ class AdminPasswordChangeForm(forms.Form):
self.error_messages['password_mismatch'],
code='password_mismatch',
)
+ password_validation.validate_password(password2, self.user)
return password2
def save(self, commit=True):
"""
Saves the new password.
"""
- self.user.set_password(self.cleaned_data["password1"])
+ password = self.cleaned_data["password1"]
+ self.user.set_password(password)
+ password_validation.password_changed(password, self.user)
if commit:
self.user.save()
return self.user
diff --git a/django/contrib/auth/password_validation.py b/django/contrib/auth/password_validation.py
new file mode 100644
index 0000000000..a44bf57875
--- /dev/null
+++ b/django/contrib/auth/password_validation.py
@@ -0,0 +1,174 @@
+from __future__ import unicode_literals
+
+import gzip
+import os
+import re
+from difflib import SequenceMatcher
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured, ValidationError
+from django.utils import lru_cache
+from django.utils.encoding import force_text
+from django.utils.html import format_html
+from django.utils.module_loading import import_string
+from django.utils.six import string_types
+from django.utils.translation import ugettext as _
+
+
+@lru_cache.lru_cache(maxsize=None)
+def get_default_password_validators():
+ return get_password_validators(settings.AUTH_PASSWORD_VALIDATORS)
+
+
+def get_password_validators(validator_config):
+ validators = []
+ for validator in validator_config:
+ try:
+ klass = import_string(validator['NAME'])
+ except ImportError:
+ msg = "The module in NAME could not be imported: %s. Check your AUTH_PASSWORD_VALIDATORS setting."
+ raise ImproperlyConfigured(msg % validator['NAME'])
+ validators.append(klass(**validator.get('OPTIONS', {})))
+
+ return validators
+
+
+def validate_password(password, user=None, password_validators=None):
+ """
+ Validate whether the password meets all validator requirements.
+
+ If the password is valid, return ``None``.
+ If the password is invalid, raise ValidationError with all error messages.
+ """
+ errors = []
+ if password_validators is None:
+ password_validators = get_default_password_validators()
+ for validator in password_validators:
+ try:
+ validator.validate(password, user)
+ except ValidationError as error:
+ errors += error.messages
+ if errors:
+ raise ValidationError(errors)
+
+
+def password_changed(password, user=None, password_validators=None):
+ """
+ Inform all validators that have implemented a password_changed() method
+ that the password has been changed.
+ """
+ if password_validators is None:
+ password_validators = get_default_password_validators()
+ for validator in password_validators:
+ password_changed = getattr(validator, 'password_changed', lambda *a: None)
+ password_changed(password, user)
+
+
+def password_validators_help_texts(password_validators=None):
+ """
+ Return a list of all help texts of all configured validators.
+ """
+ help_texts = []
+ if password_validators is None:
+ password_validators = get_default_password_validators()
+ for validator in password_validators:
+ help_texts.append(validator.get_help_text())
+ return help_texts
+
+
+def password_validators_help_text_html(password_validators=None):
+ """
+ Return an HTML string with all help texts of all configured validators
+ in an <ul>.
+ """
+ help_texts = password_validators_help_texts(password_validators)
+ help_items = [format_html('<li>{}</li>', help_text) for help_text in help_texts]
+ return '<ul>%s</ul>' % ''.join(help_items)
+
+
+class MinimumLengthValidator(object):
+ """
+ Validate whether the password is of a minimum length.
+ """
+ def __init__(self, min_length=8):
+ self.min_length = min_length
+
+ def validate(self, password, user=None):
+ if len(password) < self.min_length:
+ msg = _("This password is too short. It must contain at least %(min_length)d characters.")
+ raise ValidationError(msg % {'min_length': self.min_length})
+
+ def get_help_text(self):
+ return _("Your password must contain at least %(min_length)d characters.") % {'min_length': self.min_length}
+
+
+class UserAttributeSimilarityValidator(object):
+ """
+ Validate whether the password is sufficiently different from the user's
+ attributes.
+
+ If no specific attributes are provided, look at a sensible list of
+ defaults. Attributes that don't exist are ignored. Comparison is made to
+ not only the full attribute value, but also its components, so that, for
+ example, a password is validated against either part of an email address,
+ as well as the full address.
+ """
+ DEFAULT_USER_ATTRIBUTES = ('username', 'first_name', 'last_name', 'email')
+
+ def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
+ self.user_attributes = user_attributes
+ self.max_similarity = max_similarity
+
+ def validate(self, password, user=None):
+ if not user:
+ return
+
+ for attribute_name in self.user_attributes:
+ value = getattr(user, attribute_name, None)
+ if not value or not isinstance(value, string_types):
+ continue
+ value_parts = re.split('\W+', value) + [value]
+ for value_part in value_parts:
+ if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() > self.max_similarity:
+ verbose_name = force_text(user._meta.get_field(attribute_name).verbose_name)
+ raise ValidationError(_("The password is too similar to the %s." % verbose_name))
+
+ def get_help_text(self):
+ return _("Your password can't be too similar to your other personal information.")
+
+
+class CommonPasswordValidator(object):
+ """
+ Validate whether the password is a common password.
+
+ The password is rejected if it occurs in a provided list, which may be gzipped.
+ The list Django ships with contains 1000 common passwords, created by Mark Burnett:
+ https://xato.net/passwords/more-top-worst-passwords/
+ """
+ DEFAULT_PASSWORD_LIST_PATH = os.path.dirname(os.path.realpath(__file__)) + '/common-passwords.txt.gz'
+
+ def __init__(self, password_list_path=DEFAULT_PASSWORD_LIST_PATH):
+ try:
+ common_passwords_lines = gzip.open(password_list_path).read().decode('utf-8').splitlines()
+ except IOError:
+ common_passwords_lines = open(password_list_path).readlines()
+ self.passwords = {p.strip() for p in common_passwords_lines}
+
+ def validate(self, password, user=None):
+ if password.lower().strip() in self.passwords:
+ raise ValidationError(_("This password is too common."))
+
+ def get_help_text(self):
+ return _("Your password can't be a commonly used password.")
+
+
+class NumericPasswordValidator(object):
+ """
+ Validate whether the password is alphanumeric.
+ """
+ def validate(self, password, user=None):
+ if password.isdigit():
+ raise ValidationError(_("This password is entirely numeric."))
+
+ def get_help_text(self):
+ return _("Your password can't be entirely numeric.")
diff --git a/django/test/signals.py b/django/test/signals.py
index 6d8a914ad7..f7836637e8 100644
--- a/django/test/signals.py
+++ b/django/test/signals.py
@@ -175,3 +175,10 @@ def static_finders_changed(**kwargs):
}:
from django.contrib.staticfiles.finders import get_finder
get_finder.cache_clear()
+
+
+@receiver(setting_changed)
+def auth_password_validators_changed(**kwargs):
+ if kwargs['setting'] == 'AUTH_PASSWORD_VALIDATORS':
+ from django.contrib.auth.password_validation import get_default_password_validators
+ get_default_password_validators.cache_clear()