Skip to content

Commit

Permalink
Add a composition password validator
Browse files Browse the repository at this point in the history
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
hmpf committed Mar 6, 2024
1 parent c5366d6 commit 41307fe
Show file tree
Hide file tree
Showing 2 changed files with 174 additions and 0 deletions.
93 changes: 93 additions & 0 deletions python/nav/web/auth/password_validation.py
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 + ']'

Check warning on line 52 in python/nav/web/auth/password_validation.py

View check run for this annotation

Codecov / codecov/patch

python/nav/web/auth/password_validation.py#L52

Added line #L52 was not covered by tests
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']

Check warning on line 81 in python/nav/web/auth/password_validation.py

View check run for this annotation

Codecov / codecov/patch

python/nav/web/auth/password_validation.py#L81

Added line #L81 was not covered by tests
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]

Check warning on line 93 in python/nav/web/auth/password_validation.py

View check run for this annotation

Codecov / codecov/patch

python/nav/web/auth/password_validation.py#L93

Added line #L93 was not covered by tests
81 changes: 81 additions & 0 deletions tests/unittests/web/password_validation_test.py
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"

0 comments on commit 41307fe

Please sign in to comment.