Files
edx-platform/common/djangoapps/util/password_policy_validators.py
Anthony Mangano 07609e7d75 Add password_policy checks to login views
LEARNER-4869
2018-05-04 11:55:57 -04:00

323 lines
12 KiB
Python

"""
This file exposes a number of password complexity 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
import logging
import string
import unicodedata
from django.conf import settings
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 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_min_length():
"""
Returns minimum required length of a password.
Can be overridden by site configuration of PASSWORD_MIN_LENGTH.
"""
min_length = getattr(settings, 'PASSWORD_MIN_LENGTH', None)
if min_length is None:
return 2 # Note: This default is simply historical
return min_length
def password_max_length():
"""
Returns maximum allowed length of a password. If zero, no maximum.
Can be overridden by site configuration of PASSWORD_MAX_LENGTH.
"""
# 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
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:
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 validate_password(password, user=None, username=None, password_reset=True):
"""
Checks user-provided password against our current site policy.
Raises a ValidationError or SecurityPolicyError depending on the type of error.
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).
"""
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
username = username or (user and user.username)
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):
"""
Check password reuse and similar operational security policy considerations.
"""
# 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']
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()
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):
"""
Validator that enforces minimum complexity
"""
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)
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 []
def _validate_password_against_username(password, username):
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
"""
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")