diff --git a/requirements/base.txt b/requirements/base.txt index 1c7c145fb4..ca007fc5ec 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -155,7 +155,7 @@ django-csp==3.7 # via -r requirements/base.in django-csp-reports==1.8.1 # via -r requirements/base.in -django-digid-eherkenning==0.12.0 +django-digid-eherkenning==0.13.0 # via -r requirements/base.in django-filter==23.2 # via -r requirements/base.in diff --git a/requirements/ci.txt b/requirements/ci.txt index ffe7384cfd..dd4f45b5de 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -258,7 +258,7 @@ django-csp-reports==1.8.1 # via # -c requirements/base.txt # -r requirements/base.txt -django-digid-eherkenning==0.12.0 +django-digid-eherkenning==0.13.0 # via # -c requirements/base.txt # -r requirements/base.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index 54e2e32259..37f5d49168 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -287,7 +287,7 @@ django-csp-reports==1.8.1 # -r requirements/ci.txt django-debug-toolbar==4.3.0 # via -r requirements/dev.in -django-digid-eherkenning==0.12.0 +django-digid-eherkenning==0.13.0 # via # -c requirements/ci.txt # -r requirements/ci.txt diff --git a/requirements/extensions.txt b/requirements/extensions.txt index d49c80f0c5..f85feedd33 100644 --- a/requirements/extensions.txt +++ b/requirements/extensions.txt @@ -223,7 +223,7 @@ django-csp-reports==1.8.1 # via # -c requirements/base.in # -r requirements/base.txt -django-digid-eherkenning==0.12.0 +django-digid-eherkenning==0.13.0 # via # -c requirements/base.in # -r requirements/base.txt diff --git a/src/openapi.yaml b/src/openapi.yaml index 309caaccce..31f75ff0ae 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -6716,6 +6716,10 @@ components: type: string title: Provides authentication attributes description: The authentication attribute provided by this plugin. + supportsLoaOverride: + type: boolean + description: Does the Identity Provider support overriding the minimum Level + of Assurance (LoA) through the authentication request? assuranceLevels: type: array items: @@ -6727,6 +6731,7 @@ components: - id - label - providesAuth + - supportsLoaOverride AuthenticationBackendsEnum: enum: - digid diff --git a/src/openforms/authentication/api/serializers.py b/src/openforms/authentication/api/serializers.py index 708128a844..0d3c046044 100644 --- a/src/openforms/authentication/api/serializers.py +++ b/src/openforms/authentication/api/serializers.py @@ -23,6 +23,13 @@ class AuthPluginSerializer(PluginBaseSerializer): label=_("Provides authentication attributes"), help_text=_("The authentication attribute provided by this plugin."), ) + supports_loa_override = serializers.BooleanField( + label=_("supports loa override"), + help_text=_( + "Does the Identity Provider support overriding the minimum " + "Level of Assurance (LoA) through the authentication request?" + ), + ) assurance_levels = serializers.ListField( child=TextChoiceSerializer(), label=_("Levels of assurance"), diff --git a/src/openforms/authentication/api/tests/test_endpoints.py b/src/openforms/authentication/api/tests/test_endpoints.py index 01610ba9ca..a92f162186 100644 --- a/src/openforms/authentication/api/tests/test_endpoints.py +++ b/src/openforms/authentication/api/tests/test_endpoints.py @@ -98,6 +98,7 @@ def test_single_auth_plugin(self): "id": "plugin1", "label": "SingleAuthPlugin", "providesAuth": "bsn", + "supportsLoaOverride": False, "assuranceLevels": [ {"label": "low", "value": "low"}, {"label": "Stare into the Sun", "value": "∞"}, @@ -120,6 +121,7 @@ def test_demo_plugin(self): "id": "plugin1", "label": "SingleAuthPlugin", "providesAuth": "bsn", + "supportsLoaOverride": False, "assuranceLevels": [ {"label": "low", "value": "low"}, {"label": "Stare into the Sun", "value": "∞"}, @@ -129,6 +131,7 @@ def test_demo_plugin(self): "id": "plugin2", "label": "DemoAuthPlugin", "providesAuth": "bsn", + "supportsLoaOverride": False, "assuranceLevels": [], }, ] diff --git a/src/openforms/authentication/base.py b/src/openforms/authentication/base.py index 48cdd76958..a18efc58ab 100644 --- a/src/openforms/authentication/base.py +++ b/src/openforms/authentication/base.py @@ -39,6 +39,7 @@ class Choice(TypedDict): class BasePlugin(AbstractBasePlugin): provides_auth: AuthAttribute + supports_loa_override = False assurance_levels: type[TextChoices] = TextChoices return_method = "GET" is_for_gemachtigde = False diff --git a/src/openforms/authentication/contrib/digid/plugin.py b/src/openforms/authentication/contrib/digid/plugin.py index 023bba30dd..6ccfa9ba4a 100644 --- a/src/openforms/authentication/contrib/digid/plugin.py +++ b/src/openforms/authentication/contrib/digid/plugin.py @@ -34,6 +34,7 @@ def loa_order(loa: str) -> int: class DigidAuthentication(BasePlugin): verbose_name = _("DigiD") provides_auth = AuthAttribute.bsn + supports_loa_override = True assurance_levels = DigiDAssuranceLevels def start_login( diff --git a/src/openforms/authentication/contrib/eherkenning/plugin.py b/src/openforms/authentication/contrib/eherkenning/plugin.py index 9bc04de230..8e8da917b1 100644 --- a/src/openforms/authentication/contrib/eherkenning/plugin.py +++ b/src/openforms/authentication/contrib/eherkenning/plugin.py @@ -124,7 +124,6 @@ def logout(self, request: HttpRequest): class EHerkenningAuthentication(AuthenticationBasePlugin): verbose_name = _("eHerkenning") provides_auth = AuthAttribute.kvk - assurance_levels = AssuranceLevels session_key = EHERKENNING_AUTH_SESSION_KEY def get_session_loa(self, session) -> str: @@ -134,7 +133,7 @@ def get_session_loa(self, session) -> str: def check_requirements(self, request, config): # check LoA requirements authenticated_loa = request.session[FORM_AUTH_SESSION_KEY]["loa"] - required = config.get("loa") or EherkenningConfiguration.get_solo().loa + required = EherkenningConfiguration.get_solo().eh_loa return loa_order(authenticated_loa) >= loa_order(required) def get_logo(self, request) -> LoginLogo | None: diff --git a/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py b/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py index 70807c4299..05914a0428 100644 --- a/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py +++ b/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py @@ -9,7 +9,6 @@ from django.utils.safestring import mark_safe import requests_mock -from digid_eherkenning.choices import AssuranceLevels from digid_eherkenning.models import EherkenningConfiguration from freezegun import freeze_time from furl import furl @@ -180,70 +179,6 @@ def test_authn_request(self, mock_id): }, ) - @freeze_time("2020-04-09T08:31:46Z") - @patch( - "onelogin.saml2.authn_request.OneLogin_Saml2_Utils.generate_unique_id", - return_value="ONELOGIN_123456", - ) - def test_authn_request_uses_minimal_loa_from_form(self, mock_id): - form = FormFactory.create( - authentication_backends=["eherkenning"], - authentication_backend_options={ - "eherkenning": {"loa": AssuranceLevels.high} - }, - generate_minimal_setup=True, - formstep__form_definition__login_required=True, - ) - login_url = reverse( - "authentication:start", - kwargs={"slug": form.slug, "plugin_id": "eherkenning"}, - ) - form_path = reverse("core:form-detail", kwargs={"slug": form.slug}) - form_url = f"https://testserver{form_path}" - login_url = furl(login_url).set({"next": form_url}) - - response = self.client.get(login_url.url, follow=True) - - return_url = reverse( - "authentication:return", - kwargs={"slug": form.slug, "plugin_id": "eherkenning"}, - ) - full_return_url = furl(return_url).add({"next": form_url}) - - self.assertEqual( - response.context["form"].initial["RelayState"], - str(full_return_url), - ) - - saml_request = b64decode( - response.context["form"].initial["SAMLRequest"].encode("utf-8") - ) - tree = etree.fromstring(saml_request) - - self.assertEqual( - tree.attrib, - { - "ID": "ONELOGIN_123456", - "Version": "2.0", - "ForceAuthn": "true", - "IssueInstant": "2020-04-09T08:31:46Z", - "Destination": "https://test-iwelcome.nl/broker/sso/1.13", - "ProtocolBinding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact", - "AssertionConsumerServiceURL": "https://test-sp.nl/eherkenning/acs/", - "AttributeConsumingServiceIndex": "8888", - }, - ) - - auth_context_class_ref = tree.xpath( - "samlp:RequestedAuthnContext[@Comparison='minimum']/saml:AuthnContextClassRef", - namespaces={ - "samlp": "urn:oasis:names:tc:SAML:2.0:protocol", - "saml": "urn:oasis:names:tc:SAML:2.0:assertion", - }, - )[0] - - self.assertEqual(auth_context_class_ref.text, AssuranceLevels.high.value) - @override_settings(CORS_ALLOW_ALL_ORIGINS=True) @temp_private_root() diff --git a/src/openforms/authentication/contrib/eherkenning/urls.py b/src/openforms/authentication/contrib/eherkenning/urls.py index 4c246dc466..839a5ee829 100644 --- a/src/openforms/authentication/contrib/eherkenning/urls.py +++ b/src/openforms/authentication/contrib/eherkenning/urls.py @@ -1,6 +1,8 @@ from django.urls import path -from .views import eHerkenningAssertionConsumerServiceView, eHerkenningLoginView +from digid_eherkenning.views import eHerkenningLoginView + +from .views import eHerkenningAssertionConsumerServiceView app_name = "eherkenning" diff --git a/src/openforms/authentication/contrib/eherkenning/views.py b/src/openforms/authentication/contrib/eherkenning/views.py index 016c0a8dbf..55711e9739 100644 --- a/src/openforms/authentication/contrib/eherkenning/views.py +++ b/src/openforms/authentication/contrib/eherkenning/views.py @@ -2,7 +2,6 @@ from typing import Any from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404 from django.urls import resolve from django.utils.translation import gettext as _ @@ -10,18 +9,14 @@ from digid_eherkenning.saml2.eherkenning import eHerkenningClient from digid_eherkenning.views import ( eHerkenningAssertionConsumerServiceView as _eHerkenningAssertionConsumerServiceView, - eHerkenningLoginView as _eHerkenningLoginView, ) from furl import furl from onelogin.saml2.errors import OneLogin_Saml2_ValidationError -from openforms.forms.models import Form - from ..digid.mixins import AssertionConsumerServiceMixin from .constants import ( EHERKENNING_AUTH_SESSION_AUTHN_CONTEXTS, EHERKENNING_AUTH_SESSION_KEY, - EHERKENNING_PLUGIN_ID, EIDAS_AUTH_SESSION_KEY, ) @@ -37,20 +32,6 @@ class ExpectedIdNotPresentError(Exception): GENERIC_LOGIN_ERROR = "error" -class eHerkenningLoginView(_eHerkenningLoginView): - def get_level_of_assurance(self): - # get the form_slug from /auth/{slug}/...?next=... - return_path = furl(self.request.GET.get("next")).path - _, _, kwargs = resolve(return_path) - - form = get_object_or_404(Form, slug=kwargs.get("slug")) - - loa = form.authentication_backend_options.get(EHERKENNING_PLUGIN_ID, {}).get( - "loa" - ) - return loa if loa else super().get_level_of_assurance() - - class eHerkenningAssertionConsumerServiceView( AssertionConsumerServiceMixin, BaseSaml2Backend, diff --git a/src/openforms/js/components/admin/form_design/FormConfigurationFields.js b/src/openforms/js/components/admin/form_design/FormConfigurationFields.js index 1431692327..84c20bf25a 100644 --- a/src/openforms/js/components/admin/form_design/FormConfigurationFields.js +++ b/src/openforms/js/components/admin/form_design/FormConfigurationFields.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; +import LoAOverrideOption from 'components/admin/form_design/authentication/LoAOverrideOption'; import Field from 'components/admin/forms/Field'; import Fieldset from 'components/admin/forms/Fieldset'; import FormRow from 'components/admin/forms/FormRow'; @@ -254,41 +255,12 @@ const FormConfigurationFields = ({ > - - - } - > - + + + ); +}; + +export default LoAOverrideOption; + +LoAOverrideOption.propTypes = { + onChange: PropTypes.func.isRequired, + availableAuthPlugins: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + label: PropTypes.string, + providesAuth: PropTypes.string, + }) + ).isRequired, + selectedAuthPlugins: PropTypes.arrayOf(PropTypes.string).isRequired, + authenticationBackendOptions: PropTypes.object, +}; diff --git a/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.mdx b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.mdx new file mode 100644 index 0000000000..e5e7049399 --- /dev/null +++ b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.mdx @@ -0,0 +1,18 @@ +import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs'; + +import LoAOverrideOption from './LoAOverrideOption'; +import * as Stories from './LoAOverrideOption.stories'; + + + +# Form field + +Some authentication plugins offer the option of overriding the Level of Assurance (LoA) per form. +This form field is visible if one of these plugins has been selected. It is then possible to select +the new minimum desired LoA for a form. + + + +### Props + + diff --git a/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.stories.js b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.stories.js new file mode 100644 index 0000000000..155285d8f7 --- /dev/null +++ b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.stories.js @@ -0,0 +1,116 @@ +import {expect} from '@storybook/jest'; +import {within} from '@storybook/testing-library'; + +import LoAOverrideOption from './LoAOverrideOption'; + +export default { + title: 'Form design/ Authentication / LoA override option', + component: LoAOverrideOption, +}; + +export const Default = { + args: { + availableAuthPlugins: [ + { + id: 'digid', + label: 'DigiD', + providesAuth: 'bsn', + supportsLoaOverride: true, + assuranceLevels: [ + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + label: 'DigiD Basis', + }, + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract', + label: 'DigiD Midden', + }, + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard', + label: 'DigiD Substantieel', + }, + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI', + label: 'DigiD Hoog', + }, + ], + }, + { + id: 'eidas', + label: 'eIDAS', + providesAuth: 'pseudo', + supportsLoaOverride: false, + assuranceLevels: [], + }, + ], + selectedAuthPlugins: ['digid', 'eidas'], + authenticationBackendOptions: { + digid: {loa: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'}, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const fieldLabel = canvas.queryByText('Minimale betrouwbaarheidsniveaus'); + + await expect(fieldLabel).toBeVisible(); + + const dropdowns = canvas.getAllByRole('combobox'); + + await expect(dropdowns.length).toEqual(1); + }, +}; + +export const NoDigiDSelected = { + name: 'No DigiD selceted', + args: { + availableAuthPlugins: [ + { + id: 'digid', + label: 'DigiD', + providesAuth: 'bsn', + supportsLoaOverride: true, + assuranceLevels: [ + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + label: 'DigiD Basis', + }, + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract', + label: 'DigiD Midden', + }, + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard', + label: 'DigiD Substantieel', + }, + { + value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI', + label: 'DigiD Hoog', + }, + ], + }, + { + id: 'eidas', + label: 'eIDAS', + providesAuth: 'pseudo', + supportsLoaOverride: false, + assuranceLevels: [], + }, + ], + selectedAuthPlugins: ['eidas'], + authenticationBackendOptions: { + digid: {loa: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'}, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const fieldLabel = canvas.queryByText('Minimale betrouwbaarheidsniveaus'); + + await expect(fieldLabel).toBeNull(); + + const dropdowns = canvas.queryAllByRole('combobox'); + + await expect(dropdowns.length).toEqual(0); + }, +}; diff --git a/src/openforms/js/components/admin/form_design/types/AuthPlugin.js b/src/openforms/js/components/admin/form_design/types/AuthPlugin.js index 22e0b95f82..2cef5dfd4e 100644 --- a/src/openforms/js/components/admin/form_design/types/AuthPlugin.js +++ b/src/openforms/js/components/admin/form_design/types/AuthPlugin.js @@ -4,6 +4,8 @@ const AuthPlugin = PropTypes.shape({ id: PropTypes.string.isRequired, label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, providesAuth: PropTypes.string, + assuranceLevel: PropTypes.array, + supportsLoaOverride: PropTypes.bool, }); export default AuthPlugin;