From eda423f9ff46a98cec8f607446ce7d12a700a78f Mon Sep 17 00:00:00 2001 From: Paul Schilling Date: Mon, 23 Oct 2023 09:58:54 +0200 Subject: [PATCH] Add bsn validation to mock login form --- digid_eherkenning/mock/idp/forms.py | 7 +++- digid_eherkenning/validators.py | 54 +++++++++++++++++++++++++++++ tests/test_mock_forms.py | 24 +++++++++++++ tests/test_mock_views.py | 39 +++------------------ 4 files changed, 89 insertions(+), 35 deletions(-) create mode 100644 tests/test_mock_forms.py diff --git a/digid_eherkenning/mock/idp/forms.py b/digid_eherkenning/mock/idp/forms.py index bd29efb..7de7f18 100644 --- a/digid_eherkenning/mock/idp/forms.py +++ b/digid_eherkenning/mock/idp/forms.py @@ -1,10 +1,15 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from ...validators import BSNValidator + class PasswordLoginForm(forms.Form): auth_name = forms.CharField( - max_length=255, required=True, label=_("DigiD gebruikersnaam") + max_length=255, + required=True, + label=_("DigiD gebruikersnaam"), + validators=[BSNValidator()], ) auth_pass = forms.CharField( max_length=255, required=True, label=_("Wachtwoord"), widget=forms.PasswordInput diff --git a/digid_eherkenning/validators.py b/digid_eherkenning/validators.py index 8fc3eb5..82be08a 100644 --- a/digid_eherkenning/validators.py +++ b/digid_eherkenning/validators.py @@ -1,4 +1,6 @@ +from django.core.exceptions import ValidationError from django.core.validators import RegexValidator +from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ # See `OINType` in eherkenning-dc.xml XSD @@ -6,3 +8,55 @@ regex=r"[0-9]{20}", message=_("A valid OIN consists of 20 digits."), ) + + +validate_digits = RegexValidator( + regex="^[0-9]+$", message=_("Expected a numerical value.") +) + + +class Proef11ValidatorBase: + value_size = NotImplemented + error_messages = { + "too_short": NotImplemented, + "wrong": NotImplemented, + } + + def __call__(self, value): + """ + Validates that a string value is a valid 11-proef number (BSN, RSIN etc) by applying the + '11-proef' checking. + :param value: String object representing a presumably good 11-proef number. + """ + # Initial sanity checks. + validate_digits(value) + if len(value) != self.value_size: + raise ValidationError( + self.error_messages["too_short"], + params={"size": self.value_size}, + code="invalid", + ) + + # 11-proef check. + total = 0 + for multiplier, char in enumerate(reversed(value), start=1): + if multiplier == 1: + total += -multiplier * int(char) + else: + total += multiplier * int(char) + + if total % 11 != 0: + raise ValidationError(self.error_messages["wrong"]) + + +@deconstructible +class BSNValidator(Proef11ValidatorBase): + """ + Validate a BSN value by applying the "11-proef". + """ + + value_size = 9 + error_messages = { + "too_short": _("BSN should have %(size)i characters."), + "wrong": _("Invalid BSN."), + } diff --git a/tests/test_mock_forms.py b/tests/test_mock_forms.py new file mode 100644 index 0000000..454539e --- /dev/null +++ b/tests/test_mock_forms.py @@ -0,0 +1,24 @@ +import pytest + +from digid_eherkenning.mock.idp.forms import PasswordLoginForm + + +@pytest.mark.parametrize( + "auth_name, auth_pass, name_has_error, pass_has_error", + [ + ("296648875", "password", False, False), # OK + ("abcdefghe", "password", True, False), # bsn wrong type + ("2966488759", "password", True, False), # bsn too long + ("29664887", "password", True, False), # bsn too short + ("123456789", "password", True, False), # bsn wrong checksum + ("296648875", "", False, True), # missing password + ], +) +def test_password_login_form_validate( + auth_name, auth_pass, name_has_error, pass_has_error +): + form = PasswordLoginForm(data={"auth_name": auth_name, "auth_pass": auth_pass}) + + assert form.has_error("auth_name") is name_has_error + assert form.has_error("auth_pass") is pass_has_error + assert form.is_valid() is not (name_has_error or pass_has_error) diff --git a/tests/test_mock_views.py b/tests/test_mock_views.py index 8ca8d9d..d78e680 100644 --- a/tests/test_mock_views.py +++ b/tests/test_mock_views.py @@ -105,7 +105,7 @@ def test_post_redirects_and_authenticates(self): url = f"{url}?{urlencode(params)}" data = { - "auth_name": "123456789", + "auth_name": "296648875", "auth_pass": "bar", } # post our password to the IDP @@ -119,7 +119,7 @@ def test_post_redirects_and_authenticates(self): response = self.client.get(response["Location"], follow=False) User = get_user_model() - user = User.digid_objects.get(bsn="123456789") + user = User.digid_objects.get(bsn="296648875") # follow redirect to 'next' self.assertRedirects(response, reverse("test-success")) @@ -128,7 +128,7 @@ def test_post_redirects_and_authenticates(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "Je bent ingelogged als gebruiker") self.assertContains(response, "{}".format(str(user))) - self.assertContains(response, "123456789") + self.assertContains(response, "296648875") def test_post_redirect_retains_acs_querystring_params(self): url = reverse("digid-mock:password") @@ -140,7 +140,7 @@ def test_post_redirect_retains_acs_querystring_params(self): url = f"{url}?{urlencode(params)}" data = { - "auth_name": "123456789", + "auth_name": "296648875", "auth_pass": "bar", } # post our password to the IDP @@ -150,7 +150,7 @@ def test_post_redirect_retains_acs_querystring_params(self): expected_redirect = furl(reverse("digid:acs")).set( { "foo": "bar", - "bsn": "123456789", + "bsn": "296648875", "next": reverse("test-success"), } ) @@ -158,35 +158,6 @@ def test_post_redirect_retains_acs_querystring_params(self): response, str(expected_redirect), fetch_redirect_response=False ) - def test_backend_rejects_non_numerical_name(self): - url = reverse("digid-mock:password") - params = { - "acs": reverse("digid:acs"), - "next": reverse("test-success"), - "cancel": reverse("test-index"), - } - url = f"{url}?{urlencode(params)}" - - data = { - "auth_name": "foo", - "auth_pass": "bar", - } - # post our password to the IDP - response = self.client.post(url, data, follow=False) - - # it will redirect to our ACS - self.assertEqual(response.status_code, 302) - self.assertIn(reverse("digid:acs"), response["Location"]) - - # follow the ACS redirect and get/create the user - response = self.client.get(response["Location"], follow=False) - self.assertEqual(response.status_code, 302) - self.assertIn(reverse("test-index"), response["Location"]) - - User = get_user_model() - with self.assertRaises(User.DoesNotExist): - User.digid_objects.get(bsn="foo") - @override_settings(**OVERRIDE_SETTINGS) @modify_settings(**MODIFY_SETTINGS)