diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 72f8d0ca7ce584accb8b05dd38760af5df44b5df..25d646ed1e4f28b1e477a93ffab82d2cef2fbde9 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 8ca0a6f4c9317056157cd8df5f09de76bf61b29a..c43b10ca495b3e12b65ad42aed4cd967ee8abd9f 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 e01f0fb2a254d694345867241e2c22cdf388774b..47ce54bf309607408d0a8c8be7ec1b46d6ed3f8a 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 d7c0138fb39b52ef7a3411ce7f5759b55feb8556..ded8d0040dd0c7d130fd33365b332d2720897c7e 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 d6ae2d06af6c502950afc340c754fe3fb92b88fe..4f56c9930d82f4a17ece4cfc34a03a74cfe6b0d9 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 70594981c37d179c958d4a1b55773595b866c381..1f811dec70d11ea1ce20b2468c9d4aadc4c5e118 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 2c20364f11b8942930e634325e29f1dd6c2ba936..53326b9957c1d1434a10fa7d38561e40f4741629 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 50ec3920ff9dc42b899f18099ce43ec56c360a5b..25b62aabb3b6c0828d220532314adb9f96e236c1 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 4a321347b09d49473d1a6d31e3a0a6bc344f8e11..3a349409d58e76f7efe9fd04d880ac398c38044c 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 66f0a9dfc90a5c253690e5b52b6dd43a6b53ac9f..60784e66dbca168d66895d844ae974d3d73ac266 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 e5a5a41fedddba3d391cad142318db73772182f7..16812159a186a131b47db7d07c5b32d186bdf5b3 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')