From 4fa27f98dcc22939fce96ed34477dbcf6037e20b Mon Sep 17 00:00:00 2001 From: Dillon Dumesnil <ddumesnil@edx.org> Date: Thu, 13 Sep 2018 15:28:21 -0400 Subject: [PATCH] Implementing django password validators for edX. This involves removing the old validate password method and configuration values in favor of AUTH_PASSWORD_VALIDATORS, a list of validators to use to check a password. These include some that come straight from Django and some that were written according to Django's specifications. This work also included maintaining the current messaging as instruction text and passing along restrictions for the password field. --- cms/envs/aws.py | 6 +- common/djangoapps/student/forms.py | 22 +- common/djangoapps/student/views/management.py | 4 +- .../util/password_policy_validators.py | 640 +++++++++++------- lms/envs/aws.py | 6 +- lms/envs/devstack.py | 3 - .../djangoapps/password_policy/compliance.py | 4 +- .../core/djangoapps/user_api/accounts/api.py | 22 +- openedx/core/djangoapps/user_api/api.py | 23 +- openedx/core/djangoapps/user_api/helpers.py | 4 +- .../djangoapps/user_api/validation/views.py | 7 +- 11 files changed, 425 insertions(+), 316 deletions(-) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 72f8d0ca7ce..25d646ed1e4 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -447,11 +447,7 @@ MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_AL MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60) #### PASSWORD POLICY SETTINGS ##### -PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") -PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH") -PASSWORD_COMPLEXITY = ENV_TOKENS.get("PASSWORD_COMPLEXITY", {}) -PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = ENV_TOKENS.get("PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD") -PASSWORD_DICTIONARY = ENV_TOKENS.get("PASSWORD_DICTIONARY", []) +AUTH_PASSWORD_VALIDATORS = ENV_TOKENS.get("AUTH_PASSWORD_VALIDATORS", []) ### INACTIVITY SETTINGS #### SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS") diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 8ca0a6f4c93..c43b10ca495 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -27,7 +27,7 @@ from openedx.core.djangoapps.user_api import accounts as accounts_settings from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from student.message_types import PasswordReset from student.models import CourseEnrollmentAllowed, email_exists_or_retired -from util.password_policy_validators import password_max_length, password_min_length, validate_password +from util.password_policy_validators import edX_validate_password def send_password_reset_email_for_user(user, request): @@ -193,7 +193,6 @@ class AccountCreationForm(forms.Form): """ _EMAIL_INVALID_MSG = _("A properly formatted e-mail is required") - _PASSWORD_INVALID_MSG = _("A valid password is required") _NAME_TOO_SHORT_MSG = _("Your legal name must be a minimum of two characters long") # TODO: Resolve repetition @@ -209,15 +208,9 @@ class AccountCreationForm(forms.Form): "max_length": _("Email cannot be more than %(limit_value)s characters long"), } ) - password = forms.CharField( - min_length=password_min_length(), - max_length=password_max_length(), - error_messages={ - "required": _PASSWORD_INVALID_MSG, - "min_length": _PASSWORD_INVALID_MSG, - "max_length": _PASSWORD_INVALID_MSG, - } - ) + + password = forms.CharField() + name = forms.CharField( min_length=accounts_settings.NAME_MIN_LENGTH, error_messages={ @@ -288,7 +281,12 @@ class AccountCreationForm(forms.Form): """Enforce password policies (if applicable)""" password = self.cleaned_data["password"] if self.enforce_password_policy: - validate_password(password, username=self.cleaned_data.get('username')) + # Creating a temporary user object to test password against username + # This user should NOT be saved + username = self.cleaned_data.get('username') + email = self.cleaned_data.get('email') + temp_user = User(username=username, email=email) if username else None + edX_validate_password(password, temp_user) return password def clean_email(self): diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index e01f0fb2a25..47ce54bf309 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -94,7 +94,7 @@ from student.text_me_the_app import TextMeTheAppFragmentView from util.bad_request_rate_limiter import BadRequestRateLimiter from util.db import outer_atomic from util.json_request import JsonResponse -from util.password_policy_validators import SecurityPolicyError, validate_password +from util.password_policy_validators import edX_validate_password log = logging.getLogger("edx.student") @@ -830,7 +830,7 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None): password = request.POST['new_password1'] try: - validate_password(password, user=user) + edX_validate_password(password, user=user) except ValidationError as err: # We have a password reset attempt which violates some security # policy, or any other validation. Use the existing Django template to communicate that diff --git a/common/djangoapps/util/password_policy_validators.py b/common/djangoapps/util/password_policy_validators.py index d7c0138fb39..ded8d0040dd 100644 --- a/common/djangoapps/util/password_policy_validators.py +++ b/common/djangoapps/util/password_policy_validators.py @@ -1,322 +1,450 @@ """ -This file exposes a number of password complexity validators which can be optionally added to +This file exposes a number of password validators which can be optionally added to account creation - -This file was inspired by the django-passwords project at https://github.com/dstufft/django-passwords -authored by dstufft (https://github.com/dstufft) """ -from __future__ import division +from __future__ import unicode_literals import logging -import string import unicodedata from django.conf import settings +from django.contrib.auth.password_validation import ( + get_default_password_validators, + validate_password, + MinimumLengthValidator as DjangoMinimumLengthValidator, +) from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ -from django.utils.translation import ungettext_lazy as ungettext -from Levenshtein import distance +from django.utils.translation import ugettext as _, ungettext from six import text_type from student.models import PasswordHistory - log = logging.getLogger(__name__) -# In description order -_allowed_password_complexity = [ - 'ALPHABETIC', - 'UPPER', - 'LOWER', - 'NUMERIC', - 'DIGITS', - 'PUNCTUATION', - 'NON ASCII', - 'WORDS', -] - -class SecurityPolicyError(ValidationError): - pass +def password_validators_instruction_texts(): + """ + Return a string of instruction texts of all configured validators. + Expects at least the MinimumLengthValidator to be defined. + """ + complexity_instructions = [] + # For clarity in the printed instructions, the minimum length instruction + # is separated from the complexity instructions. + length_instruction = '' + password_validators = get_default_password_validators() + for validator in password_validators: + if hasattr(validator, 'get_instruction_text'): + text = validator.get_instruction_text() + if isinstance(validator, MinimumLengthValidator): + length_instruction = text + else: + complexity_instructions.append(text) + if complexity_instructions: + return _('Your password must contain {length_instruction}, including {complexity_instructions}.').format( + length_instruction=length_instruction, + complexity_instructions=' & '.join(complexity_instructions) + ) + else: + return _('Your password must contain {length_instruction}.'.format(length_instruction=length_instruction)) -def password_min_length(): +def password_validators_restrictions(): """ - Returns minimum required length of a password. - Can be overridden by site configuration of PASSWORD_MIN_LENGTH. + Return a dictionary of complexity restrictions to be used by mobile users on + the registration form """ - min_length = getattr(settings, 'PASSWORD_MIN_LENGTH', None) - if min_length is None: - return 2 # Note: This default is simply historical - return min_length + password_validators = get_default_password_validators() + complexity_restrictions = dict(validator.get_restriction() + for validator in password_validators + if hasattr(validator, 'get_restriction') + ) + return complexity_restrictions -def password_max_length(): +def edX_validate_password(password, user=None): """ - Returns maximum allowed length of a password. If zero, no maximum. - Can be overridden by site configuration of PASSWORD_MAX_LENGTH. + EdX's custom password validator for passwords. This function performs the + following functions: + 1) Converts the password to unicode if it is not already + 2) Calls Django's validate_password method. This calls the validate function + in all validators specified in AUTH_PASSWORD_VALIDATORS configuration. + + Parameters: + password (str or unicode): the user's password to be validated + user (django.contrib.auth.models.User): The user object to use for validating + the given password against the username and/or email. + + Returns: + None + + Raises: + ValidationError if unable to convert password to utf8 or if any of the + password validators fail. """ - # Note: The default value here is simply historical - max_length = getattr(settings, 'PASSWORD_MAX_LENGTH', None) - if max_length is None: - return 75 # Note: This default is simply historical - return max_length + if not isinstance(password, text_type): + try: + # some checks rely on unicode semantics (e.g. length) + password = text_type(password, encoding='utf8') + except UnicodeDecodeError: + # no reason to get into weeds + raise ValidationError([_('Invalid password.')]) + validate_password(password, user) -def password_complexity(): - """ - :return: A dict of complexity requirements from settings + +def _validate_condition(password, fn, min_count): """ - complexity = {} - if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): - complexity = getattr(settings, 'PASSWORD_COMPLEXITY', {}) + Validates the password using the given function. This is performed by + iterating through each character in the password and counting up the number + of characters that satisfy the function. - valid_complexity = {x: y for x, y in complexity.iteritems() if x in _allowed_password_complexity} + Parameters: + password (str): the password + fn: the function to be tested against the string. + min_count (int): the minimum number of characters that must satisfy the function - if not password_complexity.logged: - invalid = frozenset(complexity.keys()) - frozenset(valid_complexity.keys()) - for key in invalid: - log.warning('Unrecognized %s value in PASSWORD_COMPLEXITY setting.', key) - password_complexity.logged = True + Return: + True if valid_count >= min_count, else False + """ + valid_count = len([c for c in password if fn(c)]) + return valid_count >= min_count - return valid_complexity +class MinimumLengthValidator(DjangoMinimumLengthValidator): + def get_instruction_text(self): + return ungettext( + 'at least %(min_length)d character', + 'at least %(min_length)d characters', + self.min_length + ) % {'min_length': self.min_length} -# Declare static variable for the function above, which helps avoid issuing multiple log warnings. -# We don't instead keep a cached version of the complexity rules, because that might trip up unit tests. -password_complexity.logged = False + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_length', self.min_length -def _password_complexity_descriptions(which=None): +class MaximumLengthValidator(object): """ - which: A list of which complexities to describe, None if you want the configured ones - :return: A list of complexity descriptions + Validate whether the password is shorter than a maximum length. + + Parameters: + max_length (int): the maximum number of characters to require in the password. """ - descs = [] - complexity = password_complexity() - if which is None: - which = complexity.keys() - - for key in _allowed_password_complexity: # we iterate over allowed keys so that we get the order right - value = complexity.get(key, 0) if key in which else 0 - if not value: - continue - - if key == 'ALPHABETIC': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} letter', '{num} letters', value).format(num=value)) - elif key == 'UPPER': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} uppercase letter', '{num} uppercase letters', value).format(num=value)) - elif key == 'LOWER': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} lowercase letter', '{num} lowercase letters', value).format(num=value)) - elif key == 'DIGITS': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} digit', '{num} digits', value).format(num=value)) - elif key == 'NUMERIC': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} number', '{num} numbers', value).format(num=value)) - elif key == 'PUNCTUATION': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} punctuation mark', '{num} punctuation marks', value).format(num=value)) - elif key == 'NON ASCII': # note that our definition of non-ascii is non-letter, non-digit, non-punctuation - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} symbol', '{num} symbols', value).format(num=value)) - elif key == 'WORDS': - # Translators: This appears in a list of password requirements - descs.append(ungettext('{num} word', '{num} words', value).format(num=value)) - else: - raise Exception('Unexpected complexity value {}'.format(key)) + def __init__(self, max_length=75): + self.max_length = max_length + + def validate(self, password, user=None): + if len(password) > self.max_length: + raise ValidationError( + ungettext( + 'This password is too long. It must contain no more than %(max_length)d character.', + 'This password is too long. It must contain no more than %(max_length)d characters.', + self.max_length + ), + code='password_too_long', + params={'max_length': self.max_length}, + ) + + def get_help_text(self): + return ungettext( + 'Your password must contain no more than %(max_length)d character.', + 'Your password must contain no more than %(max_length)d characters.', + self.max_length + ) % {'max_length': self.max_length} - return descs + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'max_length', self.max_length -def password_instructions(): +class AlphabeticValidator(object): """ - :return: A string suitable for display to the user to tell them what password to enter + Validate whether the password contains at least min_alphabetic letters. + + Parameters: + min_alphabetic (int): the minimum number of alphabetic characters to require + in the password. Must be >= 0. """ - min_length = password_min_length() - reqs = _password_complexity_descriptions() + def __init__(self, min_alphabetic=0): + self.min_alphabetic = min_alphabetic + + def validate(self, password, user=None): + if _validate_condition(password, lambda c: c.isalpha(), self.min_alphabetic): + return + raise ValidationError( + ungettext( + 'Your password must contain at least %(min_alphabetic)d letter.', + 'Your password must contain at least %(min_alphabetic)d letters.', + self.min_alphabetic + ), + code='too_few_alphabetic_char', + params={'min_alphabetic': self.min_alphabetic}, + ) + + def get_help_text(self): + return ungettext( + 'Your password must contain at least %(min_alphabetic)d letter.', + 'Your password must contain at least %(min_alphabetic)d letters.', + self.min_alphabetic + ) % {'min_alphabetic': self.min_alphabetic} + + def get_instruction_text(self): + if self.min_alphabetic > 0: + return ungettext( + '%(num)d letter', + '%(num)d letters', + self.min_alphabetic + ) % {'num': self.min_alphabetic} + else: + return '' - if not reqs: - return ungettext('Your password must contain at least {num} character.', - 'Your password must contain at least {num} characters.', - min_length).format(num=min_length) - else: - return ungettext('Your password must contain at least {num} character, including {requirements}.', - 'Your password must contain at least {num} characters, including {requirements}.', - min_length).format(num=min_length, requirements=' & '.join(reqs)) + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_alphabetic', self.min_alphabetic -def validate_password(password, user=None, username=None, password_reset=True): +class NumericValidator(object): """ - Checks user-provided password against our current site policy. - - Raises a ValidationError or SecurityPolicyError depending on the type of error. + Validate whether the password contains at least min_numeric numbers. - Arguments: - password: The user-provided password as a string - user: A User model object, if available. Required to check against security policy. - username: The user-provided username, if available. Taken from 'user' if not provided. - password_reset: Whether to run validators that only make sense in a password reset - context (like PasswordHistory). + Parameters: + min_numeric (int): the minimum number of numeric characters to require + in the password. Must be >= 0. """ - if not isinstance(password, text_type): - try: - password = text_type(password, encoding='utf8') # some checks rely on unicode semantics (e.g. length) - except UnicodeDecodeError: - raise ValidationError(_('Invalid password.')) # no reason to get into weeds + def __init__(self, min_numeric=0): + self.min_numeric = min_numeric + + def validate(self, password, user=None): + if _validate_condition(password, lambda c: c.isnumeric(), self.min_numeric): + return + raise ValidationError( + ungettext( + 'Your password must contain at least %(min_numeric)d number.', + 'Your password must contain at least %(min_numeric)d numbers.', + self.min_numeric + ), + code='too_few_numeric_char', + params={'min_numeric': self.min_numeric}, + ) + + def get_help_text(self): + return ungettext( + "Your password must contain at least %(min_numeric)d number.", + "Your password must contain at least %(min_numeric)d numbers.", + self.min_numeric + ) % {'min_numeric': self.min_numeric} + + def get_instruction_text(self): + if self.min_numeric > 0: + return ungettext( + '%(num)d number', + '%(num)d numbers', + self.min_numeric + ) % {'num': self.min_numeric} + else: + return '' - username = username or (user and user.username) + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_numeric', self.min_numeric - if user and password_reset: - _validate_password_security(password, user) - _validate_password_dictionary(password) - _validate_password_against_username(password, username) +class UppercaseValidator(object): + """ + Validate whether the password contains at least min_upper uppercase letters. - # Some messages are composable, so we'll add them together here - errors = [_validate_password_length(password)] - errors += _validate_password_complexity(password) - errors = filter(None, errors) + Parameters: + min_upper (int): the minimum number of uppercase characters to require + in the password. Must be >= 0. + """ + def __init__(self, min_upper=0): + self.min_upper = min_upper + + def validate(self, password, user=None): + if _validate_condition(password, lambda c: c.isupper(), self.min_upper): + return + raise ValidationError( + ungettext( + 'Your password must contain at least %(min_upper)d uppercase letter.', + 'Your password must contain at least %(min_upper)d uppercase letters.', + self.min_upper + ), + code='too_few_uppercase_char', + params={'min_upper': self.min_upper}, + ) + + def get_help_text(self): + return ungettext( + "Your password must contain at least %(min_upper)d uppercase letter.", + "Your password must contain at least %(min_upper)d uppercase letters.", + self.min_upper + ) % {'min_upper': self.min_upper} + + def get_instruction_text(self): + if self.min_upper > 0: + return ungettext( + '%(num)d uppercase letter', + '%(num)d uppercase letters', + self.min_upper + ) % {'num': self.min_upper} + else: + return '' - if errors: - msg = _('Enter a password with at least {requirements}.').format(requirements=' & '.join(errors)) - raise ValidationError(msg) + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_upper', self.min_upper -def _validate_password_security(password, user): +class LowercaseValidator(object): """ - Check password reuse and similar operational security policy considerations. + Validate whether the password contains at least min_lower lowercase letters. + + Parameters: + min_lower (int): the minimum number of lowercase characters to require + in the password. Must be >= 0. """ - # Check reuse - if not PasswordHistory.is_allowable_password_reuse(user, password): - if user.is_staff: - num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + def __init__(self, min_lower=0): + self.min_lower = min_lower + + def validate(self, password, user=None): + if _validate_condition(password, lambda c: c.islower(), self.min_lower): + return + raise ValidationError( + ungettext( + 'Your password must contain at least %(min_lower)d lowercase letter.', + 'Your password must contain at least %(min_lower)d lowercase letters.', + self.min_lower + ), + code='too_few_lowercase_char', + params={'min_lower': self.min_lower}, + ) + + def get_help_text(self): + return ungettext( + "Your password must contain at least %(min_lower)d lowercase letter.", + "Your password must contain at least %(min_lower)d lowercase letters.", + self.min_lower + ) % {'min_lower': self.min_lower} + + def get_instruction_text(self): + if self.min_lower > 0: + return ungettext( + '%(num)d lowercase letter', + '%(num)d lowercase letters', + self.min_lower + ) % {'num': self.min_lower} else: - num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] - raise SecurityPolicyError(ungettext( - "You are re-using a password that you have used recently. " - "You must have {num} distinct password before reusing a previous password.", - "You are re-using a password that you have used recently. " - "You must have {num} distinct passwords before reusing a previous password.", - num_distinct - ).format(num=num_distinct)) - - # Check reset frequency - if PasswordHistory.is_password_reset_too_soon(user): - num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] - raise SecurityPolicyError(ungettext( - "You are resetting passwords too frequently. Due to security policies, " - "{num} day must elapse between password resets.", - "You are resetting passwords too frequently. Due to security policies, " - "{num} days must elapse between password resets.", - num_days - ).format(num=num_days)) - - -def _validate_password_length(value): - """ - Validator that enforces minimum length of a password - """ - min_length = password_min_length() - max_length = password_max_length() + return '' - if min_length and len(value) < min_length: - # This is an error that can be composed with other requirements, so just return a fragment - # Translators: This appears in a list of password requirements - return ungettext( - "{num} character", - "{num} characters", - min_length - ).format(num=min_length) - elif max_length and len(value) > max_length: - raise ValidationError(ungettext( - "Enter a password with at most {num} character.", - "Enter a password with at most {num} characters.", - max_length - ).format(num=max_length)) - - -def _validate_password_complexity(value): + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_lower', self.min_lower + + +class PunctuationValidator(object): """ - Validator that enforces minimum complexity + Validate whether the password contains at least min_punctuation punctuation characters + as defined by unicode categories. + + Parameters: + min_punctuation (int): the minimum number of punctuation characters to require + in the password. Must be >= 0. """ - complexities = password_complexity() - if not complexities: - return [] - - # Sets are here intentionally - uppercase, lowercase, digits, non_ascii, punctuation = set(), set(), set(), set(), set() - alphabetic, numeric = [], [] - - for character in value: - if character.isupper(): - uppercase.add(character) - elif character.islower(): - lowercase.add(character) - elif character.isdigit(): - digits.add(character) - elif character in string.punctuation: - punctuation.add(character) + def __init__(self, min_punctuation=0): + self.min_punctuation = min_punctuation + + def validate(self, password, user=None): + if _validate_condition(password, lambda c: 'P' in unicodedata.category(c), self.min_punctuation): + return + raise ValidationError( + ungettext( + 'Your password must contain at least %(min_punctuation)d punctuation character.', + 'Your password must contain at least %(min_punctuation)d punctuation characters.', + self.min_punctuation + ), + code='too_few_punctuation_characters', + params={'min_punctuation': self.min_punctuation}, + ) + + def get_help_text(self): + return ungettext( + "Your password must contain at least %(min_punctuation)d punctuation character.", + "Your password must contain at least %(min_punctuation)d punctuation characters.", + self.min_punctuation + ) % {'min_punctuation': self.min_punctuation} + + def get_instruction_text(self): + if self.min_punctuation > 0: + return ungettext( + '%(num)d punctuation character', + '%(num)d punctuation characters', + self.min_punctuation + ) % {'num': self.min_punctuation} else: - non_ascii.add(character) - - if character.isalpha(): - alphabetic.append(character) - if 'N' in unicodedata.category(character): # Check to see if the unicode category contains a 'N'umber - numeric.append(character) - - words = set(value.split()) - - errors = [] - if len(uppercase) < complexities.get("UPPER", 0): - errors.append('UPPER') - if len(lowercase) < complexities.get("LOWER", 0): - errors.append('LOWER') - if len(digits) < complexities.get("DIGITS", 0): - errors.append('DIGITS') - if len(punctuation) < complexities.get("PUNCTUATION", 0): - errors.append('PUNCTUATION') - if len(non_ascii) < complexities.get("NON ASCII", 0): - errors.append('NON ASCII') - if len(words) < complexities.get("WORDS", 0): - errors.append('WORDS') - if len(numeric) < complexities.get("NUMERIC", 0): - errors.append('NUMERIC') - if len(alphabetic) < complexities.get("ALPHABETIC", 0): - errors.append('ALPHABETIC') - - if errors: - return _password_complexity_descriptions(errors) - else: - return [] - + return '' -def _validate_password_against_username(password, username): - if not username: - return + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_punctuation', self.min_punctuation - if password == username: - # Translators: This message is shown to users who enter a password matching - # the username they enter(ed). - raise ValidationError(_(u"Password cannot be the same as the username.")) - -def _validate_password_dictionary(value): +class SymbolValidator(object): """ - Insures that the password is not too similar to a defined set of dictionary words + Validate whether the password contains at least min_symbol symbols as defined by unicode categories. + + Parameters: + min_symbol (int): the minimum number of symbols to require + in the password. Must be >= 0. """ - if not settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): - return - - password_max_edit_distance = getattr(settings, "PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD", None) - password_dictionary = getattr(settings, "PASSWORD_DICTIONARY", None) - - if password_max_edit_distance and password_dictionary: - for word in password_dictionary: - edit_distance = distance(value, text_type(word)) - if edit_distance <= password_max_edit_distance: - raise ValidationError(_("Password is too similar to a dictionary word."), - code="dictionary_word") + def __init__(self, min_symbol=0): + self.min_symbol = min_symbol + + def validate(self, password, user=None): + if _validate_condition(password, lambda c: 'S' in unicodedata.category(c), self.min_symbol): + return + raise ValidationError( + ungettext( + 'Your password must contain at least %(min_symbol)d symbol.', + 'Your password must contain at least %(min_symbol)d symbols.', + self.min_symbol + ), + code='too_few_symbols', + params={'min_symbol': self.min_symbol}, + ) + + def get_help_text(self): + return ungettext( + "Your password must contain at least %(min_symbol)d symbol.", + "Your password must contain at least %(min_symbol)d symbols.", + self.min_symbol + ) % {'min_symbol': self.min_symbol} + + def get_instruction_text(self): + if self.min_symbol > 0: + return ungettext( + '%(num)d symbol', + '%(num)d symbols', + self.min_symbol + ) % {'num': self.min_symbol} + else: + return '' + + def get_restriction(self): + """ + Returns a key, value pair for the restrictions related to the Validator + """ + return 'min_symbol', self.min_symbol diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d6ae2d06af6..4f56c9930d8 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -652,11 +652,7 @@ MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_AL MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60) #### PASSWORD POLICY SETTINGS ##### -PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") -PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH") -PASSWORD_COMPLEXITY = ENV_TOKENS.get("PASSWORD_COMPLEXITY", {}) -PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = ENV_TOKENS.get("PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD") -PASSWORD_DICTIONARY = ENV_TOKENS.get("PASSWORD_DICTIONARY", []) +AUTH_PASSWORD_VALIDATORS = ENV_TOKENS.get("AUTH_PASSWORD_VALIDATORS", []) ### INACTIVITY SETTINGS #### SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS") diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 70594981c37..1f811dec70d 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -142,9 +142,6 @@ FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False FEATURES['SQUELCH_PII_IN_LOGS'] = False FEATURES['PREVENT_CONCURRENT_LOGINS'] = False FEATURES['ADVANCED_SECURITY'] = False -PASSWORD_MIN_LENGTH = None -PASSWORD_COMPLEXITY = {} - ########################### Milestones ################################# FEATURES['MILESTONES_APP'] = True diff --git a/openedx/core/djangoapps/password_policy/compliance.py b/openedx/core/djangoapps/password_policy/compliance.py index 2c20364f11b..53326b9957c 100644 --- a/openedx/core/djangoapps/password_policy/compliance.py +++ b/openedx/core/djangoapps/password_policy/compliance.py @@ -8,7 +8,7 @@ from django.conf import settings from django.utils.translation import ugettext as _ from util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized -from util.password_policy_validators import validate_password +from util.password_policy_validators import edX_validate_password class NonCompliantPasswordException(Exception): @@ -104,7 +104,7 @@ def _check_user_compliance(user, password): Returns a boolean indicating whether or not the user is compliant with password policy rules. """ try: - validate_password(password, user=user, password_reset=False) + edX_validate_password(password, user=user) return True except Exception: # pylint: disable=broad-except # If anything goes wrong, we should assume the password is not compliant but we don't necessarily diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 50ec3920ff9..25b62aabb3b 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -18,7 +18,7 @@ from student.models import User, UserProfile, Registration, email_exists_or_reti from student import forms as student_forms from student import views as student_views from util.model_utils import emit_setting_changed_event -from util.password_policy_validators import validate_password +from util.password_policy_validators import edX_validate_password from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import errors, accounts, forms, helpers @@ -327,7 +327,7 @@ def create_account(username, password, email): # Validate the username, password, and email # This will raise an exception if any of these are not in a valid format. _validate_username(username) - _validate_password(password, username) + _validate_password(password, username, email) _validate_email(email) # Create the user account, setting them to "inactive" until they activate their account. @@ -494,17 +494,17 @@ def get_confirm_email_validation_error(confirm_email, email): return _validate(_validate_confirm_email, errors.AccountEmailInvalid, confirm_email, email) -def get_password_validation_error(password, username=None): +def get_password_validation_error(password, username=None, email=None): """Get the built-in validation error message for when the password is invalid in some way. :param password: The proposed password (unicode). :param username: The username associated with the user's account (unicode). - :param default: The message to default to in case of no error. + :param email: The email associated with the user's account (unicode). :return: Validation error message. """ - return _validate(_validate_password, errors.AccountPasswordInvalid, password, username) + return _validate(_validate_password, errors.AccountPasswordInvalid, password, username, email) def get_country_validation_error(country): @@ -643,15 +643,17 @@ def _validate_confirm_email(confirm_email, email): raise errors.AccountEmailInvalid(accounts.REQUIRED_FIELD_CONFIRM_EMAIL_MSG) -def _validate_password(password, username=None): +def _validate_password(password, username=None, email=None): """Validate the format of the user's password. Passwords cannot be the same as the username of the account, - so we take `username` as an argument. + so we create a temp_user using the username and email to test the password against. + This user is never saved. Arguments: password (unicode): The proposed password. username (unicode): The username associated with the user's account. + email (unicode): The email associated with the user's account. Returns: None @@ -662,12 +664,12 @@ def _validate_password(password, username=None): """ try: _validate_type(password, basestring, accounts.PASSWORD_BAD_TYPE_MSG) - - validate_password(password, username=username) + temp_user = User(username=username, email=email) if username else None + edX_validate_password(password, user=temp_user) except errors.AccountDataBadType as invalid_password_err: raise errors.AccountPasswordInvalid(text_type(invalid_password_err)) except ValidationError as validation_err: - raise errors.AccountPasswordInvalid(validation_err.message) + raise errors.AccountPasswordInvalid(' '.join(validation_err.messages)) def _validate_country(country): diff --git a/openedx/core/djangoapps/user_api/api.py b/openedx/core/djangoapps/user_api/api.py index 4a321347b09..3a349409d58 100644 --- a/openedx/core/djangoapps/user_api/api.py +++ b/openedx/core/djangoapps/user_api/api.py @@ -18,7 +18,7 @@ from openedx.features.enterprise_support.api import enterprise_customer_for_requ from student.forms import get_registration_extension_form from student.models import UserProfile from util.password_policy_validators import ( - password_complexity, password_instructions, password_max_length, password_min_length + password_validators_instruction_texts, password_validators_restrictions ) @@ -118,9 +118,10 @@ def get_login_session_form(request): "password", label=password_label, field_type="password", - restrictions={ - "max_length": password_max_length(), - } + # The following restriction contains the assumption that the max password length will never exceed 5000 + # characters. The point of this restriction on the login page is to prevent any sort of attacks + # involving sending massive passwords. + restrictions={'max_length': 5000} ) form_desc.add_field( @@ -419,22 +420,12 @@ class RegistrationFormFactory(object): # meant to hold the user's password. password_label = _(u"Password") - restrictions = { - "min_length": password_min_length(), - "max_length": password_max_length(), - } - - complexities = password_complexity() - for key, value in complexities.iteritems(): - api_key = key.lower().replace(' ', '_') - restrictions[api_key] = value - form_desc.add_field( "password", label=password_label, field_type="password", - instructions=password_instructions(), - restrictions=restrictions, + instructions=password_validators_instruction_texts(), + restrictions=password_validators_restrictions(), required=required ) diff --git a/openedx/core/djangoapps/user_api/helpers.py b/openedx/core/djangoapps/user_api/helpers.py index 66f0a9dfc90..60784e66dbc 100644 --- a/openedx/core/djangoapps/user_api/helpers.py +++ b/openedx/core/djangoapps/user_api/helpers.py @@ -125,8 +125,8 @@ class FormDescription(object): ALLOWED_RESTRICTIONS = { "text": ["min_length", "max_length"], - "password": ["min_length", "max_length", "upper", "lower", "digits", "punctuation", "non_ascii", "words", - "numeric", "alphabetic"], + "password": ["min_length", "max_length", "min_upper", "min_lower", + "min_punctuation", "min_symbol", "min_numeric", "min_alphabetic"], "email": ["min_length", "max_length", "readonly"], } diff --git a/openedx/core/djangoapps/user_api/validation/views.py b/openedx/core/djangoapps/user_api/validation/views.py index e5a5a41fedd..16812159a18 100644 --- a/openedx/core/djangoapps/user_api/validation/views.py +++ b/openedx/core/djangoapps/user_api/validation/views.py @@ -144,14 +144,15 @@ class RegistrationValidationView(APIView): return invalid_email_error or email_exists_error def confirm_email_handler(self, request): - email = request.data.get('email', None) + email = request.data.get('email') confirm_email = request.data.get('confirm_email') return get_confirm_email_validation_error(confirm_email, email) def password_handler(self, request): - username = request.data.get('username', None) + username = request.data.get('username') + email = request.data.get('email') password = request.data.get('password') - return get_password_validation_error(password, username) + return get_password_validation_error(password, username, email) def country_handler(self, request): country = request.data.get('country') -- GitLab