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