Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#3969] Remove ability to override LoA for eHekrenning/eIDAS #4045

Merged
merged 8 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading