Added in tests for the new password validation. Fixed old tests that
relied on the old configuration values and old way of validating passwords. Also improved registration page by always showing error messages rather than hiding them on leaving the field.
This commit is contained in:
@@ -10,17 +10,45 @@ import unicodedata
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.password_validation import (
|
||||
get_default_password_validators,
|
||||
validate_password,
|
||||
validate_password as django_validate_password,
|
||||
MinimumLengthValidator as DjangoMinimumLengthValidator,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext as _, ungettext
|
||||
from six import text_type
|
||||
|
||||
from student.models import PasswordHistory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The following constant contains the assumption that the max password length will never exceed 5000
|
||||
# characters. The point of this restriction is to restrict the login page password field to prevent
|
||||
# any sort of attacks involving sending massive passwords.
|
||||
DEFAULT_MAX_PASSWORD_LENGTH = 5000
|
||||
|
||||
|
||||
def create_validator_config(name, options={}):
|
||||
"""
|
||||
This function is meant to be used for testing purposes to create validators
|
||||
easily. It returns a validator config of the form:
|
||||
{
|
||||
"NAME": "util.password_policy_validators.SymbolValidator",
|
||||
"OPTIONS": {"min_symbol": 1}
|
||||
}
|
||||
|
||||
Parameters:
|
||||
name (str): the path name to the validator class to instantiate
|
||||
options (dict): The dictionary of options to pass in to the validator.
|
||||
These are used to initialize the validator with parameters.
|
||||
If undefined, the default parameters will be used.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the NAME and OPTIONS for the validator. These will
|
||||
be used to instantiate an instance of the validator using Django.
|
||||
"""
|
||||
if options:
|
||||
return {'NAME': name, 'OPTIONS': options}
|
||||
|
||||
return {'NAME': name}
|
||||
|
||||
|
||||
def password_validators_instruction_texts():
|
||||
"""
|
||||
@@ -61,7 +89,7 @@ def password_validators_restrictions():
|
||||
return complexity_restrictions
|
||||
|
||||
|
||||
def edX_validate_password(password, user=None):
|
||||
def validate_password(password, user=None):
|
||||
"""
|
||||
EdX's custom password validator for passwords. This function performs the
|
||||
following functions:
|
||||
@@ -89,7 +117,7 @@ def edX_validate_password(password, user=None):
|
||||
# no reason to get into weeds
|
||||
raise ValidationError([_('Invalid password.')])
|
||||
|
||||
validate_password(password, user)
|
||||
django_validate_password(password, user)
|
||||
|
||||
|
||||
def _validate_condition(password, fn, min_count):
|
||||
@@ -177,8 +205,8 @@ class AlphabeticValidator(object):
|
||||
return
|
||||
raise ValidationError(
|
||||
ungettext(
|
||||
'Your password must contain at least %(min_alphabetic)d letter.',
|
||||
'Your password must contain at least %(min_alphabetic)d letters.',
|
||||
'This password must contain at least %(min_alphabetic)d letter.',
|
||||
'This password must contain at least %(min_alphabetic)d letters.',
|
||||
self.min_alphabetic
|
||||
),
|
||||
code='too_few_alphabetic_char',
|
||||
@@ -225,8 +253,8 @@ class NumericValidator(object):
|
||||
return
|
||||
raise ValidationError(
|
||||
ungettext(
|
||||
'Your password must contain at least %(min_numeric)d number.',
|
||||
'Your password must contain at least %(min_numeric)d numbers.',
|
||||
'This password must contain at least %(min_numeric)d number.',
|
||||
'This password must contain at least %(min_numeric)d numbers.',
|
||||
self.min_numeric
|
||||
),
|
||||
code='too_few_numeric_char',
|
||||
@@ -273,8 +301,8 @@ class UppercaseValidator(object):
|
||||
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.',
|
||||
'This password must contain at least %(min_upper)d uppercase letter.',
|
||||
'This password must contain at least %(min_upper)d uppercase letters.',
|
||||
self.min_upper
|
||||
),
|
||||
code='too_few_uppercase_char',
|
||||
@@ -321,8 +349,8 @@ class LowercaseValidator(object):
|
||||
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.',
|
||||
'This password must contain at least %(min_lower)d lowercase letter.',
|
||||
'This password must contain at least %(min_lower)d lowercase letters.',
|
||||
self.min_lower
|
||||
),
|
||||
code='too_few_lowercase_char',
|
||||
@@ -355,11 +383,11 @@ class LowercaseValidator(object):
|
||||
|
||||
class PunctuationValidator(object):
|
||||
"""
|
||||
Validate whether the password contains at least min_punctuation punctuation characters
|
||||
Validate whether the password contains at least min_punctuation punctuation marks
|
||||
as defined by unicode categories.
|
||||
|
||||
Parameters:
|
||||
min_punctuation (int): the minimum number of punctuation characters to require
|
||||
min_punctuation (int): the minimum number of punctuation marks to require
|
||||
in the password. Must be >= 0.
|
||||
"""
|
||||
def __init__(self, min_punctuation=0):
|
||||
@@ -370,8 +398,8 @@ class PunctuationValidator(object):
|
||||
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.',
|
||||
'This password must contain at least %(min_punctuation)d punctuation mark.',
|
||||
'This password must contain at least %(min_punctuation)d punctuation marks.',
|
||||
self.min_punctuation
|
||||
),
|
||||
code='too_few_punctuation_characters',
|
||||
@@ -380,16 +408,16 @@ class PunctuationValidator(object):
|
||||
|
||||
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.",
|
||||
"Your password must contain at least %(min_punctuation)d punctuation mark.",
|
||||
"Your password must contain at least %(min_punctuation)d punctuation marks.",
|
||||
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',
|
||||
'%(num)d punctuation mark',
|
||||
'%(num)d punctuation marks',
|
||||
self.min_punctuation
|
||||
) % {'num': self.min_punctuation}
|
||||
else:
|
||||
@@ -418,8 +446,8 @@ class SymbolValidator(object):
|
||||
return
|
||||
raise ValidationError(
|
||||
ungettext(
|
||||
'Your password must contain at least %(min_symbol)d symbol.',
|
||||
'Your password must contain at least %(min_symbol)d symbols.',
|
||||
'This password must contain at least %(min_symbol)d symbol.',
|
||||
'This password must contain at least %(min_symbol)d symbols.',
|
||||
self.min_symbol
|
||||
),
|
||||
code='too_few_symbols',
|
||||
|
||||
@@ -6,37 +6,44 @@ import unittest
|
||||
|
||||
from ddt import data, ddt, unpack
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from util.password_policy_validators import (
|
||||
password_instructions, password_min_length, validate_password, _validate_password_dictionary
|
||||
create_validator_config, validate_password, password_validators_instruction_texts,
|
||||
)
|
||||
|
||||
|
||||
@ddt
|
||||
class PasswordPolicyValidatorsTestCase(unittest.TestCase):
|
||||
""" Tests for password validator utility functions """
|
||||
"""
|
||||
Tests for password validator utility functions
|
||||
|
||||
@override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=2)
|
||||
@override_settings(PASSWORD_DICTIONARY=['testme'])
|
||||
@mock.patch.dict(settings.FEATURES, {'ENFORCE_PASSWORD_POLICY': True})
|
||||
def test_validate_password_dictionary(self):
|
||||
""" Tests dictionary checks """
|
||||
# Direct match
|
||||
with self.assertRaises(ValidationError):
|
||||
_validate_password_dictionary(u'testme')
|
||||
The general framework I went with for testing the validators was to test:
|
||||
1) requiring a single check (also checks proper singular message)
|
||||
2) requiring multiple instances of the check (also checks proper plural message)
|
||||
3) successful check
|
||||
"""
|
||||
|
||||
# Off by one
|
||||
with self.assertRaises(ValidationError):
|
||||
_validate_password_dictionary(u'estme')
|
||||
def validation_errors_checker(self, password, msg, user=None):
|
||||
"""
|
||||
This helper function is used to check the proper error messages are
|
||||
being displayed based on the password and validator.
|
||||
|
||||
# Off by two
|
||||
with self.assertRaises(ValidationError):
|
||||
_validate_password_dictionary(u'bestmet')
|
||||
|
||||
# Off by three (should pass)
|
||||
_validate_password_dictionary(u'bestem')
|
||||
Parameters:
|
||||
password (unicode): the password to validate on
|
||||
user (django.contrib.auth.models.User): user object to use in validation.
|
||||
This is an optional parameter unless the validator requires a
|
||||
user object.
|
||||
msg (str): The expected ValidationError message
|
||||
"""
|
||||
if msg is None:
|
||||
validate_password(password, user)
|
||||
else:
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
validate_password(password, user)
|
||||
self.assertIn(msg, ' '.join(cm.exception.messages))
|
||||
|
||||
def test_unicode_password(self):
|
||||
""" Tests that validate_password enforces unicode """
|
||||
@@ -46,45 +53,193 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase):
|
||||
# Sanity checks and demonstration of why this test is useful
|
||||
self.assertEqual(len(byte_str), 4)
|
||||
self.assertEqual(len(unicode_str), 1)
|
||||
self.assertEqual(password_min_length(), 2)
|
||||
|
||||
# Test length check
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_password(byte_str)
|
||||
validate_password(byte_str + byte_str)
|
||||
self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 2 characters.')
|
||||
self.validation_errors_checker(byte_str + byte_str, None)
|
||||
|
||||
# Test badly encoded password
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
validate_password(b'\xff\xff')
|
||||
self.assertEquals('Invalid password.', cm.exception.message)
|
||||
self.validation_errors_checker(b'\xff\xff', 'Invalid password.')
|
||||
|
||||
@data(
|
||||
(u'', 'at least 2 characters & 2 letters & 1 number.'),
|
||||
(u'a.', 'at least 2 letters & 1 number.'),
|
||||
(u'a1', 'at least 2 letters.'),
|
||||
(u'aa1', None),
|
||||
([create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 2})],
|
||||
'at least 2 characters.'),
|
||||
|
||||
([
|
||||
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 2}),
|
||||
create_validator_config('util.password_policy_validators.AlphabeticValidator', {'min_alphabetic': 2}),
|
||||
], 'characters, including 2 letters.'),
|
||||
|
||||
([
|
||||
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 2}),
|
||||
create_validator_config('util.password_policy_validators.AlphabeticValidator', {'min_alphabetic': 2}),
|
||||
create_validator_config('util.password_policy_validators.NumericValidator', {'min_numeric': 1}),
|
||||
], 'characters, including 2 letters & 1 number.'),
|
||||
|
||||
([
|
||||
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 2}),
|
||||
create_validator_config('util.password_policy_validators.UppercaseValidator', {'min_upper': 3}),
|
||||
create_validator_config('util.password_policy_validators.NumericValidator', {'min_numeric': 1}),
|
||||
create_validator_config('util.password_policy_validators.SymbolValidator', {'min_symbol': 2}),
|
||||
], 'including 3 uppercase letters & 1 number & 2 symbols.'),
|
||||
)
|
||||
@unpack
|
||||
@override_settings(PASSWORD_COMPLEXITY={'ALPHABETIC': 2, 'NUMERIC': 1})
|
||||
@mock.patch.dict(settings.FEATURES, {'ENFORCE_PASSWORD_POLICY': True})
|
||||
def test_validation_errors(self, password, msg):
|
||||
""" Tests validate_password error messages """
|
||||
if msg is None:
|
||||
validate_password(password)
|
||||
else:
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
validate_password(password)
|
||||
self.assertIn(msg, cm.exception.message)
|
||||
def test_password_instructions(self, config, msg):
|
||||
""" Tests password instructions """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.assertIn(msg, password_validators_instruction_texts())
|
||||
|
||||
@data(
|
||||
({}, 'at least 2 characters.'),
|
||||
({'ALPHABETIC': 2}, 'characters, including 2 letters.'),
|
||||
({'ALPHABETIC': 2, 'NUMERIC': 1}, 'characters, including 2 letters & 1 number.'),
|
||||
({'NON ASCII': 2, 'NUMERIC': 1, 'UPPER': 3}, 'including 3 uppercase letters & 1 number & 2 symbols.'),
|
||||
(u'userna', u'username', 'test@example.com', 'The password is too similar to the username.'),
|
||||
(u'password', u'username', 'password@example.com', 'The password is too similar to the email address.'),
|
||||
(u'password', u'username', 'test@password.com', 'The password is too similar to the email address.'),
|
||||
(u'password', u'username', 'test@example.com', None),
|
||||
)
|
||||
@unpack
|
||||
@mock.patch.dict(settings.FEATURES, {'ENFORCE_PASSWORD_POLICY': True})
|
||||
def test_password_instruction(self, config, msg):
|
||||
""" Tests password_instruction """
|
||||
with override_settings(PASSWORD_COMPLEXITY=config):
|
||||
self.assertIn(msg, password_instructions())
|
||||
@override_settings(AUTH_PASSWORD_VALIDATORS=[
|
||||
create_validator_config('django.contrib.auth.password_validation.UserAttributeSimilarityValidator')
|
||||
])
|
||||
def test_user_attribute_similarity_validation_errors(self, password, username, email, msg):
|
||||
""" Tests validate_password error messages for the UserAttributeSimilarityValidator """
|
||||
user = User(username=username, email=email)
|
||||
self.validation_errors_checker(password, msg, user)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 1})],
|
||||
u'', 'This password is too short. It must contain at least 1 character.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 8})],
|
||||
u'd', 'This password is too short. It must contain at least 8 characters.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 8})],
|
||||
u'longpassword', None),
|
||||
)
|
||||
@unpack
|
||||
def test_minimum_length_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the MinimumLengthValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.MaximumLengthValidator', {'max_length': 1})],
|
||||
u'longpassword', 'This password is too long. It must contain no more than 1 character.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.MaximumLengthValidator', {'max_length': 10})],
|
||||
u'longpassword', 'This password is too long. It must contain no more than 10 characters.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.MaximumLengthValidator', {'max_length': 20})],
|
||||
u'shortpassword', None),
|
||||
)
|
||||
@unpack
|
||||
def test_maximum_length_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the MaximumLengthValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
(u'password', 'This password is too common.'),
|
||||
(u'good_password', None),
|
||||
)
|
||||
@unpack
|
||||
@override_settings(AUTH_PASSWORD_VALIDATORS=[
|
||||
create_validator_config('django.contrib.auth.password_validation.CommonPasswordValidator')
|
||||
])
|
||||
def test_common_password_validation_errors(self, password, msg):
|
||||
""" Tests validate_password error messages for the CommonPasswordValidator """
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.AlphabeticValidator', {'min_alphabetic': 1})],
|
||||
u'12345', 'This password must contain at least 1 letter.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.AlphabeticValidator', {'min_alphabetic': 5})],
|
||||
u'test123', 'This password must contain at least 5 letters.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.AlphabeticValidator', {'min_alphabetic': 2})],
|
||||
u'password', None),
|
||||
)
|
||||
@unpack
|
||||
def test_alphabetic_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the AlphabeticValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.NumericValidator', {'min_numeric': 1})],
|
||||
u'test', 'This password must contain at least 1 number.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.NumericValidator', {'min_numeric': 4})],
|
||||
u'test123', 'This password must contain at least 4 numbers.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.NumericValidator', {'min_numeric': 2})],
|
||||
u'password123', None),
|
||||
)
|
||||
@unpack
|
||||
def test_numeric_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the NumericValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.UppercaseValidator', {'min_upper': 1})],
|
||||
u'lowercase', 'This password must contain at least 1 uppercase letter.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.UppercaseValidator', {'min_upper': 6})],
|
||||
u'NOTenough', 'This password must contain at least 6 uppercase letters.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.UppercaseValidator', {'min_upper': 1})],
|
||||
u'camelCase', None),
|
||||
)
|
||||
@unpack
|
||||
def test_upper_case_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the UppercaseValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.LowercaseValidator', {'min_lower': 1})],
|
||||
u'UPPERCASE', 'This password must contain at least 1 lowercase letter.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.LowercaseValidator', {'min_lower': 4})],
|
||||
u'notENOUGH', 'This password must contain at least 4 lowercase letters.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.LowercaseValidator', {'min_lower': 1})],
|
||||
u'goodPassword', None),
|
||||
)
|
||||
@unpack
|
||||
def test_lower_case_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the LowercaseValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.PunctuationValidator', {'min_punctuation': 1})],
|
||||
u'no punctuation', 'This password must contain at least 1 punctuation mark.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.PunctuationValidator', {'min_punctuation': 7})],
|
||||
u'p@$$w0rd$!', 'This password must contain at least 7 punctuation marks.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.PunctuationValidator', {'min_punctuation': 3})],
|
||||
u'excl@m@t!on', None),
|
||||
)
|
||||
@unpack
|
||||
def test_punctuation_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the PunctuationValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
@data(
|
||||
([create_validator_config('util.password_policy_validators.SymbolValidator', {'min_symbol': 1})],
|
||||
u'no symbol', 'This password must contain at least 1 symbol.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.SymbolValidator', {'min_symbol': 3})],
|
||||
u'☹️boo☹️', 'This password must contain at least 3 symbols.'),
|
||||
|
||||
([create_validator_config('util.password_policy_validators.SymbolValidator', {'min_symbol': 2})],
|
||||
u'☪symbols!☹️', None),
|
||||
)
|
||||
@unpack
|
||||
def test_symbol_validation_errors(self, config, password, msg):
|
||||
""" Tests validate_password error messages for the SymbolValidator """
|
||||
with override_settings(AUTH_PASSWORD_VALIDATORS=config):
|
||||
self.validation_errors_checker(password, msg)
|
||||
|
||||
Reference in New Issue
Block a user