From 322675199d8d4061b8936419e0405d2c703897d3 Mon Sep 17 00:00:00 2001 From: Hanne Moa Date: Wed, 6 Mar 2024 10:41:33 +0100 Subject: [PATCH] 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. --- python/nav/web/auth/password_validation.py | 93 +++++++++++++++++++ .../unittests/web/password_validation_test.py | 81 ++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 python/nav/web/auth/password_validation.py create mode 100644 tests/unittests/web/password_validation_test.py diff --git a/python/nav/web/auth/password_validation.py b/python/nav/web/auth/password_validation.py new file mode 100644 index 0000000000..0fb16aabb0 --- /dev/null +++ b/python/nav/web/auth/password_validation.py @@ -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] diff --git a/tests/unittests/web/password_validation_test.py b/tests/unittests/web/password_validation_test.py new file mode 100644 index 0000000000..ab873519ad --- /dev/null +++ b/tests/unittests/web/password_validation_test.py @@ -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"