Commit f585d4c3 by Muhammad Shoaib Committed by Jonathan Piacenti

muhhshoaib/security-change-user-password-requirements-685:changed user password requirement

muhhshoaib/security-change-user-password-requirements-685: handled password complexity scenarios messages according to Chris Suggestions

muhhshoaib/security-change-user-password-requirements-685: changed the chracteres storage from sets to lists
parent 7da84af9
......@@ -40,42 +40,87 @@ def validate_password_complexity(value):
code = "complexity"
complexities = getattr(settings, "PASSWORD_COMPLEXITY", None)
min_password_complexity_score = getattr(settings, "MINIMUM_PASSWORD_COMPLEXITY_SCORE", 0)
if complexities is None:
return
uppercase, lowercase, digits, non_ascii, punctuation = set(), set(), set(), set(), set()
uppercase, lowercase, digits, non_ascii, punctuation = [], [], [], [], []
for character in value:
if character.isupper():
uppercase.add(character)
uppercase.append(character)
elif character.islower():
lowercase.add(character)
lowercase.append(character)
elif character.isdigit():
digits.add(character)
digits.append(character)
elif character in string.punctuation:
punctuation.add(character)
punctuation.append(character)
else:
non_ascii.add(character)
non_ascii.append(character)
words = set(value.split())
words = value.split()
password_complexity_score = 0
errors = []
if len(uppercase) < complexities.get("UPPER", 0):
errors.append(_("must contain {0} or more uppercase characters").format(complexities["UPPER"]))
errors.append((_("must contain {0} or more uppercase characters").format(complexities["UPPER"]), complexities.get("UPPER_SCORE", 1)))
elif complexities.get("UPPER", 0) > 0:
password_complexity_score += complexities.get("UPPER_SCORE", 1)
if len(lowercase) < complexities.get("LOWER", 0):
errors.append(_("must contain {0} or more lowercase characters").format(complexities["LOWER"]))
errors.append((_("must contain {0} or more lowercase characters").format(complexities["LOWER"]), complexities.get("LOWER_SCORE", 1)))
elif complexities.get("LOWER", 0) > 0:
password_complexity_score += complexities.get("LOWER_SCORE", 1)
if len(digits) < complexities.get("DIGITS", 0):
errors.append(_("must contain {0} or more digits").format(complexities["DIGITS"]))
errors.append((_("must contain {0} or more digits").format(complexities["DIGITS"]), complexities.get("DIGITS_SCORE", 2)))
elif complexities.get("DIGITS", 0) > 0:
password_complexity_score += complexities.get("DIGITS_SCORE", 2)
if len(punctuation) < complexities.get("PUNCTUATION", 0):
errors.append(_("must contain {0} or more punctuation characters").format(complexities["PUNCTUATION"]))
errors.append((_("must contain {0} or more punctuation characters").format(complexities["PUNCTUATION"]), complexities.get("PUNCTUATION_SCORE", 2)))
elif complexities.get("PUNCTUATION", 0) > 0:
password_complexity_score += complexities.get("PUNCTUATION_SCORE", 2)
if len(non_ascii) < complexities.get("NON ASCII", 0):
errors.append(_("must contain {0} or more non ascii characters").format(complexities["NON ASCII"]))
errors.append((_("must contain {0} or more non ascii characters").format(complexities["NON ASCII"]), complexities.get("NON_ASCII_SCORE", 2)))
elif complexities.get("NON ASCII", 0) > 0:
password_complexity_score += complexities.get("NON_ASCII_SCORE", 2)
if len(words) < complexities.get("WORDS", 0):
errors.append(_("must contain {0} or more unique words").format(complexities["WORDS"]))
errors.append((_("must contain {0} or more unique words").format(complexities["WORDS"]), complexities.get("WORDS_SCORE", 2)))
elif complexities.get("WORDS", 0) > 0:
password_complexity_score += complexities.get("WORDS_SCORE", 2)
if 0 < min_password_complexity_score <= password_complexity_score:
return
if errors:
raise ValidationError(message.format(u', '.join(errors)), code=code)
#show only those errors required to achieve minimum password complexity score
diff = min_password_complexity_score - password_complexity_score
if diff > 0:
filtered_error_messages = get_filtered_messages(diff, errors)
raise ValidationError(message.format(u', '.join(filtered_error_messages)), code=code)
else:
raise ValidationError(message.format(u', '.join(error[0] for error in errors)), code=code)
def get_filtered_messages(diff, errors):
"""
Get Filtered error Messages for password complexity
"""
from operator import itemgetter
weight = 0
# sort the array
sorted_errors = sorted(errors, key=itemgetter(1))
error_messages = []
index = 0
while diff > weight and len(sorted_errors) > index:
error_messages.append(sorted_errors[index][0])
weight += sorted_errors[index][1]
index += 1
return error_messages
def validate_password_dictionary(value):
......
......@@ -145,7 +145,7 @@ class SessionApiSecurityTest(TestCase):
"""
Try (and fail) user creation without any punctuation in password
"""
response, mock_audit_log = self._do_request(self.user_url, 'test', 'test64SS', email='test@edx.org',
response, mock_audit_log = self._do_request(self.user_url, 'test', 'test64Ss', email='test@edx.org', # pylint: disable=W0612
first_name='John', last_name='Doe', secure=True)
message = _('Password: Must be more complex (must contain 2 or more uppercase characters,'
' must contain 2 or more punctuation characters)')
......@@ -156,7 +156,7 @@ class SessionApiSecurityTest(TestCase):
"""
Try (and fail) user creation without any numeric characters in password
"""
response, mock_audit_log = self._do_request(self.user_url, 'test', 'test.paSS!', email='test@edx.org',
response, mock_audit_log = self._do_request(self.user_url, 'test', 'test.paSs!', email='test@edx.org', # pylint: disable=W0612
first_name='John', last_name='Doe', secure=True)
message = _('Password: Must be more complex (must contain 2 or more uppercase characters,'
' must contain 2 or more digits)')
......
......@@ -222,6 +222,232 @@ class UserPasswordResetTest(TestCase):
)
self._assert_response(response, status=200)
@override_settings(MINIMUM_PASSWORD_COMPLEXITY_SCORE=4, PASSWORD_MIN_LENGTH=8,
PASSWORD_COMPLEXITY={
'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2,
'UPPER_SCORE': 1, 'LOWER_SCORE': 1,
'PUNCTUATION_SCORE': 2, 'DIGITS_SCORE': 2
})
def test_minimum_password_complexity_scenarios(self):
"""
Test Password complexity using complex passwords scenarios
"""
# test meet the minimum password criteria
password = 'TESTPass12'
response = self._do_post_request(
self.user_url, 'test', password, email='test@edx.org',
first_name='John', last_name='Doe', secure=True
)
self._assert_response(response, status=201)
# test meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 4)
password = 'AaaaAaaa12'
response = self._do_post_request(
self.user_url, 'test_user', password, email='test_user@edx.org',
first_name='John', last_name='Doe', secure=True
)
self._assert_response(response, status=201)
# test meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 4)
password = '345Aa@$$12'
response = self._do_post_request(
self.user_url, 'test1', password, email='test1@edx.org',
first_name='John1', last_name='Doe1', secure=True
)
self._assert_response(response, status=201)
# test meet the minimum password complexity criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 6)
password = 'ASwe!@543^'
response = self._do_post_request(
self.user_url, 'test2', password, email='test2@edx.org',
first_name='John2', last_name='Doe2', secure=True
)
self._assert_response(response, status=201)
# test will not meet the minimum password complexity criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 2)
password = 'TEstFAIL1'
response = self._do_post_request(
self.user_url, 'test3', password, email='test3@edx.org',
first_name='John3', last_name='Doe3', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more digits)')
self._assert_response(response, status=400, message=message)
# test will not meet the minimum password complexity criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 2)
password = '2314562334s'
response = self._do_post_request(
self.user_url, 'test4', password, email='test4@edx.org',
first_name='John4', last_name='Doe4', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more uppercase characters,'
' must contain 2 or more lowercase characters)')
self._assert_response(response, status=400, message=message)
@override_settings(MINIMUM_PASSWORD_COMPLEXITY_SCORE=8, PASSWORD_MIN_LENGTH=8, PASSWORD_MAX_LENGTH=None,
PASSWORD_COMPLEXITY={
'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2, 'NON ASCII': 1, 'WORDS': 2,
'UPPER_SCORE': 1, 'LOWER_SCORE': 1, 'NON_ASCII_SCORE': 2,
'WORDS_SCORE': 2, 'PUNCTUATION_SCORE': 2, 'DIGITS_SCORE': 2
})
def test_password_minimum_complexity_with_all_password_complex_levels(self):
"""
Test Password Complexity using all password complex scenarios
"""
# test meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (8 <= 8)
password = "TEs\xc2t P@$$"
response = self._do_post_request(
self.user_url, 'test', password, email='test@edx.org',
first_name='John', last_name='Doe', secure=True
)
self._assert_response(response, status=201)
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (8 <= 4)
password = "TEs\xc2tFA1L"
response = self._do_post_request(
self.user_url, 'test1', password, email='test1@edx.org',
first_name='John1', last_name='Doe1', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more digits,'
' must contain 2 or more punctuation characters)')
self._assert_response(response, status=400, message=message)
# test meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (8 <= 10)
password = "TEs\xc2t P@$$123"
response = self._do_post_request(
self.user_url, 'test2', password, email='test2@edx.org',
first_name='John2', last_name='Doe2', secure=True
)
self._assert_response(response, status=201)
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (8 <= 6)
password = "TEstP@$$123"
response = self._do_post_request(
self.user_url, 'test3', password, email='test3@edx.org',
first_name='John3', last_name='Doe3', secure=True
)
message = _('Password: Must be more complex (must contain 1 or more non ascii characters)')
self._assert_response(response, status=400, message=message)
@override_settings(
MINIMUM_PASSWORD_COMPLEXITY_SCORE=10, PASSWORD_MIN_LENGTH=8, PASSWORD_MAX_LENGTH=None,
PASSWORD_COMPLEXITY={
'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2,
'UPPER_SCORE': 1, 'LOWER_SCORE': 1, 'PUNCTUATION_SCORE': 2, 'DIGITS_SCORE': 2
})
def test_minimum_password_complexity_score_greater_than_password_complexity_levels_total_score(self):
"""Test to set the minimum score of password complexity > all the complexity
level scores. Total score of the password complexity levels is 6 and minimum password
complexity score is 10
"""
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (10 <= 4)
password = "TEstP@$$"
response = self._do_post_request(
self.user_url, 'test', password, email='test@edx.org',
first_name='John', last_name='Doe', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more digits)')
self._assert_response(response, status=400, message=message)
# test meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (10 <= 6)
# (10 <= 6 ) is false but the password meet all the complex levels that is enabled in the
# settings and the error dictionary is empty and simply it validates the password because we
# have set the minimum score of password greater that all the complex levels that are enabled
password = "TEstP@$$12"
response = self._do_post_request(
self.user_url, 'test1', password, email='test1@edx.org',
first_name='John1', last_name='Doe1', secure=True
)
self._assert_response(response, status=201)
@override_settings(
MINIMUM_PASSWORD_COMPLEXITY_SCORE=2, PASSWORD_MIN_LENGTH=8, PASSWORD_MAX_LENGTH=None,
PASSWORD_COMPLEXITY={
'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2,
'UPPER_SCORE': 1, 'LOWER_SCORE': 1, 'PUNCTUATION_SCORE': 2, 'DIGITS_SCORE': 2
})
def test_password_complexity_score_less_than_password_complexity_levels_total_score(self):
"""Test to set the minimum score of password complexity < all the complexity
level scores. Total score of the password complexity levels is 6 and minimum password
complexity score is 2
"""
# test meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (2 <= 4)
password = "TEstP@$$"
response = self._do_post_request(
self.user_url, 'test', password, email='test@edx.org',
first_name='John', last_name='Doe', secure=True
)
self._assert_response(response, status=201)
@override_settings(
MINIMUM_PASSWORD_COMPLEXITY_SCORE=4, PASSWORD_MIN_LENGTH=8, PASSWORD_MAX_LENGTH=None,
PASSWORD_COMPLEXITY={
'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2,
'UPPER_SCORE': 2, 'LOWER_SCORE': 2, 'PUNCTUATION_SCORE': 1, 'DIGITS_SCORE': 1
})
def test_password_complexity_setting_different_score_values(self):
"""
Test password complexity using different score values
"""
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 2)
password = "ASDSADASDSAD"
response = self._do_post_request(
self.user_url, 'test', password, email='test@edx.org',
first_name='John', last_name='Doe', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more digits,'
' must contain 2 or more punctuation characters)')
self._assert_response(response, status=400, message=message)
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 1)
password = "1234565778"
response = self._do_post_request(
self.user_url, 'test1', password, email='test1@edx.org',
first_name='John', last_name='Doe', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more punctuation characters,'
' must contain 2 or more uppercase characters)')
self._assert_response(response, status=400, message=message)
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 1)
password = "@$@%%!@%^^#@$"
response = self._do_post_request(
self.user_url, 'test2', password, email='test2@edx.org',
first_name='John', last_name='Doe', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more digits,'
' must contain 2 or more uppercase characters)')
self._assert_response(response, status=400, message=message)
# test will not meet the minimum password criteria
# min_password_complexity_score <= password_complexity_score i.e (4 <= 2)
password = "asdsadasdsadsadsadsdasdasad"
response = self._do_post_request(
self.user_url, 'test2', password, email='test2@edx.org',
first_name='John', last_name='Doe', secure=True
)
message = _('Password: Must be more complex (must contain 2 or more digits,'
' must contain 2 or more punctuation characters)')
self._assert_response(response, status=400, message=message)
def _do_post_request(self, url, username, password, **kwargs):
"""
Post the login info
......
......@@ -526,9 +526,28 @@ MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {})
MICROSITE_ROOT_DIR = path(ENV_TOKENS.get('MICROSITE_ROOT_DIR', ''))
#### PASSWORD POLICY SETTINGS #####
# Minimum number of characters a password should have
# e.g PASSWORD_MIN_LENGTH = 8
PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH")
# Maximum number of characters a password should have
# e.g PASSWORD_MAX_LENGTH = None
PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH")
# Password complexity should be a dict of complexity levels and their corresponding score
# e.g.
# { 'UPPER': 2, 'LOWER': 2, 'PUNCTUATION': 2, 'DIGITS': 2,
# 'UPPER_SCORE': 1, 'LOWER_SCORE': 1, 'PUNCTUATION_SCORE': 2, 'DIGITS_SCORE': 2 }
# if score is not specified for any of complexity level its default value will be used
# UPPER_SCORE has a default value of 1
# LOWER_SCORE has a default value of 1
# PUNCTUATION_SCORE has a default value of 2
# DIGITS_SCORE has a default value of 2
# NON_ASCII_SCORE has a default value of 2
# WORDS_SCORE has a default value of 2
# Default scores are only applicable if relevant complexity level is specified
PASSWORD_COMPLEXITY = ENV_TOKENS.get("PASSWORD_COMPLEXITY", {})
# Minimum score required to fulfill password complexity criteria. defaults to zero
# which means a password should fulfill all levels specified in PASSWORD_COMPLEXITY
MINIMUM_PASSWORD_COMPLEXITY_SCORE = ENV_TOKENS.get('MINIMUM_PASSWORD_COMPLEXITY_SCORE', 0)
PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = ENV_TOKENS.get("PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD")
PASSWORD_DICTIONARY = ENV_TOKENS.get("PASSWORD_DICTIONARY", [])
......
......@@ -313,3 +313,7 @@ except ImportError:
########################## ALLOWED API USER IP ########################
API_ALLOWED_IP_ADDRESSES = ['127.0.0.1']
# Minimum score required to fulfill password complexity criteria. defaults to zero
# which means a password should fulfill all levels specified in PASSWORD_COMPLEXITY
MINIMUM_PASSWORD_COMPLEXITY_SCORE = 0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment