Skip to content

Commit

Permalink
Merge pull request #4045 from open-formulieren/fix/3969-remove-eherke…
Browse files Browse the repository at this point in the history
…nning-loa-overwrite

[#3969] Remove ability to override LoA for eHekrenning/eIDAS
  • Loading branch information
sergei-maertens authored Apr 2, 2024
2 parents 31380fa + ff05b99 commit dba0ffe
Show file tree
Hide file tree
Showing 18 changed files with 246 additions and 126 deletions.
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements/extensions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -6727,6 +6731,7 @@ components:
- id
- label
- providesAuth
- supportsLoaOverride
AuthenticationBackendsEnum:
enum:
- digid
Expand Down
7 changes: 7 additions & 0 deletions src/openforms/authentication/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
3 changes: 3 additions & 0 deletions src/openforms/authentication/api/tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "∞"},
Expand All @@ -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": "∞"},
Expand All @@ -129,6 +131,7 @@ def test_demo_plugin(self):
"id": "plugin2",
"label": "DemoAuthPlugin",
"providesAuth": "bsn",
"supportsLoaOverride": False,
"assuranceLevels": [],
},
]
Expand Down
1 change: 1 addition & 0 deletions src/openforms/authentication/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/openforms/authentication/contrib/digid/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions src/openforms/authentication/contrib/eherkenning/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion src/openforms/authentication/contrib/eherkenning/urls.py
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
19 changes: 0 additions & 19 deletions src/openforms/authentication/contrib/eherkenning/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,21 @@
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 _

from digid_eherkenning.backends import BaseSaml2Backend
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,
)

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -254,41 +255,12 @@ const FormConfigurationFields = ({
></AuthPluginAutoLoginField>
</Field>
</FormRow>
<FormRow>
<Field
name="form.authenticationBackendOptions"
label={
<FormattedMessage
description="Minimal levels of assurance label"
defaultMessage="Minimal levels of assurance"
/>
}
>
<ul>
{availableAuthPlugins
.filter(
plugin =>
plugin.assuranceLevels.length && selectedAuthPlugins.includes(plugin.id)
)
.map(plugin => (
<li key={plugin.id}>
<label htmlFor={`form.authenticationBackendOptions.${plugin.id}.loa`}>
{plugin.label}
</label>
<Select
key={plugin.id}
id={`form.authenticationBackendOptions.${plugin.id}.loa`}
name={`form.authenticationBackendOptions.${plugin.id}.loa`}
value={form.authenticationBackendOptions[plugin.id]?.loa}
onChange={onChange}
allowBlank={true}
choices={plugin.assuranceLevels.map(loa => [loa.value, loa.label])}
/>
</li>
))}
</ul>
</Field>
</FormRow>
<LoAOverrideOption
availableAuthPlugins={availableAuthPlugins}
selectedAuthPlugins={selectedAuthPlugins}
authenticationBackendOptions={form.authenticationBackendOptions}
onChange={onChange}
/>
</>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';

import Field from 'components/admin/forms/Field';
import FormRow from 'components/admin/forms/FormRow';
import Select from 'components/admin/forms/Select';

const LoAOverrideOption = ({
availableAuthPlugins,
selectedAuthPlugins,
authenticationBackendOptions,
onChange,
}) => {
// These are the selected plugins that support overriding the LoA via the Authentication request
const pluginsToDisplay = availableAuthPlugins.filter(
plugin =>
selectedAuthPlugins.includes(plugin.id) &&
plugin.supportsLoaOverride &&
plugin.assuranceLevels.length
);

if (pluginsToDisplay.length === 0) return null;

return (
<FormRow>
<Field
name="form.authenticationBackendOptions"
label={
<FormattedMessage
description="Minimal levels of assurance label"
defaultMessage="Minimal levels of assurance"
/>
}
helpText={
<FormattedMessage
defaultMessage="Override the minimum Level of Assurance. This is not supported by all authentication plugins."
description="Minimal LoA override help text"
/>
}
>
<ul>
{pluginsToDisplay.map(plugin => (
<li key={plugin.id}>
<label htmlFor={`form.authenticationBackendOptions.${plugin.id}.loa`}>
{plugin.label}
</label>
<Select
key={plugin.id}
id={`form.authenticationBackendOptions.${plugin.id}.loa`}
name={`form.authenticationBackendOptions.${plugin.id}.loa`}
value={authenticationBackendOptions[plugin.id]?.loa}
onChange={onChange}
allowBlank={true}
choices={plugin.assuranceLevels.map(loa => [loa.value, loa.label])}
/>
</li>
))}
</ul>
</Field>
</FormRow>
);
};

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,
};
Loading

0 comments on commit dba0ffe

Please sign in to comment.