diff --git a/src/digid_eherkenning_oidc_generics/admin.py b/src/digid_eherkenning_oidc_generics/admin.py index ec85913a42..341c5db087 100644 --- a/src/digid_eherkenning_oidc_generics/admin.py +++ b/src/digid_eherkenning_oidc_generics/admin.py @@ -25,6 +25,7 @@ class OpenIDConnectConfigBaseAdmin(DynamicArrayMixin, SingletonModelAdmin): "oidc_rp_sign_algo", "oidc_rp_idp_sign_key", "userinfo_claims_source", + "error_message_mapping", ) }, ), diff --git a/src/digid_eherkenning_oidc_generics/migrations/0002_auto_20240109_1055.py b/src/digid_eherkenning_oidc_generics/migrations/0002_auto_20240109_1055.py new file mode 100644 index 0000000000..34ff0cf0d6 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/migrations/0002_auto_20240109_1055.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.23 on 2024-01-09 09:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("digid_eherkenning_oidc_generics", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="openidconnectdigidconfig", + name="error_message_mapping", + field=models.JSONField( + blank=True, + default=dict, + help_text="Mapping that maps error messages returned by the identity provider to human readable error messages that are shown to the user", + max_length=1000, + verbose_name="Error message mapping", + ), + ), + migrations.AddField( + model_name="openidconnecteherkenningconfig", + name="error_message_mapping", + field=models.JSONField( + blank=True, + default=dict, + help_text="Mapping that maps error messages returned by the identity provider to human readable error messages that are shown to the user", + max_length=1000, + verbose_name="Error message mapping", + ), + ), + ] diff --git a/src/digid_eherkenning_oidc_generics/models.py b/src/digid_eherkenning_oidc_generics/models.py index 7dfa80691c..b5827e0cac 100644 --- a/src/digid_eherkenning_oidc_generics/models.py +++ b/src/digid_eherkenning_oidc_generics/models.py @@ -35,6 +35,17 @@ class OpenIDConnectBaseConfig(CachingMixin, OpenIDConnectConfigBase): blank=True, ) + error_message_mapping = models.JSONField( + _("Error message mapping"), + max_length=1000, + help_text=_( + "Mapping that maps error messages returned by the identity provider " + "to human readable error messages that are shown to the user" + ), + default=dict, + blank=True, + ) + # Keycloak specific config oidc_keycloak_idp_hint = models.CharField( _("Keycloak Identity Provider hint"), diff --git a/src/digid_eherkenning_oidc_generics/views.py b/src/digid_eherkenning_oidc_generics/views.py index ec741a1745..70633cad83 100644 --- a/src/digid_eherkenning_oidc_generics/views.py +++ b/src/digid_eherkenning_oidc_generics/views.py @@ -1,11 +1,15 @@ import logging from django.conf import settings -from django.contrib import auth +from django.contrib import auth, messages +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.http import HttpResponseRedirect from django.shortcuts import resolve_url -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext_lazy as _ from django.views import View +from django.views.generic import View import requests from furl import furl @@ -13,8 +17,9 @@ OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView, ) from mozilla_django_oidc_db.views import ( - AdminLoginFailure, + OIDC_ERROR_SESSION_KEY, OIDCCallbackView as _OIDCCallbackView, + get_exception_message, ) from digid_eherkenning_oidc_generics.mixins import ( @@ -25,6 +30,19 @@ logger = logging.getLogger(__name__) +GENERIC_DIGID_ERROR_MSG = _( + "Inloggen bij deze organisatie is niet gelukt. Probeert u het later " + "nog een keer. Lukt het nog steeds niet? Log in bij Mijn DigiD. " + "Zo controleert u of uw DigiD goed werkt. Mogelijk is er een " + "storing bij de organisatie waar u inlogt." +) +GENERIC_EHERKENNING_ERROR_MSG = _( + "Inloggen bij deze organisatie is niet gelukt. Probeert u het later nog een keer. " + "Lukt het nog steeds niet? Neem dan contact op met uw eHerkenning leverancier of " + "kijk op https://www.eherkenning.nl" +) + + class OIDCAuthenticationRequestView(_OIDCAuthenticationRequestView): def get_extra_params(self, request): kc_idp_hint = self.get_settings("OIDC_KEYCLOAK_IDP_HINT", "") @@ -33,12 +51,37 @@ def get_extra_params(self, request): return {} -class OIDCFailureView(AdminLoginFailure): - template_name = "digid_eherkenning_oidc_login_failure.html" +class OIDCFailureView(View): + def get(self, request): + if OIDC_ERROR_SESSION_KEY in self.request.session: + message = self.request.session[OIDC_ERROR_SESSION_KEY] + del self.request.session[OIDC_ERROR_SESSION_KEY] + messages.error(request, message) + else: + messages.error( + request, + _("Something went wrong while logging in, please try again later."), + ) + return HttpResponseRedirect(reverse("login")) class OIDCCallbackView(_OIDCCallbackView): failure_url = reverse_lazy("oidc-error") + generic_error_msg = "" + + def get(self, request): + response = super().get(request) + + error = request.GET.get("error_description") + error_label = self.config.error_message_mapping.get( + error, str(self.generic_error_msg) + ) + if error and error_label: + request.session[OIDC_ERROR_SESSION_KEY] = error_label + elif OIDC_ERROR_SESSION_KEY in request.session and error_label: + request.session[OIDC_ERROR_SESSION_KEY] = error_label + + return response class OIDCLogoutView(View): @@ -73,7 +116,7 @@ class DigiDOIDCAuthenticationRequestView( class DigiDOIDCAuthenticationCallbackView(SoloConfigDigiDMixin, OIDCCallbackView): - pass + generic_error_msg = GENERIC_DIGID_ERROR_MSG class DigiDOIDCLogoutView(SoloConfigDigiDMixin, OIDCLogoutView): @@ -89,7 +132,7 @@ class eHerkenningOIDCAuthenticationRequestView( class eHerkenningOIDCAuthenticationCallbackView( SoloConfigEHerkenningMixin, OIDCCallbackView ): - pass + generic_error_msg = GENERIC_EHERKENNING_ERROR_MSG class eHerkenningOIDCLogoutView(SoloConfigEHerkenningMixin, OIDCLogoutView): diff --git a/src/open_inwoner/accounts/tests/test_oidc_views.py b/src/open_inwoner/accounts/tests/test_oidc_views.py index d4190a2c7c..eb22cbddb9 100644 --- a/src/open_inwoner/accounts/tests/test_oidc_views.py +++ b/src/open_inwoner/accounts/tests/test_oidc_views.py @@ -2,17 +2,24 @@ from unittest.mock import patch from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.test import TestCase, modify_settings, override_settings from django.urls import reverse +from django.utils.translation import gettext as _ import requests_mock from furl import furl from mozilla_django_oidc_db.models import OpenIDConnectConfig +from pyquery import PyQuery as PQ from digid_eherkenning_oidc_generics.models import ( OpenIDConnectDigiDConfig, OpenIDConnectEHerkenningConfig, ) +from digid_eherkenning_oidc_generics.views import ( + GENERIC_DIGID_ERROR_MSG, + GENERIC_EHERKENNING_ERROR_MSG, +) from open_inwoner.kvk.branches import KVK_BRANCH_SESSION_VARIABLE from ..choices import LoginTypeChoices @@ -370,12 +377,12 @@ def test_logout(self, mock_get_solo): self.assertNotIn("oidc_states", self.client.session) self.assertNotIn("oidc_id_token", self.client.session) - def test_error_page_direct_access_forbidden(self): + def test_error_page_direct_access(self): error_url = reverse("oidc-error") response = self.client.get(error_url) - self.assertEqual(response.status_code, 403) + self.assertRedirects(response, reverse("login"), fetch_redirect_response=False) @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") @@ -426,6 +433,162 @@ def test_error_first_cleared_after_succesful_login( self.assertEqual(response.status_code, 403) + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.verify_token") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectDigiDConfig.get_solo", + return_value=OpenIDConnectDigiDConfig( + id=1, + enabled=True, + error_message_mapping={"some mapped message": "Some Error"}, + ), + ) + def test_login_error_message_mapped_in_config( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_get_userinfo.return_value = { + "sub": "some_username", + "bsn": "123456782", + } + + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("digid_oidc:callback") + + # enter the login flow + callback_response = self.client.get( + callback_url, + { + "error": "access_denied", + "error_description": "some mapped message", + "state": "mock", + }, + ) + + self.assertRedirects( + callback_response, reverse("oidc-error"), fetch_redirect_response=False + ) + + error_response = self.client.get(callback_response.url) + + self.assertRedirects( + error_response, reverse("login"), fetch_redirect_response=False + ) + + login_response = self.client.get(error_response.url) + doc = PQ(login_response.content) + error_msg = doc.find(".notification__content").text() + + self.assertEqual(error_msg, "Some Error") + + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.verify_token") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectDigiDConfig.get_solo", + return_value=OpenIDConnectDigiDConfig(id=1, enabled=True), + ) + def test_login_error_message_not_mapped_in_config( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_get_userinfo.return_value = { + "sub": "some_username", + "bsn": "123456782", + } + + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("digid_oidc:callback") + + # enter the login flow + callback_response = self.client.get( + callback_url, + { + "error": "access_denied", + "error_description": "some unmapped message", + "state": "mock", + }, + ) + + self.assertRedirects( + callback_response, reverse("oidc-error"), fetch_redirect_response=False + ) + + error_response = self.client.get(callback_response.url) + + self.assertRedirects( + error_response, reverse("login"), fetch_redirect_response=False + ) + + login_response = self.client.get(error_response.url) + doc = PQ(login_response.content) + error_msg = doc.find(".notification__content").text() + + self.assertEqual(error_msg, str(GENERIC_DIGID_ERROR_MSG)) + + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.verify_token") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectDigiDConfig.get_solo", + return_value=OpenIDConnectDigiDConfig(id=1, enabled=True), + ) + def test_login_validation_error( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_verify_token.side_effect = ValidationError("Something went wrong") + mock_get_userinfo.return_value = { + "sub": "some_username", + "bsn": "123456782", + } + + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("digid_oidc:callback") + + # enter the login flow + callback_response = self.client.get( + callback_url, {"code": "mock", "state": "mock"} + ) + + self.assertRedirects( + callback_response, reverse("oidc-error"), fetch_redirect_response=False + ) + + error_response = self.client.get(callback_response.url) + + self.assertRedirects( + error_response, reverse("login"), fetch_redirect_response=False + ) + + login_response = self.client.get(error_response.url) + doc = PQ(login_response.content) + error_msg = doc.find(".notification__content").text() + + self.assertEqual(error_msg, str(GENERIC_DIGID_ERROR_MSG)) + @override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") class eHerkenningOIDCFlowTests(TestCase): @@ -650,3 +813,159 @@ def test_error_first_cleared_after_succesful_login( response = self.client.get(error_url) self.assertEqual(response.status_code, 403) + + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.verify_token") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectEHerkenningConfig.get_solo", + return_value=OpenIDConnectEHerkenningConfig( + id=1, + enabled=True, + error_message_mapping={"some mapped message": "Some Error"}, + ), + ) + def test_login_error_message_mapped_in_config( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_get_userinfo.return_value = { + "sub": "some_username", + "bsn": "123456782", + } + + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("eherkenning_oidc:callback") + + # enter the login flow + callback_response = self.client.get( + callback_url, + { + "error": "access_denied", + "error_description": "some mapped message", + "state": "mock", + }, + ) + + self.assertRedirects( + callback_response, reverse("oidc-error"), fetch_redirect_response=False + ) + + error_response = self.client.get(callback_response.url) + + self.assertRedirects( + error_response, reverse("login"), fetch_redirect_response=False + ) + + login_response = self.client.get(error_response.url) + doc = PQ(login_response.content) + error_msg = doc.find(".notification__content").text() + + self.assertEqual(error_msg, "Some Error") + + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.verify_token") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectEHerkenningConfig.get_solo", + return_value=OpenIDConnectEHerkenningConfig(id=1, enabled=True), + ) + def test_login_error_message_not_mapped_in_config( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_get_userinfo.return_value = { + "sub": "some_username", + "bsn": "123456782", + } + + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("eherkenning_oidc:callback") + + # enter the login flow + callback_response = self.client.get( + callback_url, + { + "error": "access_denied", + "error_description": "some unmapped message", + "state": "mock", + }, + ) + + self.assertRedirects( + callback_response, reverse("oidc-error"), fetch_redirect_response=False + ) + + error_response = self.client.get(callback_response.url) + + self.assertRedirects( + error_response, reverse("login"), fetch_redirect_response=False + ) + + login_response = self.client.get(error_response.url) + doc = PQ(login_response.content) + error_msg = doc.find(".notification__content").text() + + self.assertEqual(error_msg, str(GENERIC_EHERKENNING_ERROR_MSG)) + + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_userinfo") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.store_tokens") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.verify_token") + @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectEHerkenningConfig.get_solo", + return_value=OpenIDConnectEHerkenningConfig(id=1, enabled=True), + ) + def test_login_validation_error( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_verify_token.side_effect = ValidationError("Something went wrong") + mock_get_userinfo.return_value = { + "sub": "some_username", + "bsn": "123456782", + } + + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("eherkenning_oidc:callback") + + # enter the login flow + callback_response = self.client.get( + callback_url, {"code": "mock", "state": "mock"} + ) + + self.assertRedirects( + callback_response, reverse("oidc-error"), fetch_redirect_response=False + ) + + error_response = self.client.get(callback_response.url) + + self.assertRedirects( + error_response, reverse("login"), fetch_redirect_response=False + ) + + login_response = self.client.get(error_response.url) + doc = PQ(login_response.content) + error_msg = doc.find(".notification__content").text() + + self.assertEqual(error_msg, str(GENERIC_EHERKENNING_ERROR_MSG)) diff --git a/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html b/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html deleted file mode 100644 index a5b55409e4..0000000000 --- a/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends 'master.html' %} -{% load i18n %} -{% block menu %}{% endblock %} -{% block content %} -

{% trans "Something went wrong while logging in, please try again later." %}

-{% endblock content %}