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.
This commit is contained in:
Dillon Dumesnil
2018-09-13 15:28:21 -04:00
parent 40918f36d8
commit 4fa27f98dc
11 changed files with 441 additions and 332 deletions

View File

@@ -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) MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60)
#### PASSWORD POLICY SETTINGS ##### #### PASSWORD POLICY SETTINGS #####
PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") AUTH_PASSWORD_VALIDATORS = ENV_TOKENS.get("AUTH_PASSWORD_VALIDATORS", [])
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", [])
### INACTIVITY SETTINGS #### ### INACTIVITY SETTINGS ####
SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS") SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS")

View File

@@ -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 openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from student.message_types import PasswordReset from student.message_types import PasswordReset
from student.models import CourseEnrollmentAllowed, email_exists_or_retired 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): 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") _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") _NAME_TOO_SHORT_MSG = _("Your legal name must be a minimum of two characters long")
# TODO: Resolve repetition # TODO: Resolve repetition
@@ -209,15 +208,9 @@ class AccountCreationForm(forms.Form):
"max_length": _("Email cannot be more than %(limit_value)s characters long"), "max_length": _("Email cannot be more than %(limit_value)s characters long"),
} }
) )
password = forms.CharField(
min_length=password_min_length(), password = forms.CharField()
max_length=password_max_length(),
error_messages={
"required": _PASSWORD_INVALID_MSG,
"min_length": _PASSWORD_INVALID_MSG,
"max_length": _PASSWORD_INVALID_MSG,
}
)
name = forms.CharField( name = forms.CharField(
min_length=accounts_settings.NAME_MIN_LENGTH, min_length=accounts_settings.NAME_MIN_LENGTH,
error_messages={ error_messages={
@@ -288,7 +281,12 @@ class AccountCreationForm(forms.Form):
"""Enforce password policies (if applicable)""" """Enforce password policies (if applicable)"""
password = self.cleaned_data["password"] password = self.cleaned_data["password"]
if self.enforce_password_policy: 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 return password
def clean_email(self): def clean_email(self):

View File

@@ -94,7 +94,7 @@ from student.text_me_the_app import TextMeTheAppFragmentView
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.db import outer_atomic from util.db import outer_atomic
from util.json_request import JsonResponse 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") log = logging.getLogger("edx.student")
@@ -830,7 +830,7 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
password = request.POST['new_password1'] password = request.POST['new_password1']
try: try:
validate_password(password, user=user) edX_validate_password(password, user=user)
except ValidationError as err: except ValidationError as err:
# We have a password reset attempt which violates some security # We have a password reset attempt which violates some security
# policy, or any other validation. Use the existing Django template to communicate that # policy, or any other validation. Use the existing Django template to communicate that

View File

@@ -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 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 logging
import string
import unicodedata import unicodedata
from django.conf import settings 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.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext as _, ungettext
from django.utils.translation import ungettext_lazy as ungettext
from Levenshtein import distance
from six import text_type from six import text_type
from student.models import PasswordHistory from student.models import PasswordHistory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# In description order
_allowed_password_complexity = [
'ALPHABETIC',
'UPPER',
'LOWER',
'NUMERIC',
'DIGITS',
'PUNCTUATION',
'NON ASCII',
'WORDS',
]
def password_validators_instruction_texts():
class SecurityPolicyError(ValidationError):
pass
def password_min_length():
""" """
Returns minimum required length of a password. Return a string of instruction texts of all configured validators.
Can be overridden by site configuration of PASSWORD_MIN_LENGTH. Expects at least the MinimumLengthValidator to be defined.
""" """
min_length = getattr(settings, 'PASSWORD_MIN_LENGTH', None) complexity_instructions = []
if min_length is None: # For clarity in the printed instructions, the minimum length instruction
return 2 # Note: This default is simply historical # is separated from the complexity instructions.
return min_length length_instruction = ''
password_validators = get_default_password_validators()
for validator in password_validators:
def password_max_length(): if hasattr(validator, 'get_instruction_text'):
""" text = validator.get_instruction_text()
Returns maximum allowed length of a password. If zero, no maximum. if isinstance(validator, MinimumLengthValidator):
Can be overridden by site configuration of PASSWORD_MAX_LENGTH. length_instruction = text
""" else:
# Note: The default value here is simply historical complexity_instructions.append(text)
max_length = getattr(settings, 'PASSWORD_MAX_LENGTH', None) if complexity_instructions:
if max_length is None: return _('Your password must contain {length_instruction}, including {complexity_instructions}.').format(
return 75 # Note: This default is simply historical length_instruction=length_instruction,
return max_length complexity_instructions=' & '.join(complexity_instructions)
)
def password_complexity():
"""
:return: A dict of complexity requirements from settings
"""
complexity = {}
if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False):
complexity = getattr(settings, 'PASSWORD_COMPLEXITY', {})
valid_complexity = {x: y for x, y in complexity.iteritems() if x in _allowed_password_complexity}
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 valid_complexity
# 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 _password_complexity_descriptions(which=None):
"""
which: A list of which complexities to describe, None if you want the configured ones
:return: A list of complexity descriptions
"""
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))
return descs
def password_instructions():
"""
:return: A string suitable for display to the user to tell them what password to enter
"""
min_length = password_min_length()
reqs = _password_complexity_descriptions()
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: else:
return ungettext('Your password must contain at least {num} character, including {requirements}.', return _('Your password must contain {length_instruction}.'.format(length_instruction=length_instruction))
'Your password must contain at least {num} characters, including {requirements}.',
min_length).format(num=min_length, requirements=' & '.join(reqs))
def validate_password(password, user=None, username=None, password_reset=True): def password_validators_restrictions():
""" """
Checks user-provided password against our current site policy. Return a dictionary of complexity restrictions to be used by mobile users on
the registration form
"""
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
Raises a ValidationError or SecurityPolicyError depending on the type of error.
Arguments: def edX_validate_password(password, user=None):
password: The user-provided password as a string """
user: A User model object, if available. Required to check against security policy. EdX's custom password validator for passwords. This function performs the
username: The user-provided username, if available. Taken from 'user' if not provided. following functions:
password_reset: Whether to run validators that only make sense in a password reset 1) Converts the password to unicode if it is not already
context (like PasswordHistory). 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.
""" """
if not isinstance(password, text_type): if not isinstance(password, text_type):
try: try:
password = text_type(password, encoding='utf8') # some checks rely on unicode semantics (e.g. length) # some checks rely on unicode semantics (e.g. length)
password = text_type(password, encoding='utf8')
except UnicodeDecodeError: except UnicodeDecodeError:
raise ValidationError(_('Invalid password.')) # no reason to get into weeds # no reason to get into weeds
raise ValidationError([_('Invalid password.')])
username = username or (user and user.username) validate_password(password, user)
if user and password_reset:
_validate_password_security(password, user)
_validate_password_dictionary(password)
_validate_password_against_username(password, username)
# 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)
if errors:
msg = _('Enter a password with at least {requirements}.').format(requirements=' & '.join(errors))
raise ValidationError(msg)
def _validate_password_security(password, user): def _validate_condition(password, fn, min_count):
""" """
Check password reuse and similar operational security policy considerations. 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.
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
Return:
True if valid_count >= min_count, else False
""" """
# Check reuse valid_count = len([c for c in password if fn(c)])
if not PasswordHistory.is_allowable_password_reuse(user, password): return valid_count >= min_count
if user.is_staff:
num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE']
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): class MinimumLengthValidator(DjangoMinimumLengthValidator):
""" def get_instruction_text(self):
Validator that enforces minimum length of a password
"""
min_length = password_min_length()
max_length = password_max_length()
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( return ungettext(
"{num} character", 'at least %(min_length)d character',
"{num} characters", 'at least %(min_length)d characters',
min_length self.min_length
).format(num=min_length) ) % {'min_length': self.min_length}
elif max_length and len(value) > max_length:
raise ValidationError(ungettext( def get_restriction(self):
"Enter a password with at most {num} character.", """
"Enter a password with at most {num} characters.", Returns a key, value pair for the restrictions related to the Validator
max_length """
).format(num=max_length)) return 'min_length', self.min_length
def _validate_password_complexity(value): class MaximumLengthValidator(object):
""" """
Validator that enforces minimum complexity Validate whether the password is shorter than a maximum length.
Parameters:
max_length (int): the maximum number of characters to require in the password.
""" """
complexities = password_complexity() def __init__(self, max_length=75):
if not complexities: self.max_length = max_length
return []
# Sets are here intentionally def validate(self, password, user=None):
uppercase, lowercase, digits, non_ascii, punctuation = set(), set(), set(), set(), set() if len(password) > self.max_length:
alphabetic, numeric = [], [] 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},
)
for character in value: def get_help_text(self):
if character.isupper(): return ungettext(
uppercase.add(character) 'Your password must contain no more than %(max_length)d character.',
elif character.islower(): 'Your password must contain no more than %(max_length)d characters.',
lowercase.add(character) self.max_length
elif character.isdigit(): ) % {'max_length': self.max_length}
digits.add(character)
elif character in string.punctuation: def get_restriction(self):
punctuation.add(character) """
Returns a key, value pair for the restrictions related to the Validator
"""
return 'max_length', self.max_length
class AlphabeticValidator(object):
"""
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.
"""
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: else:
non_ascii.add(character) return ''
if character.isalpha(): def get_restriction(self):
alphabetic.append(character) """
if 'N' in unicodedata.category(character): # Check to see if the unicode category contains a 'N'umber Returns a key, value pair for the restrictions related to the Validator
numeric.append(character) """
return 'min_alphabetic', self.min_alphabetic
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 []
def _validate_password_against_username(password, username): class NumericValidator(object):
if not username:
return
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):
""" """
Insures that the password is not too similar to a defined set of dictionary words Validate whether the password contains at least min_numeric numbers.
Parameters:
min_numeric (int): the minimum number of numeric characters to require
in the password. Must be >= 0.
""" """
if not settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): def __init__(self, min_numeric=0):
return self.min_numeric = min_numeric
password_max_edit_distance = getattr(settings, "PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD", None) def validate(self, password, user=None):
password_dictionary = getattr(settings, "PASSWORD_DICTIONARY", 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},
)
if password_max_edit_distance and password_dictionary: def get_help_text(self):
for word in password_dictionary: return ungettext(
edit_distance = distance(value, text_type(word)) "Your password must contain at least %(min_numeric)d number.",
if edit_distance <= password_max_edit_distance: "Your password must contain at least %(min_numeric)d numbers.",
raise ValidationError(_("Password is too similar to a dictionary word."), self.min_numeric
code="dictionary_word") ) % {'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 ''
def get_restriction(self):
"""
Returns a key, value pair for the restrictions related to the Validator
"""
return 'min_numeric', self.min_numeric
class UppercaseValidator(object):
"""
Validate whether the password contains at least min_upper uppercase letters.
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 ''
def get_restriction(self):
"""
Returns a key, value pair for the restrictions related to the Validator
"""
return 'min_upper', self.min_upper
class LowercaseValidator(object):
"""
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.
"""
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:
return ''
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):
"""
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.
"""
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:
return ''
def get_restriction(self):
"""
Returns a key, value pair for the restrictions related to the Validator
"""
return 'min_punctuation', self.min_punctuation
class SymbolValidator(object):
"""
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.
"""
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

View File

@@ -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) MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60)
#### PASSWORD POLICY SETTINGS ##### #### PASSWORD POLICY SETTINGS #####
PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") AUTH_PASSWORD_VALIDATORS = ENV_TOKENS.get("AUTH_PASSWORD_VALIDATORS", [])
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", [])
### INACTIVITY SETTINGS #### ### INACTIVITY SETTINGS ####
SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS") SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS")

View File

@@ -142,9 +142,6 @@ FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False
FEATURES['SQUELCH_PII_IN_LOGS'] = False FEATURES['SQUELCH_PII_IN_LOGS'] = False
FEATURES['PREVENT_CONCURRENT_LOGINS'] = False FEATURES['PREVENT_CONCURRENT_LOGINS'] = False
FEATURES['ADVANCED_SECURITY'] = False FEATURES['ADVANCED_SECURITY'] = False
PASSWORD_MIN_LENGTH = None
PASSWORD_COMPLEXITY = {}
########################### Milestones ################################# ########################### Milestones #################################
FEATURES['MILESTONES_APP'] = True FEATURES['MILESTONES_APP'] = True

View File

@@ -8,7 +8,7 @@ from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized 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): 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. Returns a boolean indicating whether or not the user is compliant with password policy rules.
""" """
try: try:
validate_password(password, user=user, password_reset=False) edX_validate_password(password, user=user)
return True return True
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
# If anything goes wrong, we should assume the password is not compliant but we don't necessarily # If anything goes wrong, we should assume the password is not compliant but we don't necessarily

View File

@@ -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 forms as student_forms
from student import views as student_views from student import views as student_views
from util.model_utils import emit_setting_changed_event 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.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import errors, accounts, forms, 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 # Validate the username, password, and email
# This will raise an exception if any of these are not in a valid format. # This will raise an exception if any of these are not in a valid format.
_validate_username(username) _validate_username(username)
_validate_password(password, username) _validate_password(password, username, email)
_validate_email(email) _validate_email(email)
# Create the user account, setting them to "inactive" until they activate their account. # 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) 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 """Get the built-in validation error message for when
the password is invalid in some way. the password is invalid in some way.
:param password: The proposed password (unicode). :param password: The proposed password (unicode).
:param username: The username associated with the user's account (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: 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): 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) 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. """Validate the format of the user's password.
Passwords cannot be the same as the username of the account, 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: Arguments:
password (unicode): The proposed password. password (unicode): The proposed password.
username (unicode): The username associated with the user's account. username (unicode): The username associated with the user's account.
email (unicode): The email associated with the user's account.
Returns: Returns:
None None
@@ -662,12 +664,12 @@ def _validate_password(password, username=None):
""" """
try: try:
_validate_type(password, basestring, accounts.PASSWORD_BAD_TYPE_MSG) _validate_type(password, basestring, accounts.PASSWORD_BAD_TYPE_MSG)
temp_user = User(username=username, email=email) if username else None
validate_password(password, username=username) edX_validate_password(password, user=temp_user)
except errors.AccountDataBadType as invalid_password_err: except errors.AccountDataBadType as invalid_password_err:
raise errors.AccountPasswordInvalid(text_type(invalid_password_err)) raise errors.AccountPasswordInvalid(text_type(invalid_password_err))
except ValidationError as validation_err: except ValidationError as validation_err:
raise errors.AccountPasswordInvalid(validation_err.message) raise errors.AccountPasswordInvalid(' '.join(validation_err.messages))
def _validate_country(country): def _validate_country(country):

View File

@@ -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.forms import get_registration_extension_form
from student.models import UserProfile from student.models import UserProfile
from util.password_policy_validators import ( 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", "password",
label=password_label, label=password_label,
field_type="password", field_type="password",
restrictions={ # The following restriction contains the assumption that the max password length will never exceed 5000
"max_length": password_max_length(), # 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( form_desc.add_field(
@@ -419,22 +420,12 @@ class RegistrationFormFactory(object):
# meant to hold the user's password. # meant to hold the user's password.
password_label = _(u"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( form_desc.add_field(
"password", "password",
label=password_label, label=password_label,
field_type="password", field_type="password",
instructions=password_instructions(), instructions=password_validators_instruction_texts(),
restrictions=restrictions, restrictions=password_validators_restrictions(),
required=required required=required
) )

View File

@@ -125,8 +125,8 @@ class FormDescription(object):
ALLOWED_RESTRICTIONS = { ALLOWED_RESTRICTIONS = {
"text": ["min_length", "max_length"], "text": ["min_length", "max_length"],
"password": ["min_length", "max_length", "upper", "lower", "digits", "punctuation", "non_ascii", "words", "password": ["min_length", "max_length", "min_upper", "min_lower",
"numeric", "alphabetic"], "min_punctuation", "min_symbol", "min_numeric", "min_alphabetic"],
"email": ["min_length", "max_length", "readonly"], "email": ["min_length", "max_length", "readonly"],
} }

View File

@@ -144,14 +144,15 @@ class RegistrationValidationView(APIView):
return invalid_email_error or email_exists_error return invalid_email_error or email_exists_error
def confirm_email_handler(self, request): def confirm_email_handler(self, request):
email = request.data.get('email', None) email = request.data.get('email')
confirm_email = request.data.get('confirm_email') confirm_email = request.data.get('confirm_email')
return get_confirm_email_validation_error(confirm_email, email) return get_confirm_email_validation_error(confirm_email, email)
def password_handler(self, request): 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') 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): def country_handler(self, request):
country = request.data.get('country') country = request.data.get('country')