Skip to content

Commit c93aee7

Browse files
committed
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.
1 parent 3c4f8bd commit c93aee7

File tree

2 files changed

+174
-0
lines changed

2 files changed

+174
-0
lines changed
+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import re
2+
3+
from django.core.exceptions import ValidationError
4+
5+
6+
class CompositionValidator:
7+
DEFAULT_SPECIAL_CHARACTERS = '-=_+,.; :!@#$%&*'
8+
MAPPING = {
9+
'min_numeric': {
10+
'pattern': r'[0-9]',
11+
'help_singular': '%i digit',
12+
'help_plural': '%i digits',
13+
},
14+
'min_upper': {
15+
'pattern': r'[A-Z]',
16+
'help_singular': '%i uppercase letter',
17+
'help_plural': '%i uppercase letters',
18+
},
19+
'min_lower': {
20+
'pattern': r'[a-z]',
21+
'help_singular': '%i lowercase letter',
22+
'help_plural': '%i lowercase letters',
23+
},
24+
'min_special': {
25+
'pattern': None,
26+
'help_singular': '%i special character from the following: %%s',
27+
'help_plural': '%i special characters from the following: %%s',
28+
},
29+
}
30+
31+
def __init__(
32+
self,
33+
min_numeric=1,
34+
min_upper=1,
35+
min_lower=1,
36+
min_special=1,
37+
special_characters=DEFAULT_SPECIAL_CHARACTERS,
38+
):
39+
self.check_mapping = {}
40+
self.special_characters = special_characters
41+
self._build_check_mapping_item('min_numeric', int(min_numeric))
42+
self._build_check_mapping_item('min_upper', int(min_upper))
43+
self._build_check_mapping_item('min_lower', int(min_lower))
44+
self._build_check_mapping_item('min_special', int(min_special))
45+
46+
def validate(self, password, user=None):
47+
errors = []
48+
for name, value in self.check_mapping.items():
49+
pattern = self.MAPPING[name]['pattern']
50+
required = value['required']
51+
if name == 'min_special':
52+
pattern = r'[' + self.special_characters + ']'
53+
found = re.findall(pattern, password)
54+
if len(found) >= required:
55+
continue
56+
# not found
57+
errors.append(name)
58+
if errors:
59+
error_msg = self._build_error_msg(errors)
60+
raise ValidationError(
61+
'Invalid password, must have at least ' + error_msg,
62+
code='password_is_insufficiently_complex',
63+
)
64+
65+
def get_help_text(self):
66+
msg = "The password needs to contain at least: "
67+
help_texts = [v['help_text'] for v in self.check_mapping.values()]
68+
if len(self.check_mapping) == 1:
69+
return msg + help_texts[-1]
70+
return msg + ', '.join(help_texts[:-1]) + ' and ' + help_texts[-1]
71+
72+
def _build_check_mapping_item(self, name, count):
73+
if not count:
74+
return
75+
if name == 'min_special' and not self.special_characters:
76+
return
77+
self.check_mapping[name] = {'required': count}
78+
if count == 1:
79+
help_text = self.MAPPING[name]['help_singular']
80+
else:
81+
help_text = self.MAPPING[name]['help_plural']
82+
help_text = help_text % count
83+
if name == 'min_special':
84+
help_text = help_text % self.special_characters
85+
self.check_mapping[name]['help_text'] = help_text
86+
87+
def _build_error_msg(self, errors):
88+
error_msgs = []
89+
for error in errors:
90+
error_msgs.append(self.check_mapping[error]['help_text'])
91+
if len(errors) == 1:
92+
return error_msgs[0]
93+
return ' '.join(error_msgs[:-1]) + ' and ' + error_msgs[-1]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from django.core.exceptions import ValidationError
2+
3+
from nav.web.auth.password_validation import CompositionValidator
4+
5+
6+
def test_init_with_no_args_builds_default_check_mapping():
7+
default_check_mapping = {
8+
'min_numeric': {
9+
'required': 1,
10+
'help_text': '1 digit',
11+
},
12+
'min_upper': {
13+
'required': 1,
14+
'help_text': '1 uppercase letter',
15+
},
16+
'min_lower': {
17+
'required': 1,
18+
'help_text': '1 lowercase letter',
19+
},
20+
'min_special': {
21+
'required': 1,
22+
'help_text': (
23+
'1 special character from the following: %s'
24+
% CompositionValidator.DEFAULT_SPECIAL_CHARACTERS
25+
),
26+
},
27+
}
28+
cv = CompositionValidator()
29+
assert cv.check_mapping == default_check_mapping, 'Check mapping was built wrong'
30+
31+
32+
def test_init_with_int_args_as_zero_builds_empty_check_mapping():
33+
cv = CompositionValidator(min_numeric=0, min_upper=0, min_lower=0, min_special=0)
34+
assert cv.check_mapping == {}, 'Check mapping is not empty'
35+
36+
37+
def test_init_with_empty_special_characters_menas_nop_special_check():
38+
cv = CompositionValidator(special_characters="")
39+
assert (
40+
'min_special' not in cv.check_mapping
41+
), "Check mapping was built wrong, special check should not have been included"
42+
43+
44+
def test_get_help_text_with_one_required_check_does_not_contain_and_or_comma():
45+
cv = CompositionValidator(min_upper=0, min_lower=0, min_special=0)
46+
help_text = cv.get_help_text()
47+
assert 'and' not in help_text, 'Help text for a single check is wrong, has "and"'
48+
assert ',' not in help_text, 'Help text for a single check is wrong, has comma'
49+
50+
51+
def test_get_help_text_with_two_or_more_required_check_always_contains_and_and_may_contain_comma():
52+
cv = CompositionValidator(min_lower=0, min_special=0)
53+
help_text = cv.get_help_text()
54+
assert 'and' in help_text, 'Help text for two checks is wrongi, lacks "and"'
55+
cv = CompositionValidator(min_special=0)
56+
help_text = cv.get_help_text()
57+
assert 'and' in help_text, 'Help text for three checks is wrong, lacks "and"'
58+
assert ',' in help_text, 'Help text for three checks is wrong, lacks comma'
59+
cv = CompositionValidator()
60+
help_text = cv.get_help_text()
61+
assert 'and' in help_text, 'Help text for four checks is wrong'
62+
assert ',' in help_text, 'Help text for four checks is wrong, lacks comma'
63+
64+
65+
def test_validate_with_correct_password_returns_None():
66+
cv = CompositionValidator(min_upper=0, min_lower=0, min_special=0)
67+
result = cv.validate("42")
68+
assert result is None, "The password did not validate but should"
69+
70+
71+
def test_validate_with_incorrect_password_returns_ValidationError_with_error_message():
72+
cv = CompositionValidator(min_upper=0, min_lower=0, min_special=0)
73+
try:
74+
cv.validate("")
75+
except ValidationError as e:
76+
expected_error = 'Invalid password, must have at least 1 digit'
77+
assert (
78+
e.message == expected_error
79+
), "Error message of incorrect password was wrong"
80+
else:
81+
assert False, "Incorrect password did not raise ValidationError"

0 commit comments

Comments
 (0)