From 322675199d8d4061b8936419e0405d2c703897d3 Mon Sep 17 00:00:00 2001
From: Hanne Moa <hanne.moa@sikt.no>
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"