Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a composition password validator #2864

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Loading