From 3b63168f426640e34d3fce6f56dc631f8fcffe4d Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 11:23:43 +0100 Subject: [PATCH] :sparkles: [#1902/1903] DigiD/eHerkenning via OIDC tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 --- .../__init__.py | 0 src/digid_eherkenning_oidc_generics/admin.py | 55 +++ src/digid_eherkenning_oidc_generics/apps.py | 7 + .../backends.py | 72 ++++ .../constants.py | 2 + .../digid_settings.py | 2 + .../digid_urls.py | 24 ++ .../eherkenning_settings.py | 2 + .../eherkenning_urls.py | 24 ++ src/digid_eherkenning_oidc_generics/forms.py | 39 ++ .../migrations/0001_initial.py | 400 ++++++++++++++++++ .../migrations/__init__.py | 0 src/digid_eherkenning_oidc_generics/mixins.py | 28 ++ src/digid_eherkenning_oidc_generics/models.py | 111 +++++ src/digid_eherkenning_oidc_generics/views.py | 112 +++++ .../templates/registration/login.html | 58 ++- src/open_inwoner/conf/base.py | 3 + src/open_inwoner/conf/ci.py | 2 + src/open_inwoner/conf/dev.py | 2 + .../conf/fixtures/django-admin-index.json | 8 + src/open_inwoner/conf/production.py | 2 + src/open_inwoner/urls.py | 12 + 22 files changed, 947 insertions(+), 18 deletions(-) create mode 100644 src/digid_eherkenning_oidc_generics/__init__.py create mode 100644 src/digid_eherkenning_oidc_generics/admin.py create mode 100644 src/digid_eherkenning_oidc_generics/apps.py create mode 100644 src/digid_eherkenning_oidc_generics/backends.py create mode 100644 src/digid_eherkenning_oidc_generics/constants.py create mode 100644 src/digid_eherkenning_oidc_generics/digid_settings.py create mode 100644 src/digid_eherkenning_oidc_generics/digid_urls.py create mode 100644 src/digid_eherkenning_oidc_generics/eherkenning_settings.py create mode 100644 src/digid_eherkenning_oidc_generics/eherkenning_urls.py create mode 100644 src/digid_eherkenning_oidc_generics/forms.py create mode 100644 src/digid_eherkenning_oidc_generics/migrations/0001_initial.py create mode 100644 src/digid_eherkenning_oidc_generics/migrations/__init__.py create mode 100644 src/digid_eherkenning_oidc_generics/mixins.py create mode 100644 src/digid_eherkenning_oidc_generics/models.py create mode 100644 src/digid_eherkenning_oidc_generics/views.py diff --git a/src/digid_eherkenning_oidc_generics/__init__.py b/src/digid_eherkenning_oidc_generics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/digid_eherkenning_oidc_generics/admin.py b/src/digid_eherkenning_oidc_generics/admin.py new file mode 100644 index 0000000000..768e74f33e --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin +from solo.admin import SingletonModelAdmin + +from .forms import OpenIDConnectEHerkenningConfigForm, OpenIDConnectPublicConfigForm +from .models import OpenIDConnectEHerkenningConfig, OpenIDConnectPublicConfig + + +class OpenIDConnectConfigBaseAdmin(DynamicArrayMixin, SingletonModelAdmin): + fieldsets = ( + ( + _("Activation"), + {"fields": ("enabled",)}, + ), + ( + _("Common settings"), + { + "fields": ( + "identifier_claim_name", + "oidc_rp_client_id", + "oidc_rp_client_secret", + "oidc_rp_scopes_list", + "oidc_rp_sign_algo", + "oidc_rp_idp_sign_key", + "userinfo_claims_source", + ) + }, + ), + ( + _("Endpoints"), + { + "fields": ( + "oidc_op_discovery_endpoint", + "oidc_op_jwks_endpoint", + "oidc_op_authorization_endpoint", + "oidc_op_token_endpoint", + "oidc_op_user_endpoint", + "oidc_op_logout_endpoint", + ) + }, + ), + (_("Keycloak specific settings"), {"fields": ("oidc_keycloak_idp_hint",)}), + ) + + +@admin.register(OpenIDConnectPublicConfig) +class OpenIDConnectConfigDigiDAdmin(OpenIDConnectConfigBaseAdmin): + form = OpenIDConnectPublicConfigForm + + +@admin.register(OpenIDConnectEHerkenningConfig) +class OpenIDConnectConfigEHerkenningAdmin(OpenIDConnectConfigBaseAdmin): + form = OpenIDConnectEHerkenningConfigForm diff --git a/src/digid_eherkenning_oidc_generics/apps.py b/src/digid_eherkenning_oidc_generics/apps.py new file mode 100644 index 0000000000..77906b4bb5 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class DigiDeHerkenningOIDCAppConfig(AppConfig): + name = "digid_eherkenning_oidc_generics" + verbose_name = _("DigiD & eHerkenning via OpenID Connect") diff --git a/src/digid_eherkenning_oidc_generics/backends.py b/src/digid_eherkenning_oidc_generics/backends.py new file mode 100644 index 0000000000..5ba253da2a --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/backends.py @@ -0,0 +1,72 @@ +import logging + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import SuspiciousOperation + +from mozilla_django_oidc_db.backends import ( + OIDCAuthenticationBackend as _OIDCAuthenticationBackend, +) +from requests.exceptions import RequestException + +from open_inwoner.accounts.choices import LoginTypeChoices + +from .constants import DIGID_OIDC_AUTH_SESSION_KEY, EHERKENNING_OIDC_AUTH_SESSION_KEY +from .mixins import SoloConfigDigiDMixin, SoloConfigEHerkenningMixin + +logger = logging.getLogger(__name__) + + +class OIDCAuthenticationBackend(_OIDCAuthenticationBackend): + config_identifier_field = "identifier_claim_name" + + def filter_users_by_claims(self, claims): + """Return all users matching the specified subject.""" + identifier_claim_name = getattr(self.config, self.config_identifier_field) + unique_id = self.retrieve_identifier_claim(claims) + + if not unique_id: + return self.UserModel.objects.none() + return self.UserModel.objects.filter( + **{f"{identifier_claim_name}__iexact": unique_id} + ) + + def create_user(self, claims): + """Return object for a newly created user account.""" + identifier_claim_name = getattr(self.config, self.config_identifier_field) + unique_id = self.retrieve_identifier_claim(claims) + + logger.debug("Creating OIDC user: %s", unique_id) + + user = self.UserModel.objects.create_user( + **{ + self.UserModel.USERNAME_FIELD: "user-{}@localhost".format(unique_id), + identifier_claim_name: unique_id, + "login_type": self.login_type, + } + ) + + return user + + def update_user(self, user, claims): + # TODO should we do anything here? or do we only fetch data from HaalCentraal + return user + + +class OIDCAuthenticationDigiDBackend(SoloConfigDigiDMixin, OIDCAuthenticationBackend): + """ + Allows logging in via OIDC with DigiD + """ + + session_key = DIGID_OIDC_AUTH_SESSION_KEY + login_type = LoginTypeChoices.digid + + +class OIDCAuthenticationEHerkenningBackend( + SoloConfigEHerkenningMixin, OIDCAuthenticationBackend +): + """ + Allows logging in via OIDC with DigiD + """ + + session_key = EHERKENNING_OIDC_AUTH_SESSION_KEY + login_type = LoginTypeChoices.eherkenning diff --git a/src/digid_eherkenning_oidc_generics/constants.py b/src/digid_eherkenning_oidc_generics/constants.py new file mode 100644 index 0000000000..a2f917db49 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/constants.py @@ -0,0 +1,2 @@ +DIGID_OIDC_AUTH_SESSION_KEY = "digid_oidc:bsn" +EHERKENNING_OIDC_AUTH_SESSION_KEY = "eherkenning_oidc:kvk" diff --git a/src/digid_eherkenning_oidc_generics/digid_settings.py b/src/digid_eherkenning_oidc_generics/digid_settings.py new file mode 100644 index 0000000000..212c1d2303 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/digid_settings.py @@ -0,0 +1,2 @@ +DIGID_CUSTOM_OIDC_DB_PREFIX = "digid_oidc" +OIDC_AUTHENTICATION_CALLBACK_URL = "digid_oidc:callback" diff --git a/src/digid_eherkenning_oidc_generics/digid_urls.py b/src/digid_eherkenning_oidc_generics/digid_urls.py new file mode 100644 index 0000000000..8ef8c21a17 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/digid_urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from mozilla_django_oidc.urls import urlpatterns + +from .views import ( + DigiDOIDCAuthenticationCallbackView, + DigiDOIDCAuthenticationRequestView, +) + +app_name = "digid_oidc" + + +urlpatterns = [ + path( + "callback/", + DigiDOIDCAuthenticationCallbackView.as_view(), + name="callback", + ), + path( + "authenticate/", + DigiDOIDCAuthenticationRequestView.as_view(), + name="init", + ), +] + urlpatterns diff --git a/src/digid_eherkenning_oidc_generics/eherkenning_settings.py b/src/digid_eherkenning_oidc_generics/eherkenning_settings.py new file mode 100644 index 0000000000..3b8c2871bf --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/eherkenning_settings.py @@ -0,0 +1,2 @@ +EHERKENNING_CUSTOM_OIDC_DB_PREFIX = "eherkenning_oidc" +OIDC_AUTHENTICATION_CALLBACK_URL = "eherkenning_oidc:callback" diff --git a/src/digid_eherkenning_oidc_generics/eherkenning_urls.py b/src/digid_eherkenning_oidc_generics/eherkenning_urls.py new file mode 100644 index 0000000000..c1fbbd4523 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/eherkenning_urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from mozilla_django_oidc.urls import urlpatterns + +from .views import ( + eHerkenningOIDCAuthenticationCallbackView, + eHerkenningOIDCAuthenticationRequestView, +) + +app_name = "eherkenning_oidc" + + +urlpatterns = [ + path( + "callback/", + eHerkenningOIDCAuthenticationCallbackView.as_view(), + name="callback", + ), + path( + "authenticate/", + eHerkenningOIDCAuthenticationRequestView.as_view(), + name="init", + ), +] + urlpatterns diff --git a/src/digid_eherkenning_oidc_generics/forms.py b/src/digid_eherkenning_oidc_generics/forms.py new file mode 100644 index 0000000000..90d1be0ee5 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/forms.py @@ -0,0 +1,39 @@ +from copy import deepcopy + +from django.utils.translation import gettext_lazy as _ + +from mozilla_django_oidc_db.constants import OIDC_MAPPING as _OIDC_MAPPING +from mozilla_django_oidc_db.forms import OpenIDConnectConfigForm + +from .models import OpenIDConnectEHerkenningConfig, OpenIDConnectPublicConfig + +OIDC_MAPPING = deepcopy(_OIDC_MAPPING) + +OIDC_MAPPING["oidc_op_logout_endpoint"] = "end_session_endpoint" + + +class OpenIDConnectBaseConfigForm(OpenIDConnectConfigForm): + required_endpoints = [ + "oidc_op_authorization_endpoint", + "oidc_op_token_endpoint", + "oidc_op_user_endpoint", + "oidc_op_logout_endpoint", + ] + oidc_mapping = OIDC_MAPPING + plugin_identifier = "" + + +class OpenIDConnectPublicConfigForm(OpenIDConnectBaseConfigForm): + plugin_identifier = "digid_oidc" + + class Meta: + model = OpenIDConnectPublicConfig + fields = "__all__" + + +class OpenIDConnectEHerkenningConfigForm(OpenIDConnectBaseConfigForm): + plugin_identifier = "eherkenning_oidc" + + class Meta: + model = OpenIDConnectEHerkenningConfig + fields = "__all__" diff --git a/src/digid_eherkenning_oidc_generics/migrations/0001_initial.py b/src/digid_eherkenning_oidc_generics/migrations/0001_initial.py new file mode 100644 index 0000000000..a1d99794b6 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/migrations/0001_initial.py @@ -0,0 +1,400 @@ +# Generated by Django 3.2.20 on 2023-12-04 14:41 + +import digid_eherkenning_oidc_generics.models +from django.db import migrations, models +import django_better_admin_arrayfield.models.fields +import mozilla_django_oidc_db.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="OpenIDConnectEHerkenningConfig", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "enabled", + models.BooleanField( + default=False, + help_text="Indicates whether OpenID Connect for authentication/authorization is enabled", + verbose_name="enable", + ), + ), + ( + "oidc_rp_client_id", + models.CharField( + help_text="OpenID Connect client ID provided by the OIDC Provider", + max_length=1000, + verbose_name="OpenID Connect client ID", + ), + ), + ( + "oidc_rp_client_secret", + models.CharField( + help_text="OpenID Connect secret provided by the OIDC Provider", + max_length=1000, + verbose_name="OpenID Connect secret", + ), + ), + ( + "oidc_rp_sign_algo", + models.CharField( + default="HS256", + help_text="Algorithm the Identity Provider uses to sign ID tokens", + max_length=50, + verbose_name="OpenID sign algorithm", + ), + ), + ( + "oidc_op_discovery_endpoint", + models.URLField( + blank=True, + help_text="URL of your OpenID Connect provider discovery endpoint ending with a slash (`.well-known/...` will be added automatically). If this is provided, the remaining endpoints can be omitted, as they will be derived from this endpoint.", + max_length=1000, + verbose_name="Discovery endpoint", + ), + ), + ( + "oidc_op_jwks_endpoint", + models.URLField( + blank=True, + help_text="URL of your OpenID Connect provider JSON Web Key Set endpoint. Required if `RS256` is used as signing algorithm.", + max_length=1000, + verbose_name="JSON Web Key Set endpoint", + ), + ), + ( + "oidc_op_authorization_endpoint", + models.URLField( + help_text="URL of your OpenID Connect provider authorization endpoint", + max_length=1000, + verbose_name="Authorization endpoint", + ), + ), + ( + "oidc_op_token_endpoint", + models.URLField( + help_text="URL of your OpenID Connect provider token endpoint", + max_length=1000, + verbose_name="Token endpoint", + ), + ), + ( + "oidc_op_user_endpoint", + models.URLField( + help_text="URL of your OpenID Connect provider userinfo endpoint", + max_length=1000, + verbose_name="User endpoint", + ), + ), + ( + "oidc_rp_idp_sign_key", + models.CharField( + blank=True, + help_text="Key the Identity Provider uses to sign ID tokens in the case of an RSA sign algorithm. Should be the signing key in PEM or DER format.", + max_length=1000, + verbose_name="Sign key", + ), + ), + ( + "oidc_use_nonce", + models.BooleanField( + default=True, + help_text="Controls whether the OpenID Connect client uses nonce verification", + verbose_name="Use nonce", + ), + ), + ( + "oidc_nonce_size", + models.PositiveIntegerField( + default=32, + help_text="Sets the length of the random string used for OpenID Connect nonce verification", + verbose_name="Nonce size", + ), + ), + ( + "oidc_state_size", + models.PositiveIntegerField( + default=32, + help_text="Sets the length of the random string used for OpenID Connect state verification", + verbose_name="State size", + ), + ), + ( + "oidc_exempt_urls", + django_better_admin_arrayfield.models.fields.ArrayField( + base_field=models.CharField( + max_length=1000, verbose_name="Exempt URL" + ), + blank=True, + default=list, + help_text="This is a list of absolute url paths, regular expressions for url paths, or Django view names. This plus the mozilla-django-oidc urls are exempted from the session renewal by the SessionRefresh middleware.", + size=None, + verbose_name="URLs exempt from session renewal", + ), + ), + ( + "userinfo_claims_source", + models.CharField( + choices=[ + ("userinfo_endpoint", "Userinfo endpoint"), + ("id_token", "ID token"), + ], + default="userinfo_endpoint", + help_text="Indicates the source from which the user information claims should be extracted.", + max_length=100, + verbose_name="user information claims extracted from", + ), + ), + ( + "oidc_op_logout_endpoint", + models.URLField( + blank=True, + help_text="URL of your OpenID Connect provider logout endpoint", + max_length=1000, + verbose_name="Logout endpoint", + ), + ), + ( + "oidc_keycloak_idp_hint", + models.CharField( + blank=True, + help_text="Specific for Keycloak: parameter that indicates which identity provider should be used (therefore skipping the Keycloak login screen).", + max_length=1000, + verbose_name="Keycloak Identity Provider hint", + ), + ), + ( + "identifier_claim_name", + models.CharField( + default="kvk", + help_text="The name of the claim in which the KVK of the user is stored", + max_length=100, + verbose_name="KVK claim name", + ), + ), + ( + "oidc_rp_scopes_list", + django_better_admin_arrayfield.models.fields.ArrayField( + base_field=models.CharField( + max_length=50, verbose_name="OpenID Connect scope" + ), + blank=True, + default=digid_eherkenning_oidc_generics.models.get_default_scopes_kvk, + help_text="OpenID Connect scopes that are requested during login. These scopes are hardcoded and must be supported by the identity provider", + size=None, + verbose_name="OpenID Connect scopes", + ), + ), + ], + options={ + "verbose_name": "OpenID Connect configuration for eHerkenning", + }, + bases=(mozilla_django_oidc_db.models.CachingMixin, models.Model), + ), + migrations.CreateModel( + name="OpenIDConnectPublicConfig", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "enabled", + models.BooleanField( + default=False, + help_text="Indicates whether OpenID Connect for authentication/authorization is enabled", + verbose_name="enable", + ), + ), + ( + "oidc_rp_client_id", + models.CharField( + help_text="OpenID Connect client ID provided by the OIDC Provider", + max_length=1000, + verbose_name="OpenID Connect client ID", + ), + ), + ( + "oidc_rp_client_secret", + models.CharField( + help_text="OpenID Connect secret provided by the OIDC Provider", + max_length=1000, + verbose_name="OpenID Connect secret", + ), + ), + ( + "oidc_rp_sign_algo", + models.CharField( + default="HS256", + help_text="Algorithm the Identity Provider uses to sign ID tokens", + max_length=50, + verbose_name="OpenID sign algorithm", + ), + ), + ( + "oidc_op_discovery_endpoint", + models.URLField( + blank=True, + help_text="URL of your OpenID Connect provider discovery endpoint ending with a slash (`.well-known/...` will be added automatically). If this is provided, the remaining endpoints can be omitted, as they will be derived from this endpoint.", + max_length=1000, + verbose_name="Discovery endpoint", + ), + ), + ( + "oidc_op_jwks_endpoint", + models.URLField( + blank=True, + help_text="URL of your OpenID Connect provider JSON Web Key Set endpoint. Required if `RS256` is used as signing algorithm.", + max_length=1000, + verbose_name="JSON Web Key Set endpoint", + ), + ), + ( + "oidc_op_authorization_endpoint", + models.URLField( + help_text="URL of your OpenID Connect provider authorization endpoint", + max_length=1000, + verbose_name="Authorization endpoint", + ), + ), + ( + "oidc_op_token_endpoint", + models.URLField( + help_text="URL of your OpenID Connect provider token endpoint", + max_length=1000, + verbose_name="Token endpoint", + ), + ), + ( + "oidc_op_user_endpoint", + models.URLField( + help_text="URL of your OpenID Connect provider userinfo endpoint", + max_length=1000, + verbose_name="User endpoint", + ), + ), + ( + "oidc_rp_idp_sign_key", + models.CharField( + blank=True, + help_text="Key the Identity Provider uses to sign ID tokens in the case of an RSA sign algorithm. Should be the signing key in PEM or DER format.", + max_length=1000, + verbose_name="Sign key", + ), + ), + ( + "oidc_use_nonce", + models.BooleanField( + default=True, + help_text="Controls whether the OpenID Connect client uses nonce verification", + verbose_name="Use nonce", + ), + ), + ( + "oidc_nonce_size", + models.PositiveIntegerField( + default=32, + help_text="Sets the length of the random string used for OpenID Connect nonce verification", + verbose_name="Nonce size", + ), + ), + ( + "oidc_state_size", + models.PositiveIntegerField( + default=32, + help_text="Sets the length of the random string used for OpenID Connect state verification", + verbose_name="State size", + ), + ), + ( + "oidc_exempt_urls", + django_better_admin_arrayfield.models.fields.ArrayField( + base_field=models.CharField( + max_length=1000, verbose_name="Exempt URL" + ), + blank=True, + default=list, + help_text="This is a list of absolute url paths, regular expressions for url paths, or Django view names. This plus the mozilla-django-oidc urls are exempted from the session renewal by the SessionRefresh middleware.", + size=None, + verbose_name="URLs exempt from session renewal", + ), + ), + ( + "userinfo_claims_source", + models.CharField( + choices=[ + ("userinfo_endpoint", "Userinfo endpoint"), + ("id_token", "ID token"), + ], + default="userinfo_endpoint", + help_text="Indicates the source from which the user information claims should be extracted.", + max_length=100, + verbose_name="user information claims extracted from", + ), + ), + ( + "oidc_op_logout_endpoint", + models.URLField( + blank=True, + help_text="URL of your OpenID Connect provider logout endpoint", + max_length=1000, + verbose_name="Logout endpoint", + ), + ), + ( + "oidc_keycloak_idp_hint", + models.CharField( + blank=True, + help_text="Specific for Keycloak: parameter that indicates which identity provider should be used (therefore skipping the Keycloak login screen).", + max_length=1000, + verbose_name="Keycloak Identity Provider hint", + ), + ), + ( + "identifier_claim_name", + models.CharField( + default="bsn", + help_text="The name of the claim in which the BSN of the user is stored", + max_length=100, + verbose_name="BSN claim name", + ), + ), + ( + "oidc_rp_scopes_list", + django_better_admin_arrayfield.models.fields.ArrayField( + base_field=models.CharField( + max_length=50, verbose_name="OpenID Connect scope" + ), + blank=True, + default=digid_eherkenning_oidc_generics.models.get_default_scopes_bsn, + help_text="OpenID Connect scopes that are requested during login. These scopes are hardcoded and must be supported by the identity provider", + size=None, + verbose_name="OpenID Connect scopes", + ), + ), + ], + options={ + "verbose_name": "OpenID Connect configuration for DigiD", + }, + bases=(mozilla_django_oidc_db.models.CachingMixin, models.Model), + ), + ] diff --git a/src/digid_eherkenning_oidc_generics/migrations/__init__.py b/src/digid_eherkenning_oidc_generics/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/digid_eherkenning_oidc_generics/mixins.py b/src/digid_eherkenning_oidc_generics/mixins.py new file mode 100644 index 0000000000..1c2b8f5810 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/mixins.py @@ -0,0 +1,28 @@ +import logging + +from mozilla_django_oidc_db.mixins import SoloConfigMixin as _SoloConfigMixin + +from . import digid_settings, eherkenning_settings +from .models import OpenIDConnectEHerkenningConfig, OpenIDConnectPublicConfig + +logger = logging.getLogger(__name__) + + +class SoloConfigMixin(_SoloConfigMixin): + config_class = "" + settings_attribute = None + + def get_settings(self, attr, *args): + if hasattr(self.settings_attribute, attr): + return getattr(self.settings_attribute, attr) + return super().get_settings(attr, *args) + + +class SoloConfigDigiDMixin(SoloConfigMixin): + config_class = OpenIDConnectPublicConfig + settings_attribute = digid_settings + + +class SoloConfigEHerkenningMixin(SoloConfigMixin): + config_class = OpenIDConnectEHerkenningConfig + settings_attribute = eherkenning_settings diff --git a/src/digid_eherkenning_oidc_generics/models.py b/src/digid_eherkenning_oidc_generics/models.py new file mode 100644 index 0000000000..0fe43b5b39 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/models.py @@ -0,0 +1,111 @@ +from django.db import models +from django.utils.functional import classproperty +from django.utils.translation import gettext_lazy as _ + +from django_better_admin_arrayfield.models.fields import ArrayField +from mozilla_django_oidc_db.models import CachingMixin, OpenIDConnectConfigBase + +from .digid_settings import DIGID_CUSTOM_OIDC_DB_PREFIX +from .eherkenning_settings import EHERKENNING_CUSTOM_OIDC_DB_PREFIX + + +def get_default_scopes_bsn(): + """ + Returns the default scopes to request for OpenID Connect logins + """ + return ["openid", "bsn"] + + +def get_default_scopes_kvk(): + """ + Returns the default scopes to request for OpenID Connect logins + """ + return ["openid", "kvk"] + + +class OpenIDConnectBaseConfig(CachingMixin, OpenIDConnectConfigBase): + """ + Configuration for DigiD authentication via OpenID connect + """ + + oidc_op_logout_endpoint = models.URLField( + _("Logout endpoint"), + max_length=1000, + help_text=_("URL of your OpenID Connect provider logout endpoint"), + blank=True, + ) + + # Keycloak specific config + oidc_keycloak_idp_hint = models.CharField( + _("Keycloak Identity Provider hint"), + max_length=1000, + help_text=_( + "Specific for Keycloak: parameter that indicates which identity provider " + "should be used (therefore skipping the Keycloak login screen)." + ), + blank=True, + ) + + class Meta: + verbose_name = _("OpenID Connect configuration") + abstract = True + + +class OpenIDConnectPublicConfig(OpenIDConnectBaseConfig): + """ + Configuration for DigiD authentication via OpenID connect + """ + + identifier_claim_name = models.CharField( + _("BSN claim name"), + max_length=100, + help_text=_("The name of the claim in which the BSN of the user is stored"), + default="bsn", + ) + oidc_rp_scopes_list = ArrayField( + verbose_name=_("OpenID Connect scopes"), + base_field=models.CharField(_("OpenID Connect scope"), max_length=50), + default=get_default_scopes_bsn, + blank=True, + help_text=_( + "OpenID Connect scopes that are requested during login. " + "These scopes are hardcoded and must be supported by the identity provider" + ), + ) + + @classproperty + def custom_oidc_db_prefix(cls): + return DIGID_CUSTOM_OIDC_DB_PREFIX + + class Meta: + verbose_name = _("OpenID Connect configuration for DigiD") + + +class OpenIDConnectEHerkenningConfig(OpenIDConnectBaseConfig): + """ + Configuration for eHerkenning authentication via OpenID connect + """ + + identifier_claim_name = models.CharField( + _("KVK claim name"), + max_length=100, + help_text=_("The name of the claim in which the KVK of the user is stored"), + default="kvk", + ) + oidc_rp_scopes_list = ArrayField( + verbose_name=_("OpenID Connect scopes"), + base_field=models.CharField(_("OpenID Connect scope"), max_length=50), + default=get_default_scopes_kvk, + blank=True, + help_text=_( + "OpenID Connect scopes that are requested during login. " + "These scopes are hardcoded and must be supported by the identity provider" + ), + ) + + @classproperty + def custom_oidc_db_prefix(cls): + return EHERKENNING_CUSTOM_OIDC_DB_PREFIX + + class Meta: + verbose_name = _("OpenID Connect configuration for eHerkenning") diff --git a/src/digid_eherkenning_oidc_generics/views.py b/src/digid_eherkenning_oidc_generics/views.py new file mode 100644 index 0000000000..fd484817f0 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/views.py @@ -0,0 +1,112 @@ +import logging + +from django.contrib import auth +from django.core.exceptions import SuspiciousOperation + +from mozilla_django_oidc.views import ( + OIDCAuthenticationCallbackView as _OIDCAuthenticationCallbackView, + OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView, +) + +from digid_eherkenning_oidc_generics.mixins import ( + SoloConfigDigiDMixin, + SoloConfigEHerkenningMixin, +) + +from .backends import ( + OIDCAuthenticationDigiDBackend, + OIDCAuthenticationEHerkenningBackend, +) + +logger = logging.getLogger(__name__) + + +class OIDCAuthenticationRequestView(_OIDCAuthenticationRequestView): + def get_extra_params(self, request): + kc_idp_hint = self.get_settings("OIDC_KEYCLOAK_IDP_HINT", "") + if kc_idp_hint: + return {"kc_idp_hint": kc_idp_hint} + return {} + + +class OIDCAuthenticationCallbackView(_OIDCAuthenticationCallbackView): + auth_backend_class = None + + # TODO find easier way to authenticate using the backend class directly, without + # having to override all of this + def get(self, request): + """ + Callback handler for OIDC authorization code flow + """ + + if request.GET.get("error"): + # Ouch! Something important failed. + # Make sure the user doesn't get to continue to be logged in + # otherwise the refresh middleware will force the user to + # redirect to authorize again if the session refresh has + # expired. + if request.user.is_authenticated: + auth.logout(request) + assert not request.user.is_authenticated + elif "code" in request.GET and "state" in request.GET: + + # Check instead of "oidc_state" check if the "oidc_states" session key exists! + if "oidc_states" not in request.session: + return self.login_failure() + + # State and Nonce are stored in the session "oidc_states" dictionary. + # State is the key, the value is a dictionary with the Nonce in the "nonce" field. + state = request.GET.get("state") + if state not in request.session["oidc_states"]: + msg = "OIDC callback state not found in session `oidc_states`!" + raise SuspiciousOperation(msg) + + # Get the nonce from the dictionary for further processing and delete the entry to + # prevent replay attacks. + nonce = request.session["oidc_states"][state]["nonce"] + del request.session["oidc_states"][state] + + # Authenticating is slow, so save the updated oidc_states. + request.session.save() + # Reset the session. This forces the session to get reloaded from the database after + # fetching the token from the OpenID connect provider. + # Without this step we would overwrite items that are being added/removed from the + # session in parallel browser tabs. + request.session = request.session.__class__(request.session.session_key) + + kwargs = { + "request": request, + "nonce": nonce, + } + self.user = self.auth_backend_class().authenticate(**kwargs) + self.user.backend = f"{self.auth_backend_class.__module__}.{self.auth_backend_class.__name__}" + + if self.user and self.user.is_active: + return self.login_success() + return self.login_failure() + + +class DigiDOIDCAuthenticationRequestView( + SoloConfigDigiDMixin, OIDCAuthenticationRequestView +): + plugin_identifier = "digid_oidc" + + +class DigiDOIDCAuthenticationCallbackView( + SoloConfigDigiDMixin, OIDCAuthenticationCallbackView +): + plugin_identifier = "digid_oidc" + auth_backend_class = OIDCAuthenticationDigiDBackend + + +class eHerkenningOIDCAuthenticationRequestView( + SoloConfigEHerkenningMixin, OIDCAuthenticationRequestView +): + plugin_identifier = "eherkenning_oidc" + + +class eHerkenningOIDCAuthenticationCallbackView( + SoloConfigEHerkenningMixin, OIDCAuthenticationCallbackView +): + plugin_identifier = "eherkenning_oidc" + auth_backend_class = OIDCAuthenticationEHerkenningBackend diff --git a/src/open_inwoner/accounts/templates/registration/login.html b/src/open_inwoner/accounts/templates/registration/login.html index 68570629b8..cc4d12ae99 100644 --- a/src/open_inwoner/accounts/templates/registration/login.html +++ b/src/open_inwoner/accounts/templates/registration/login.html @@ -16,27 +16,49 @@

{% trans 'Welkom' %}

{% if login_text %}

{{ login_text|linebreaksbr }}

{% endif %}
{% if settings.DIGID_ENABLED %} - {% render_card direction='horizontal' tinted=True %} - - {% url 'digid:login' as href %} - {% with href|addnexturl:next as href_with_next %} - {% link href=href_with_next text=_('Inloggen met DigiD') secondary=True icon='arrow_forward' extra_classes="link--digid" %} - {% endwith %} - {% endrender_card %} + {% get_solo 'digid_eherkenning_oidc_generics.OpenIDConnectPublicConfig' as digid_oidc_config %} + {% if digid_oidc_config.enabled %} + {% render_card direction='horizontal' tinted=True %} + + {% url 'digid_oidc:init' as href %} + {% link href=href text=_('Inloggen met DigiD') secondary=True icon='arrow_forward' extra_classes="link--digid" %} + {% endrender_card %} + {% else %} + {% render_card direction='horizontal' tinted=True %} + + {% url 'digid:login' as href %} + {% with href|addnexturl:next as href_with_next %} + {% link href=href_with_next text=_('Inloggen met DigiD') secondary=True icon='arrow_forward' extra_classes="link--digid" %} + {% endwith %} + {% endrender_card %} + {% endif %} {% endif %} {% if eherkenning_enabled %} - {% render_card direction='horizontal' tinted=True %} - - {% url 'eherkenning:login' as href %} - {% with href|addnexturl:next as href_with_next %} - {% link href=href_with_next text=_('Inloggen met eHerkenning') secondary=True icon='arrow_forward' extra_classes="link--eherkenning" %} - {% endwith %} - {% endrender_card %} + {% get_solo 'digid_eherkenning_oidc_generics.OpenIDConnectEHerkenningConfig' as eherkenning_oidc_config %} + {% if eherkenning_oidc_config.enabled %} + {% render_card direction='horizontal' tinted=True %} + + {% url 'eherkenning_oidc:init' as href %} + {% link href=href text=_('Inloggen met eHerkenning') secondary=True icon='arrow_forward' extra_classes="link--eherkenning" %} + {% endrender_card %} + {% else %} + {% render_card direction='horizontal' tinted=True %} + + {% url 'eherkenning:login' as href %} + {% with href|addnexturl:next as href_with_next %} + {% link href=href_with_next text=_('Inloggen met eHerkenning') secondary=True icon='arrow_forward' extra_classes="link--eherkenning" %} + {% endwith %} + {% endrender_card %} + {% endif %} {% endif %} {% get_solo 'mozilla_django_oidc_db.OpenIDConnectConfig' as oidc_config %} diff --git a/src/open_inwoner/conf/base.py b/src/open_inwoner/conf/base.py index 8acd1aaf39..0a66a9e5c1 100644 --- a/src/open_inwoner/conf/base.py +++ b/src/open_inwoner/conf/base.py @@ -192,6 +192,7 @@ "cspreports", "mozilla_django_oidc", "mozilla_django_oidc_db", + "digid_eherkenning_oidc_generics", "sessionprofile", "openformsclient", "django_htmx", @@ -474,6 +475,8 @@ "django.contrib.auth.backends.ModelBackend", "digid_eherkenning.backends.DigiDBackend", "eherkenning.backends.eHerkenningBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationDigiDBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationEHerkenningBackend", "open_inwoner.accounts.backends.CustomOIDCBackend", ] diff --git a/src/open_inwoner/conf/ci.py b/src/open_inwoner/conf/ci.py index 7994817d24..dc6ad8673f 100644 --- a/src/open_inwoner/conf/ci.py +++ b/src/open_inwoner/conf/ci.py @@ -36,6 +36,8 @@ # mock login like dev.py "digid_eherkenning.mock.backends.DigiDBackend", "eherkenning.mock.backends.eHerkenningBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationDigiDBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationEHerkenningBackend", "open_inwoner.accounts.backends.CustomOIDCBackend", ] diff --git a/src/open_inwoner/conf/dev.py b/src/open_inwoner/conf/dev.py index d834627228..aa61344188 100644 --- a/src/open_inwoner/conf/dev.py +++ b/src/open_inwoner/conf/dev.py @@ -87,6 +87,8 @@ "django.contrib.auth.backends.ModelBackend", "digid_eherkenning.mock.backends.DigiDBackend", "eherkenning.mock.backends.eHerkenningBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationDigiDBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationEHerkenningBackend", "open_inwoner.accounts.backends.CustomOIDCBackend", ] diff --git a/src/open_inwoner/conf/fixtures/django-admin-index.json b/src/open_inwoner/conf/fixtures/django-admin-index.json index 5a2e4396c1..71385e6b10 100644 --- a/src/open_inwoner/conf/fixtures/django-admin-index.json +++ b/src/open_inwoner/conf/fixtures/django-admin-index.json @@ -304,6 +304,14 @@ "digid_eherkenning", "eherkenningconfiguration" ], + [ + "digid_eherkenning_oidc_generics", + "openidconnecteherkenningconfig" + ], + [ + "digid_eherkenning_oidc_generics", + "openidconnectpublicconfig" + ], [ "haalcentraal", "haalcentraalconfig" diff --git a/src/open_inwoner/conf/production.py b/src/open_inwoner/conf/production.py index 42546f967f..6d26f5ed16 100644 --- a/src/open_inwoner/conf/production.py +++ b/src/open_inwoner/conf/production.py @@ -16,6 +16,8 @@ "open_inwoner.accounts.backends.CustomAxesBackend", "open_inwoner.accounts.backends.UserModelEmailBackend", "django.contrib.auth.backends.ModelBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationDigiDBackend", + "digid_eherkenning_oidc_generics.backends.OIDCAuthenticationEHerkenningBackend", "open_inwoner.accounts.backends.CustomOIDCBackend", ] diff --git a/src/open_inwoner/urls.py b/src/open_inwoner/urls.py index 9028e9d8a5..ce519a4cf1 100644 --- a/src/open_inwoner/urls.py +++ b/src/open_inwoner/urls.py @@ -107,6 +107,18 @@ ), path("contactformulier/", ContactFormView.as_view(), name="contactform"), path("oidc/", include("mozilla_django_oidc.urls")), + path( + "digid-oidc/", + include( + "digid_eherkenning_oidc_generics.digid_urls", + ), + ), + path( + "eherkenning-oidc/", + include( + "digid_eherkenning_oidc_generics.eherkenning_urls", + ), + ), path("faq/", FAQView.as_view(), name="general_faq"), path("yubin/", include("django_yubin.urls")), path("apimock/", include("open_inwoner.apimock.urls")),