-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a composition password validator
Checks whether a passwor has M digits, N uppercase letters, O lowercase letters, P special characters and can set which special characters are looked for. M, N, O and P er all implicitly set to 1 if not overridden.
- Loading branch information
Showing
2 changed files
with
174 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import re | ||
|
||
from django.core.exceptions import ValidationError | ||
|
||
|
||
class CompositionValidator: | ||
DEFAULT_SPECIAL_CHARACTERS = '-=_+,.; :!@#$%&*' | ||
MAPPING = { | ||
'min_numeric': { | ||
'pattern': r'[0-9]', | ||
'help_singular': '%i digit', | ||
'help_plural': '%i digits', | ||
}, | ||
'min_upper': { | ||
'pattern': r'[A-Z]', | ||
'help_singular': '%i uppercase letter', | ||
'help_plural': '%i uppercase letters', | ||
}, | ||
'min_lower': { | ||
'pattern': r'[a-z]', | ||
'help_singular': '%i lowercase letter', | ||
'help_plural': '%i lowercase letters', | ||
}, | ||
'min_special': { | ||
'pattern': None, | ||
'help_singular': '%i special character from the following: %%s', | ||
'help_plural': '%i special characters from the following: %%s', | ||
}, | ||
} | ||
|
||
def __init__( | ||
self, | ||
min_numeric=1, | ||
min_upper=1, | ||
min_lower=1, | ||
min_special=1, | ||
special_characters=DEFAULT_SPECIAL_CHARACTERS, | ||
): | ||
self.check_mapping = {} | ||
self.special_characters = special_characters | ||
self._build_check_mapping_item('min_numeric', int(min_numeric)) | ||
self._build_check_mapping_item('min_upper', int(min_upper)) | ||
self._build_check_mapping_item('min_lower', int(min_lower)) | ||
self._build_check_mapping_item('min_special', int(min_special)) | ||
|
||
def validate(self, password, user=None): | ||
errors = [] | ||
for name, value in self.check_mapping.items(): | ||
pattern = self.MAPPING[name]['pattern'] | ||
required = value['required'] | ||
if name == 'min_special': | ||
pattern = r'[' + self.special_characters + ']' | ||
found = re.findall(pattern, password) | ||
if len(found) >= required: | ||
continue | ||
# not found | ||
errors.append(name) | ||
if errors: | ||
error_msg = self._build_error_msg(errors) | ||
raise ValidationError( | ||
'Invalid password, must have at least ' + error_msg, | ||
code='password_is_insufficiently_complex', | ||
) | ||
|
||
def get_help_text(self): | ||
msg = "The password needs to contain at least: " | ||
help_texts = [v['help_text'] for v in self.check_mapping.values()] | ||
if len(self.check_mapping) == 1: | ||
return msg + help_texts[-1] | ||
return msg + ', '.join(help_texts[:-1]) + ' and ' + help_texts[-1] | ||
|
||
def _build_check_mapping_item(self, name, count): | ||
if not count: | ||
return | ||
if name == 'min_special' and not self.special_characters: | ||
return | ||
self.check_mapping[name] = {'required': count} | ||
if count == 1: | ||
help_text = self.MAPPING[name]['help_singular'] | ||
else: | ||
help_text = self.MAPPING[name]['help_plural'] | ||
help_text = help_text % count | ||
if name == 'min_special': | ||
help_text = help_text % self.special_characters | ||
self.check_mapping[name]['help_text'] = help_text | ||
|
||
def _build_error_msg(self, errors): | ||
error_msgs = [] | ||
for error in errors: | ||
error_msgs.append(self.check_mapping[error]['help_text']) | ||
if len(errors) == 1: | ||
return error_msgs[0] | ||
return ' '.join(error_msgs[:-1]) + ' and ' + error_msgs[-1] | ||
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
from django.core.exceptions import ValidationError | ||
|
||
from nav.web.auth.password_validation import CompositionValidator | ||
|
||
|
||
def test_init_with_no_args_builds_default_check_mapping(): | ||
default_check_mapping = { | ||
'min_numeric': { | ||
'required': 1, | ||
'help_text': '1 digit', | ||
}, | ||
'min_upper': { | ||
'required': 1, | ||
'help_text': '1 uppercase letter', | ||
}, | ||
'min_lower': { | ||
'required': 1, | ||
'help_text': '1 lowercase letter', | ||
}, | ||
'min_special': { | ||
'required': 1, | ||
'help_text': ( | ||
'1 special character from the following: %s' | ||
% CompositionValidator.DEFAULT_SPECIAL_CHARACTERS | ||
), | ||
}, | ||
} | ||
cv = CompositionValidator() | ||
assert cv.check_mapping == default_check_mapping, 'Check mapping was built wrong' | ||
|
||
|
||
def test_init_with_int_args_as_zero_builds_empty_check_mapping(): | ||
cv = CompositionValidator(min_numeric=0, min_upper=0, min_lower=0, min_special=0) | ||
assert cv.check_mapping == {}, 'Check mapping is not empty' | ||
|
||
|
||
def test_init_with_empty_special_characters_menas_nop_special_check(): | ||
cv = CompositionValidator(special_characters="") | ||
assert ( | ||
'min_special' not in cv.check_mapping | ||
), "Check mapping was built wrong, special check should not have been included" | ||
|
||
|
||
def test_get_help_text_with_one_required_check_does_not_contain_and_or_comma(): | ||
cv = CompositionValidator(min_upper=0, min_lower=0, min_special=0) | ||
help_text = cv.get_help_text() | ||
assert 'and' not in help_text, 'Help text for a single check is wrong, has "and"' | ||
assert ',' not in help_text, 'Help text for a single check is wrong, has comma' | ||
|
||
|
||
def test_get_help_text_with_two_or_more_required_check_always_contains_and_and_may_contain_comma(): | ||
cv = CompositionValidator(min_lower=0, min_special=0) | ||
help_text = cv.get_help_text() | ||
assert 'and' in help_text, 'Help text for two checks is wrongi, lacks "and"' | ||
cv = CompositionValidator(min_special=0) | ||
help_text = cv.get_help_text() | ||
assert 'and' in help_text, 'Help text for three checks is wrong, lacks "and"' | ||
assert ',' in help_text, 'Help text for three checks is wrong, lacks comma' | ||
cv = CompositionValidator() | ||
help_text = cv.get_help_text() | ||
assert 'and' in help_text, 'Help text for four checks is wrong' | ||
assert ',' in help_text, 'Help text for four checks is wrong, lacks comma' | ||
|
||
|
||
def test_validate_with_correct_password_returns_None(): | ||
cv = CompositionValidator(min_upper=0, min_lower=0, min_special=0) | ||
result = cv.validate("42") | ||
assert result is None, "The password did not validate but should" | ||
|
||
|
||
def test_validate_with_incorrect_password_returns_ValidationError_with_error_message(): | ||
cv = CompositionValidator(min_upper=0, min_lower=0, min_special=0) | ||
try: | ||
cv.validate("") | ||
except ValidationError as e: | ||
expected_error = 'Invalid password, must have at least 1 digit' | ||
assert ( | ||
e.message == expected_error | ||
), "Error message of incorrect password was wrong" | ||
else: | ||
assert False, "Incorrect password did not raise ValidationError" |