From 830602c4d6c65e10d46e0366309856cea6faa035 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Mon, 4 Dec 2023 15:40:20 +0100 Subject: [PATCH 1/9] :arrow_up: [#1902/1903] Bump mozilla-django-oidc-db to 0.12.0 tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 --- requirements/base.txt | 2 +- requirements/ci.txt | 2 +- requirements/dev.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 461886fbc0..b624859069 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -366,7 +366,7 @@ messagebird==2.1.0 # via -r requirements/base.in mozilla-django-oidc==2.0.0 # via mozilla-django-oidc-db -mozilla-django-oidc-db==0.10.0 +mozilla-django-oidc-db==0.12.0 # via -r requirements/base.in notifications-api-common==0.2.0 # via -r requirements/base.in diff --git a/requirements/ci.txt b/requirements/ci.txt index ee2acc3be3..6a010d48af 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -643,7 +643,7 @@ mozilla-django-oidc==2.0.0 # -c requirements/base.txt # -r requirements/base.txt # mozilla-django-oidc-db -mozilla-django-oidc-db==0.10.0 +mozilla-django-oidc-db==0.12.0 # via # -c requirements/base.txt # -r requirements/base.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index f0ab92f9a7..21cab9e725 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -705,7 +705,7 @@ mozilla-django-oidc==2.0.0 # -c requirements/ci.txt # -r requirements/ci.txt # mozilla-django-oidc-db -mozilla-django-oidc-db==0.10.0 +mozilla-django-oidc-db==0.12.0 # via # -c requirements/ci.txt # -r requirements/ci.txt From b0a058fd368ec21292ceb3682d6720ef4ea91e09 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 11:12:42 +0100 Subject: [PATCH 2/9] :whale: [#1902/1903] Add docker setup for Keycloak to test OIDC tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 --- docker/docker-compose.keycloak.yml | 21 + docker/keycloak/README.md | 47 + docker/keycloak/fixtures/oidc.json | 52 + docker/keycloak/fixtures/realm.json | 2680 +++++++++++++++++++++++++++ 4 files changed, 2800 insertions(+) create mode 100644 docker/docker-compose.keycloak.yml create mode 100644 docker/keycloak/README.md create mode 100644 docker/keycloak/fixtures/oidc.json create mode 100644 docker/keycloak/fixtures/realm.json diff --git a/docker/docker-compose.keycloak.yml b/docker/docker-compose.keycloak.yml new file mode 100644 index 0000000000..cd09eba4e6 --- /dev/null +++ b/docker/docker-compose.keycloak.yml @@ -0,0 +1,21 @@ +version: '3.4' + +services: + keycloak: + image: jboss/keycloak + environment: + - KEYCLOAK_USER=demo + - KEYCLOAK_PASSWORD=demo + - KEYCLOAK_IMPORT=/realm.json + volumes: + - ./keycloak/fixtures/realm.json:/realm.json + ports: + - 8080:8080 + networks: + open-inwoner-dev: + aliases: + - keycloak.open-inwoner.local + +networks: + open-inwoner-dev: + name: open-inwoner-dev diff --git a/docker/keycloak/README.md b/docker/keycloak/README.md new file mode 100644 index 0000000000..bfc02689fd --- /dev/null +++ b/docker/keycloak/README.md @@ -0,0 +1,47 @@ +# Keycloak infrastructure + +Open Inwoner supports OpenID Connect as an authentication protocol. Keycloak is +an example of an Identity Provider that supports OIDC. + +We include a compose stack for development and CI purposes. This is **NOT** suitable +for production usage. + +## docker-compose + +Start a Keycloak instance in your local environment from the parent directory: + +```bash +docker-compose -f docker-compose.keycloak.yml up -d +``` + +This brings up Keycloak, the admin interface is accessible at http://localhost:8080/. +You can log in with `demo:demo`. + +In order to allow access to Keycloak via the same hostname via the Open Inwoner backend +container and the browser, add the following entry to your `/etc/hosts` file: + +``` +127.0.0.1 keycloak.open-inwoner.local +``` + + +## Load fixtures + +Before the DigiD login via OIDC can be tested, a fixture needs to be loaded. +Assuming the docker containers specified in `docker-compose.yml` in the root directory +are running, run the following command: + +```bash +cat docker/keycloak/fixtures/oidc.json | docker-compose exec web src/manage.py loaddata --format=json - +``` + +This loads an example form configured to use DigiD via OIDC for authentication and +it loads a configuration to connect to our Keycloak instance. + +## Test login flow + +To test the login flow, navigate to `http://127.0.0.1:8000/digid-oidc/` +(not `localhost`, because this domain is not on the allowlist in the Keycloak config). + +Click `Inloggen met DigiD` and fill in `testuser` for both username and password +in the Keycloak login screen. If everything succeeded, you are now redirected back to the form. diff --git a/docker/keycloak/fixtures/oidc.json b/docker/keycloak/fixtures/oidc.json new file mode 100644 index 0000000000..f31fe260db --- /dev/null +++ b/docker/keycloak/fixtures/oidc.json @@ -0,0 +1,52 @@ +[ + { + "model": "digid_eherkenning_oidc_generics.openidconnectdigidconfig", + "pk": 1, + "fields": { + "enabled": true, + "oidc_rp_client_id": "testid_public", + "oidc_rp_client_secret": "23a12032-e080-4f65-b733-ad2567ec1605", + "oidc_rp_sign_algo": "RS256", + "oidc_op_discovery_endpoint": "http://localhost:8080/auth/realms/test/", + "oidc_op_jwks_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/certs", + "oidc_op_authorization_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/auth", + "oidc_op_token_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/token", + "oidc_op_user_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/userinfo", + "oidc_rp_idp_sign_key": "", + "oidc_use_nonce": true, + "oidc_nonce_size": 32, + "oidc_state_size": 32, + "oidc_exempt_urls": "[]", + "userinfo_claims_source": "userinfo_endpoint", + "oidc_op_logout_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/logout", + "oidc_keycloak_idp_hint": "", + "identifier_claim_name": "bsn", + "oidc_rp_scopes_list": "[\"openid\", \"bsn\"]" + } + }, + { + "model": "digid_eherkenning_oidc_generics.openidconnecteherkenningconfig", + "pk": 1, + "fields": { + "enabled": true, + "oidc_rp_client_id": "testid_public", + "oidc_rp_client_secret": "23a12032-e080-4f65-b733-ad2567ec1605", + "oidc_rp_sign_algo": "RS256", + "oidc_op_discovery_endpoint": "http://localhost:8080/auth/realms/test/", + "oidc_op_jwks_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/certs", + "oidc_op_authorization_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/auth", + "oidc_op_token_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/token", + "oidc_op_user_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/userinfo", + "oidc_rp_idp_sign_key": "", + "oidc_use_nonce": true, + "oidc_nonce_size": 32, + "oidc_state_size": 32, + "oidc_exempt_urls": "[]", + "userinfo_claims_source": "userinfo_endpoint", + "oidc_op_logout_endpoint": "http://localhost:8080/auth/realms/test/protocol/openid-connect/logout", + "oidc_keycloak_idp_hint": "", + "identifier_claim_name": "kvk", + "oidc_rp_scopes_list": "[\"openid\", \"kvk\"]" + } + } +] \ No newline at end of file diff --git a/docker/keycloak/fixtures/realm.json b/docker/keycloak/fixtures/realm.json new file mode 100644 index 0000000000..e767710215 --- /dev/null +++ b/docker/keycloak/fixtures/realm.json @@ -0,0 +1,2680 @@ +{ + "id": "test", + "realm": "test", + "displayName": "Keycloak", + "displayNameHtml": "
Keycloak
", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 60, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 600, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "0a58bc72-3b61-446d-9686-680dac2d7837", + "name": "bsn_role", + "composite": false, + "clientRole": false, + "containerId": "test", + "attributes": { + "bsn": [ + "123456782" + ] + } + }, + { + "id": "136f9153-1b58-4925-a8d9-6cd3b5b14460", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "test", + "attributes": {} + }, + { + "id": "dfb35e47-58b6-4080-a666-e3855f80dae0", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "test", + "attributes": {} + }, + { + "id": "0b34dd0e-fba9-48a1-a139-6400f32d1361", + "name": "default-roles-master", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "test", + "attributes": {} + }, + { + "id": "2815411c-85c2-494c-b0dd-7ba1765c20eb", + "name": "admin", + "description": "${role_admin}", + "composite": true, + "composites": { + "realm": [ + "create-realm" + ], + "client": { + "master-realm": [ + "view-realm", + "manage-authorization", + "query-clients", + "view-clients", + "manage-users", + "impersonation", + "view-authorization", + "manage-events", + "query-realms", + "view-users", + "manage-clients", + "view-identity-providers", + "manage-identity-providers", + "query-groups", + "query-users", + "create-client", + "manage-realm", + "view-events" + ] + } + }, + "clientRole": false, + "containerId": "test", + "attributes": {} + }, + { + "id": "868ad5f4-6f71-48aa-a66b-baa9463e0384", + "name": "create-realm", + "description": "${role_create-realm}", + "composite": false, + "clientRole": false, + "containerId": "test", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "2a00dbed-f906-45c7-944b-ac36c122a447", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "cada1b50-0393-4671-a22f-4f64fdb6750f", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "c84f8fc0-92ff-45b8-ae20-361153e027e3", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "7438cf5c-c406-423e-a87c-282da2643a23", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "a3ba4f3c-5a68-4546-9d0c-d347a01a0f80", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "f5af997b-0200-493c-833a-f29922393c36", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "bb1ea04a-1455-436b-a9ce-a52ddf63080b", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "73ecfaa2-7321-47d0-b0da-b502d63e8a38", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "371d40bd-5c2d-4883-8f39-fee31929e8ff", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "9806ccc3-9c5d-4cd4-8703-a3fb2578d33e", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "4d15db11-54e1-440a-ada0-54eb2cfba916", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "b0abae85-2f7a-41a1-9e94-6fcc1c0cffc9", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "981a0138-1eb5-4bb0-9435-707d86e8ea15", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "f20ca9de-e050-4db4-bc78-a7d594198371", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "6f7ba22f-13b9-488b-bdc6-cf30bd69045c", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "manage-events", + "manage-authorization", + "view-authorization", + "view-users", + "manage-clients", + "impersonation", + "view-identity-providers", + "view-clients", + "query-realms", + "query-clients", + "view-realm", + "manage-users", + "manage-realm", + "view-events", + "manage-identity-providers", + "create-client", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "c82f77bf-ca5d-493b-937c-a1b95cfcc02c", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "afe6ce5b-60ed-4cc3-a522-689f3356000d", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "6b9787d5-1172-4c55-901b-24d1d8dc2211", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + }, + { + "id": "eadd571c-5634-4dbd-b8d3-9a3f41755c0b", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "attributes": {} + } + ], + "security-admin-console": [], + "testid_public": [], + "admin-cli": [], + "testid": [], + "account-console": [], + "broker": [ + { + "id": "7e86123e-f54c-4479-b61a-dab28c62cbd4", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "1a9e1f8f-3ab0-4df4-a94b-f7907a241edf", + "attributes": {} + } + ], + "master-realm": [ + { + "id": "96ee1616-5a21-41f0-a6b3-ab9fca3211db", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "43c2379c-939f-48ad-b911-fa4f81bcd05d", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "2f3dc289-3f71-4cd0-abc6-71c130a5ecc4", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "d67707ff-cfd2-48c8-8afd-9aa036328a1c", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "master-realm": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "dfb95d8e-c7f6-4946-a826-aeeb704c00a0", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "0b5ce3df-1ada-4367-98c4-cfb49a2fb453", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "09426dfa-97e4-4b2f-9ac4-1e4c257c7d40", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "6a72e26e-18d8-4e7d-b2b9-0bdf4f4cf1b0", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "2c0e9ccf-9f72-4c24-b47a-e223d0862725", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "f26d3f41-90ed-4c6d-9165-76d7693fda37", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "master-realm": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "86af1267-c64d-48c9-809b-240135e3d5a5", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "e9694609-410b-4d4e-b784-e29a3caf14f5", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "575d54ae-8947-44a6-9d3f-0e64f60cc6dc", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "28a8f941-bb0f-4cfd-bc5e-40a5c3720fd6", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "915a433b-6f77-4dc4-9dfe-d9939440db51", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "9eb57526-c7da-4949-97d6-1bf168f5612b", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "7d99c4d1-9f93-44ba-a735-b08c06a43a2c", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + }, + { + "id": "0a993f3b-96d2-4f41-a139-b03a1758e0eb", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "attributes": {} + } + ], + "account": [ + { + "id": "004da97f-4333-41bd-bc60-7ea8c0a1e9ed", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + }, + { + "id": "4aad4492-cf74-4a17-86b4-61e5ab7ed61f", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + }, + { + "id": "680ca61c-ab20-49a8-ad6f-335b594b0ae1", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + }, + { + "id": "a0f0c00d-b4fc-49e5-9a62-3ca1f1ec6878", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + }, + { + "id": "befbedad-59dd-49f6-b053-c6573e57d08b", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + }, + { + "id": "ae77a205-70f6-4d71-b07d-0c3c9d5c941a", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + }, + { + "id": "ac7e6cdd-fe6e-4adc-9d90-59e385e96aab", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "0b34dd0e-fba9-48a1-a139-6400f32d1361", + "name": "default-roles-master", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "test" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "d35f482f-0717-475e-9cc9-b02f18b24df9", + "createdTimestamp": 1624867127533, + "username": "service-account-testid", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "testid", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-master" + ], + "notBefore": 0, + "groups": [] + }, + { + "id": "3e1b7b99-f368-41b9-8149-ef80d0e8c636", + "createdTimestamp": 1624867127533, + "username": "service-account-testid_public", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "testid_public", + "disableableCredentialTypes": [], + "requiredActions": [], + "notBefore": 0, + "groups": [] + }, + { + "username": "testuser", + "enabled": true, + "attributes": { + "bsn": [ + "123456782" + ], + "kvk": [ + "68750110" + ] + }, + "credentials": [ + { + "type": "password", + "value": "testuser" + } + ], + "realmRoles": [], + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account" + ] + } + ] + }, + "clients": [ + { + "id": "95d9ea1e-5614-405d-ab24-fa705bbd4088", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/master/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e369bc52-f2f5-4589-ae85-6edda9d65a30", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/master/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/master/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "800daeff-beec-444f-87d7-4683fab13c6b", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d19332b1-d21f-4386-93d6-786b00946d3a", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "1a9e1f8f-3ab0-4df4-a94b-f7907a241edf", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "6eab5916-d431-4c7d-9bc5-9516e338da37", + "clientId": "master-realm", + "name": "master Realm", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "245fcb6a-6484-401c-97b1-ed1c9c4e383f", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [], + "optionalClientScopes": [] + }, + { + "id": "08fb476c-6150-43b3-a43b-2e5504e88e98", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/master/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/master/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "0cb070a6-c2c3-4828-bd18-8b1e40791325", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "34345dc9-af96-404f-9d24-9b40adc8cd0a", + "clientId": "testid", + "rootUrl": "http://127.0.0.1:8000", + "adminUrl": "http://127.0.0.1:8000/oidc/connect", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "23a12032-e080-4f65-b733-ad2567ec1605", + "redirectUris": [ + "http://localhost:8000/*", + "http://127.0.0.1:8000/*" + ], + "webOrigins": [ + "http://127.0.0.1:8000" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "0ff0a187-8ff8-45ea-8803-bbc2308f806b", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + }, + { + "id": "7a3d9a9e-88ee-4b52-9d05-46e08ea4cf60", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "55db54cf-3cd1-4846-91bb-27080ba78981", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f9b0c49c-3e26-47fd-956b-1edf4038fb1f", + "clientId": "testid_public", + "rootUrl": "http://127.0.0.1:8000", + "adminUrl": "http://127.0.0.1:8000/digid-oidc/connect", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "23a12032-e080-4f65-b733-ad2567ec1605", + "redirectUris": [ + "http://localhost:8000/*", + "http://127.0.0.1:8000/*" + ], + "webOrigins": [ + "http://127.0.0.1:8000" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "67f46369-2fbd-454c-a973-a85b4994c6c3", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "70b51643-0cd4-4fd8-9263-2d8b16424506", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "1b3787f7-8849-4d3c-b939-3f81a962cc17", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "bsn", + "kvk", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "37417998-812b-4b5f-844d-41fc3687dc99", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "9db2b90e-d681-43d3-94b8-9be904bbddef", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "716c0fe5-aaa2-4b78-899f-06634b7075cd", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0e2b1301-058e-4b8c-b45a-3136d8271ef9", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a165541d-1ae6-46ca-ab75-63ce49a7cb91", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "a91f862d-c949-4083-9987-cd77aeee5c5f", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "9a6cded0-dd1f-4083-bb19-bdf11eb339af", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "3bd6b791-56a1-4529-bfbd-cbd1c7fbb5a0", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "57c9a639-c2aa-4ddd-a7e1-72be379873f4", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "e210a123-5fde-49a7-9ea4-ce6eee7a7be5", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "01466ea2-8cdd-49bc-ab14-15369d2909a7", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "87d08d97-095a-4cfd-a3ed-3db9da9650d3", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "875850b9-6772-4304-9eed-52f671ee71c7", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "aa67db2e-be2e-456b-bea1-8bfa0536a8bb", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "fb4e5425-2561-43c8-b724-8c757e0f5dd7", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "04ccd4c8-c818-4536-9867-d36c18c8927a", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "ea264046-b77f-4960-ba07-c5111f972e61", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "1c552d93-9e4f-46d2-ae7c-82c2bc41b00f", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "e5373652-4aaf-4231-989f-d49add353a17", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "ce98d4b9-09ab-40f6-a798-7381f9c42c13", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "b27d0913-efc4-4a8a-b24b-d92370d7d07e", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "76451df0-06ef-4e91-993e-9f950b2f0925", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "2df5fd48-50fd-4433-b997-5786588e3976", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "bf1f2630-1990-48d7-bbe4-e7e3cd1f3850", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "bf3a3e65-2616-4b82-a9dd-665b74c038ee", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "064c7bf7-4692-4a1d-bd2d-7cd3a6ec9634", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "1d40dfd8-671e-484d-a322-25852822a251", + "name": "bsn", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "4d62841a-f294-4286-aa2a-dab05a0a0fed", + "name": "bsn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "bsn", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "bsn", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "ed28ea90-164d-4ff1-9faf-4ba349f08127", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "fc60ef39-3fc1-4b74-957a-55df0b831a68", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "1d40dfd8-611e-484d-a322-25852822a251", + "name": "kvk", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "4d62141a-f294-4286-aa2a-dab05a0a0fed", + "name": "kvk", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "kvk", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "kvk", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "ea6085b0-e2d8-4f17-8145-4d5537bc5cd7", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "845444c7-18c3-42e7-8c74-93e007dd0fe2", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "3bb4a654-e283-4184-bf05-d7b4010f2a1a", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "e0b8a1ee-5a77-49b6-8cf0-21520b1c23ca", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "427929bf-edf0-4f3a-ae71-34276186f623", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "fdcf1a6a-c113-47b7-9cca-a84e6f14813a", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "a447cc62-b731-47f9-9d6f-05325b89283b", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "roles", + "email", + "web-origins", + "profile", + "role_list" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "phone", + "microprofile-jwt", + "address" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "8977dcfb-2206-4216-a274-ce00b88c1553", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "19b1cffd-debb-4411-bd32-a62c06950b78", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "f7f43ebc-b362-4edf-8561-a2a76da62d0f", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "e9385552-310b-43a4-9692-e34fe6e8c119", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "bcaf8044-0491-4b43-b3f2-cb2a72750d34", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "778b4d6b-9b2b-4da3-b790-719dd93e57cf", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "60b88614-4521-47cb-a944-7586dc9d91ff", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "c1338f65-08a5-492b-a9fc-bbe38f73f5ca", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "5e8b1c64-f2f3-4e9a-b35b-9ac294561e63", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "59f6c9f5-917a-4dcb-86b4-65483afe4965", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "75866248-ab2f-4477-a1a1-6d201f5b3f6d", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "c4442b16-2ff2-423f-ad5b-0fa17b425d96", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "5cc93375-b9c6-47e3-9eb7-c248a374dae6", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "930178d8-3c25-480e-9d99-4370851af843", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "650c9131-d97c-45de-9fec-703590c5dea7", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "2c8c1327-b0d9-444d-8232-1fa51dde4f9b", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "4af88e1f-ccb0-4a7b-8ee0-b34e48566059", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "b9bd0bef-cdff-4995-ac13-d900c29f7683", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "0817adee-9170-40a9-a690-6906f0ef7dad", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "6d2e00ce-9a40-4e2b-989e-7bffce97c760", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "dfca197a-4f38-4fa8-bc14-d9f5a4fc4585", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "9d216f18-8227-4549-b975-028941902565", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "59656dfb-75a1-4932-8efc-55110dde6a2a", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "f9ee35b4-8d66-47aa-90fd-e1bd32bfbfe9", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "678ff104-d570-40ec-8a3d-a9aa8cdb2656", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "81a95ef6-1840-4690-aa01-3966075fec3e", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "21a3f527-5ee0-4e0f-923b-d61e3eddfe31", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "6cedbdfd-318a-4d30-b251-f0a884b167fb", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "abdb0e32-9902-4859-ba25-329b26bac777", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "3a5f896c-9b3e-4133-aac6-cf5ce5a19c5e", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "7c997de8-300e-4fbd-a68a-94fb0207fad9", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "e44a784d-8366-45d5-bb73-8f8fa3163434", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "32e37929-8a7d-433f-86fb-7208f5193ed9", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "600", + "clientSessionIdleTimeout": "0", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5" + }, + "keycloakVersion": "14.0.0", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} From 33147ab29fa00400d312b5d01f946b0b79e01d80 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 11:23:43 +0100 Subject: [PATCH 3/9] :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 | 70 +++ .../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 | 34 ++ .../migrations/0001_initial.py | 400 ++++++++++++++++++ .../migrations/__init__.py | 0 src/digid_eherkenning_oidc_generics/mixins.py | 28 ++ src/digid_eherkenning_oidc_generics/models.py | 129 ++++++ src/digid_eherkenning_oidc_generics/views.py | 110 +++++ .../templates/registration/login.html | 58 ++- .../accounts/views/registration.py | 20 +- 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 + 23 files changed, 972 insertions(+), 22 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..ec85913a42 --- /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 OpenIDConnectDigiDConfigForm, OpenIDConnectEHerkenningConfigForm +from .models import OpenIDConnectDigiDConfig, OpenIDConnectEHerkenningConfig + + +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(OpenIDConnectDigiDConfig) +class OpenIDConnectConfigDigiDAdmin(OpenIDConnectConfigBaseAdmin): + form = OpenIDConnectDigiDConfigForm + + +@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..9d1cff2057 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/backends.py @@ -0,0 +1,70 @@ +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 + """ + + login_type = LoginTypeChoices.digid + + +class OIDCAuthenticationEHerkenningBackend( + SoloConfigEHerkenningMixin, OIDCAuthenticationBackend +): + """ + Allows logging in via OIDC with DigiD + """ + + 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..27aec6e208 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/forms.py @@ -0,0 +1,34 @@ +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 OpenIDConnectDigiDConfig, OpenIDConnectEHerkenningConfig + +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 + + +class OpenIDConnectDigiDConfigForm(OpenIDConnectBaseConfigForm): + class Meta: + model = OpenIDConnectDigiDConfig + fields = "__all__" + + +class OpenIDConnectEHerkenningConfigForm(OpenIDConnectBaseConfigForm): + 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..7d5b875d1c --- /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-07 12:02 + +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="OpenIDConnectDigiDConfig", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "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", + ), + ), + ( + "enabled", + models.BooleanField( + default=False, + help_text="Indicates whether OpenID Connect for authentication/authorization is enabled. This overrides overrides the usage of SAML for DigiD authentication.", + verbose_name="enable", + ), + ), + ( + "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), + ), + migrations.CreateModel( + name="OpenIDConnectEHerkenningConfig", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "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", + ), + ), + ( + "enabled", + models.BooleanField( + default=False, + help_text="Indicates whether OpenID Connect for authentication/authorization is enabled. This overrides overrides the usage of SAML for eHerkenning authentication.", + verbose_name="enable", + ), + ), + ( + "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), + ), + ] 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..40c8223650 --- /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 OpenIDConnectDigiDConfig, OpenIDConnectEHerkenningConfig + +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 = OpenIDConnectDigiDConfig + 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..7dfa80691c --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/models.py @@ -0,0 +1,129 @@ +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 OpenIDConnectDigiDConfig(OpenIDConnectBaseConfig): + """ + Configuration for DigiD authentication via OpenID connect + """ + + enabled = models.BooleanField( + _("enable"), + default=False, + help_text=_( + "Indicates whether OpenID Connect for authentication/authorization is enabled. " + "This overrides overrides the usage of SAML for DigiD authentication." + ), + ) + + 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 + """ + + enabled = models.BooleanField( + _("enable"), + default=False, + help_text=_( + "Indicates whether OpenID Connect for authentication/authorization is enabled. " + "This overrides overrides the usage of SAML for eHerkenning authentication." + ), + ) + + 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..9ceb876118 --- /dev/null +++ b/src/digid_eherkenning_oidc_generics/views.py @@ -0,0 +1,110 @@ +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 +): + pass + + +class DigiDOIDCAuthenticationCallbackView( + SoloConfigDigiDMixin, OIDCAuthenticationCallbackView +): + auth_backend_class = OIDCAuthenticationDigiDBackend + + +class eHerkenningOIDCAuthenticationRequestView( + SoloConfigEHerkenningMixin, OIDCAuthenticationRequestView +): + pass + + +class eHerkenningOIDCAuthenticationCallbackView( + SoloConfigEHerkenningMixin, OIDCAuthenticationCallbackView +): + 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..5cbbdbd130 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.OpenIDConnectDigiDConfig' 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/accounts/views/registration.py b/src/open_inwoner/accounts/views/registration.py index eb0dfa4594..3a1ecff72e 100644 --- a/src/open_inwoner/accounts/views/registration.py +++ b/src/open_inwoner/accounts/views/registration.py @@ -12,6 +12,10 @@ from django_registration.backends.one_step.views import RegistrationView from furl import furl +from digid_eherkenning_oidc_generics.models import ( + OpenIDConnectDigiDConfig, + OpenIDConnectEHerkenningConfig, +) from open_inwoner.utils.hash import generate_email_from_string from open_inwoner.utils.views import CommonPageMixin, LogMixin @@ -96,17 +100,25 @@ def get_context_data(self, **kwargs): else reverse("profile:registration_necessary") ) try: + config = OpenIDConnectDigiDConfig.get_solo() + if config.enabled: + digid_url = reverse("digid_oidc:init") + else: + digid_url = reverse("digid:login") context["digit_url"] = ( - furl(reverse("digid:login")).add({"next": necessary_fields_url}).url + furl(digid_url).add({"next": necessary_fields_url}).url ) except: context["digit_url"] = "" try: + config = OpenIDConnectEHerkenningConfig.get_solo() + if config.enabled: + eherkenning_url = reverse("eherkenning_oidc:init") + else: + eherkenning_url = reverse("eherkenning:login") context["eherkenning_url"] = ( - furl(reverse("eherkenning:login")) - .add({"next": necessary_fields_url}) - .url + furl(eherkenning_url).add({"next": necessary_fields_url}).url ) except: context["eherkenning_url"] = "" 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..889a0539db 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", + "openidconnectdigidconfig" + ], [ "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")), From 84176982edb010cd9e74c88c6661202cba7ea6ba Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 12:17:34 +0100 Subject: [PATCH 4/9] :sparkles: [#1902/1903] Logout for DigiD/eHerkenning via OIDC tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 --- .../digid_urls.py | 6 +++ .../eherkenning_urls.py | 6 +++ src/digid_eherkenning_oidc_generics/views.py | 39 +++++++++++++++++++ src/open_inwoner/accounts/models.py | 25 +++++++++--- src/open_inwoner/conf/base.py | 2 + 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/digid_eherkenning_oidc_generics/digid_urls.py b/src/digid_eherkenning_oidc_generics/digid_urls.py index 8ef8c21a17..8c591eba4a 100644 --- a/src/digid_eherkenning_oidc_generics/digid_urls.py +++ b/src/digid_eherkenning_oidc_generics/digid_urls.py @@ -5,6 +5,7 @@ from .views import ( DigiDOIDCAuthenticationCallbackView, DigiDOIDCAuthenticationRequestView, + DigiDOIDCLogoutView, ) app_name = "digid_oidc" @@ -21,4 +22,9 @@ DigiDOIDCAuthenticationRequestView.as_view(), name="init", ), + path( + "logout/", + DigiDOIDCLogoutView.as_view(), + name="logout", + ), ] + urlpatterns diff --git a/src/digid_eherkenning_oidc_generics/eherkenning_urls.py b/src/digid_eherkenning_oidc_generics/eherkenning_urls.py index c1fbbd4523..453f3ab754 100644 --- a/src/digid_eherkenning_oidc_generics/eherkenning_urls.py +++ b/src/digid_eherkenning_oidc_generics/eherkenning_urls.py @@ -5,6 +5,7 @@ from .views import ( eHerkenningOIDCAuthenticationCallbackView, eHerkenningOIDCAuthenticationRequestView, + eHerkenningOIDCLogoutView, ) app_name = "eherkenning_oidc" @@ -21,4 +22,9 @@ eHerkenningOIDCAuthenticationRequestView.as_view(), name="init", ), + path( + "logout/", + eHerkenningOIDCLogoutView.as_view(), + name="logout", + ), ] + urlpatterns diff --git a/src/digid_eherkenning_oidc_generics/views.py b/src/digid_eherkenning_oidc_generics/views.py index 9ceb876118..6b8cffdd64 100644 --- a/src/digid_eherkenning_oidc_generics/views.py +++ b/src/digid_eherkenning_oidc_generics/views.py @@ -1,8 +1,14 @@ import logging +from django.conf import settings from django.contrib import auth from django.core.exceptions import SuspiciousOperation +from django.http import HttpResponseRedirect +from django.shortcuts import resolve_url +from django.views import View +import requests +from furl import furl from mozilla_django_oidc.views import ( OIDCAuthenticationCallbackView as _OIDCAuthenticationCallbackView, OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView, @@ -86,6 +92,31 @@ def get(self, request): return self.login_failure() +class OIDCLogoutView(View): + def get_success_url(self): + return resolve_url(settings.LOGOUT_REDIRECT_URL) + + def get(self, request): + if "oidc_id_token" in request.session: + logout_endpoint = self.config_class.get_solo().oidc_op_logout_endpoint + if logout_endpoint: + logout_url = furl(logout_endpoint).set( + { + "id_token_hint": request.session["oidc_id_token"], + } + ) + requests.get(str(logout_url)) + + del request.session["oidc_id_token"] + + if "oidc_login_next" in request.session: + del request.session["oidc_login_next"] + + auth.logout(request) + + return HttpResponseRedirect(self.get_success_url()) + + class DigiDOIDCAuthenticationRequestView( SoloConfigDigiDMixin, OIDCAuthenticationRequestView ): @@ -98,6 +129,10 @@ class DigiDOIDCAuthenticationCallbackView( auth_backend_class = OIDCAuthenticationDigiDBackend +class DigiDOIDCLogoutView(SoloConfigDigiDMixin, OIDCLogoutView): + pass + + class eHerkenningOIDCAuthenticationRequestView( SoloConfigEHerkenningMixin, OIDCAuthenticationRequestView ): @@ -108,3 +143,7 @@ class eHerkenningOIDCAuthenticationCallbackView( SoloConfigEHerkenningMixin, OIDCAuthenticationCallbackView ): auth_backend_class = OIDCAuthenticationEHerkenningBackend + + +class eHerkenningOIDCLogoutView(SoloConfigEHerkenningMixin, OIDCLogoutView): + pass diff --git a/src/open_inwoner/accounts/models.py b/src/open_inwoner/accounts/models.py index a7b146297e..53b8360831 100644 --- a/src/open_inwoner/accounts/models.py +++ b/src/open_inwoner/accounts/models.py @@ -19,6 +19,10 @@ from privates.storages import PrivateMediaFileSystemStorage from timeline_logger.models import TimelineLog +from digid_eherkenning_oidc_generics.models import ( + OpenIDConnectDigiDConfig, + OpenIDConnectEHerkenningConfig, +) from open_inwoner.utils.hash import create_sha256_hash from open_inwoner.utils.validators import ( CharFieldValidator, @@ -399,11 +403,22 @@ def require_necessary_fields(self) -> bool: return False def get_logout_url(self) -> str: - return ( - reverse("digid:logout") - if self.login_type == LoginTypeChoices.digid - else reverse("logout") - ) + # Exit early, because for some reason reverse("logout") fails after checking + # the singletonmodels + if self.login_type not in [ + LoginTypeChoices.digid, + LoginTypeChoices.eherkenning, + ]: + return reverse("logout") + + if self.login_type == LoginTypeChoices.digid: + if OpenIDConnectDigiDConfig.get_solo().enabled: + return reverse("digid_oidc:logout") + return reverse("digid:logout") + elif self.login_type == LoginTypeChoices.eherkenning: + if OpenIDConnectEHerkenningConfig.get_solo().enabled: + return reverse("eherkenning_oidc:logout") + return reverse("eherkenning:logout") def get_contact_update_url(self): return reverse("profile:contact_edit", kwargs={"uuid": self.uuid}) diff --git a/src/open_inwoner/conf/base.py b/src/open_inwoner/conf/base.py index 0a66a9e5c1..3f18a0d368 100644 --- a/src/open_inwoner/conf/base.py +++ b/src/open_inwoner/conf/base.py @@ -885,6 +885,8 @@ OIDC_AUTHENTICATE_CLASS = "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" OIDC_CALLBACK_CLASS = "mozilla_django_oidc_db.views.OIDCCallbackView" OIDC_AUTHENTICATION_CALLBACK_URL = "oidc_authentication_callback" +# ID token is required to enable OIDC logout +OIDC_STORE_ID_TOKEN = True MOZILLA_DJANGO_OIDC_DB_CACHE = "oidc" MOZILLA_DJANGO_OIDC_DB_CACHE_TIMEOUT = 1 From e6d7bde7905603668efa2c42c8b479ce602a872c Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 15:30:21 +0100 Subject: [PATCH 5/9] :recycle: [#1902/1903] Simplify DigiD/eHerkenning OIDC code tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 To avoid the OIDC backends for other variants attempting to authenticate a user for another variant, we check if the callback path matches or not --- .../backends.py | 14 +++- src/digid_eherkenning_oidc_generics/views.py | 69 +------------------ 2 files changed, 16 insertions(+), 67 deletions(-) diff --git a/src/digid_eherkenning_oidc_generics/backends.py b/src/digid_eherkenning_oidc_generics/backends.py index 9d1cff2057..9c3f385f44 100644 --- a/src/digid_eherkenning_oidc_generics/backends.py +++ b/src/digid_eherkenning_oidc_generics/backends.py @@ -2,11 +2,12 @@ from django.contrib.auth.models import AnonymousUser from django.core.exceptions import SuspiciousOperation +from django.urls import reverse_lazy from mozilla_django_oidc_db.backends import ( OIDCAuthenticationBackend as _OIDCAuthenticationBackend, ) -from requests.exceptions import RequestException +from requests.exceptions import HTTPError, RequestException from open_inwoner.accounts.choices import LoginTypeChoices @@ -18,6 +19,15 @@ class OIDCAuthenticationBackend(_OIDCAuthenticationBackend): config_identifier_field = "identifier_claim_name" + callback_path = None + + def authenticate(self, request, *args, **kwargs): + # Avoid attempting OIDC for a specific variant if we know that that is not the + # correct variant being attempted + if request and request.path != self.callback_path: + return + + return super().authenticate(request, *args, **kwargs) def filter_users_by_claims(self, claims): """Return all users matching the specified subject.""" @@ -58,6 +68,7 @@ class OIDCAuthenticationDigiDBackend(SoloConfigDigiDMixin, OIDCAuthenticationBac """ login_type = LoginTypeChoices.digid + callback_path = reverse_lazy("digid_oidc:callback") class OIDCAuthenticationEHerkenningBackend( @@ -68,3 +79,4 @@ class OIDCAuthenticationEHerkenningBackend( """ login_type = LoginTypeChoices.eherkenning + callback_path = reverse_lazy("eherkenning_oidc:callback") diff --git a/src/digid_eherkenning_oidc_generics/views.py b/src/digid_eherkenning_oidc_generics/views.py index 6b8cffdd64..3c4def2905 100644 --- a/src/digid_eherkenning_oidc_generics/views.py +++ b/src/digid_eherkenning_oidc_generics/views.py @@ -2,7 +2,6 @@ from django.conf import settings from django.contrib import auth -from django.core.exceptions import SuspiciousOperation from django.http import HttpResponseRedirect from django.shortcuts import resolve_url from django.views import View @@ -10,7 +9,7 @@ import requests from furl import furl from mozilla_django_oidc.views import ( - OIDCAuthenticationCallbackView as _OIDCAuthenticationCallbackView, + OIDCAuthenticationCallbackView, OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView, ) @@ -19,11 +18,6 @@ SoloConfigEHerkenningMixin, ) -from .backends import ( - OIDCAuthenticationDigiDBackend, - OIDCAuthenticationEHerkenningBackend, -) - logger = logging.getLogger(__name__) @@ -35,63 +29,6 @@ def get_extra_params(self, request): 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 OIDCLogoutView(View): def get_success_url(self): return resolve_url(settings.LOGOUT_REDIRECT_URL) @@ -126,7 +63,7 @@ class DigiDOIDCAuthenticationRequestView( class DigiDOIDCAuthenticationCallbackView( SoloConfigDigiDMixin, OIDCAuthenticationCallbackView ): - auth_backend_class = OIDCAuthenticationDigiDBackend + pass class DigiDOIDCLogoutView(SoloConfigDigiDMixin, OIDCLogoutView): @@ -142,7 +79,7 @@ class eHerkenningOIDCAuthenticationRequestView( class eHerkenningOIDCAuthenticationCallbackView( SoloConfigEHerkenningMixin, OIDCAuthenticationCallbackView ): - auth_backend_class = OIDCAuthenticationEHerkenningBackend + pass class eHerkenningOIDCLogoutView(SoloConfigEHerkenningMixin, OIDCLogoutView): From 690cd29c78aa39d5781ac8ca6ce43ac43eacb193 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 16:30:28 +0100 Subject: [PATCH 6/9] :goal_net: [#1902/1903] Failure page for DigiD/eHerkenning via OIDC tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 --- src/digid_eherkenning_oidc_generics/views.py | 20 ++++++++++++++----- .../digid_eherkenning_oidc_login_failure.html | 10 ++++++++++ src/open_inwoner/urls.py | 2 ++ 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html diff --git a/src/digid_eherkenning_oidc_generics/views.py b/src/digid_eherkenning_oidc_generics/views.py index 3c4def2905..ec741a1745 100644 --- a/src/digid_eherkenning_oidc_generics/views.py +++ b/src/digid_eherkenning_oidc_generics/views.py @@ -4,14 +4,18 @@ from django.contrib import auth from django.http import HttpResponseRedirect from django.shortcuts import resolve_url +from django.urls import reverse_lazy from django.views import View import requests from furl import furl from mozilla_django_oidc.views import ( - OIDCAuthenticationCallbackView, OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView, ) +from mozilla_django_oidc_db.views import ( + AdminLoginFailure, + OIDCCallbackView as _OIDCCallbackView, +) from digid_eherkenning_oidc_generics.mixins import ( SoloConfigDigiDMixin, @@ -29,6 +33,14 @@ def get_extra_params(self, request): return {} +class OIDCFailureView(AdminLoginFailure): + template_name = "digid_eherkenning_oidc_login_failure.html" + + +class OIDCCallbackView(_OIDCCallbackView): + failure_url = reverse_lazy("oidc-error") + + class OIDCLogoutView(View): def get_success_url(self): return resolve_url(settings.LOGOUT_REDIRECT_URL) @@ -60,9 +72,7 @@ class DigiDOIDCAuthenticationRequestView( pass -class DigiDOIDCAuthenticationCallbackView( - SoloConfigDigiDMixin, OIDCAuthenticationCallbackView -): +class DigiDOIDCAuthenticationCallbackView(SoloConfigDigiDMixin, OIDCCallbackView): pass @@ -77,7 +87,7 @@ class eHerkenningOIDCAuthenticationRequestView( class eHerkenningOIDCAuthenticationCallbackView( - SoloConfigEHerkenningMixin, OIDCAuthenticationCallbackView + SoloConfigEHerkenningMixin, OIDCCallbackView ): pass diff --git a/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html b/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html new file mode 100644 index 0000000000..807336236d --- /dev/null +++ b/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html @@ -0,0 +1,10 @@ +{% extends 'master.html' %} +{% load i18n %} +{% block menu %}{% endblock %} + +{% block content %} + +

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

+ + +{% endblock content %} diff --git a/src/open_inwoner/urls.py b/src/open_inwoner/urls.py index ce519a4cf1..205bc3b71b 100644 --- a/src/open_inwoner/urls.py +++ b/src/open_inwoner/urls.py @@ -9,6 +9,7 @@ from mozilla_django_oidc_db.views import AdminLoginFailure +from digid_eherkenning_oidc_generics.views import OIDCFailureView from open_inwoner.accounts.forms import CustomRegistrationForm from open_inwoner.accounts.views import ( AddPhoneNumberWizardView, @@ -119,6 +120,7 @@ "digid_eherkenning_oidc_generics.eherkenning_urls", ), ), + path("login/failure/", OIDCFailureView.as_view(), name="oidc-error"), path("faq/", FAQView.as_view(), name="general_faq"), path("yubin/", include("django_yubin.urls")), path("apimock/", include("open_inwoner.apimock.urls")), From ec02ad0e24188632091da491c34747ff41d795b8 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Tue, 5 Dec 2023 13:52:45 +0100 Subject: [PATCH 7/9] :white_check_mark: [#1902/1903] Tests for DigiD/eHerkenning via OIDC tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 --- src/open_inwoner/accounts/tests/test_auth.py | 126 +++++- .../accounts/tests/test_oidc_views.py | 417 +++++++++++++++++- .../accounts/tests/test_profile_views.py | 57 ++- 3 files changed, 570 insertions(+), 30 deletions(-) diff --git a/src/open_inwoner/accounts/tests/test_auth.py b/src/open_inwoner/accounts/tests/test_auth.py index 5bb730c65a..cee126a935 100644 --- a/src/open_inwoner/accounts/tests/test_auth.py +++ b/src/open_inwoner/accounts/tests/test_auth.py @@ -13,6 +13,10 @@ from furl import furl from pyquery import PyQuery as PQ +from digid_eherkenning_oidc_generics.models import ( + OpenIDConnectDigiDConfig, + OpenIDConnectEHerkenningConfig, +) from open_inwoner.configurations.models import SiteConfiguration from open_inwoner.contrib.kvk.models import KvKConfig from open_inwoner.contrib.kvk.tests.factories import CertificateFactory @@ -42,20 +46,31 @@ class DigiDRegistrationTest(AssertRedirectsMixin, HaalCentraalMixin, WebTest): def setUpTestData(cls): cms_tools.create_homepage() - def test_registration_page_only_digid(self): - response = self.app.get(self.url) + @patch("digid_eherkenning_oidc_generics.models.OpenIDConnectDigiDConfig.get_solo") + def test_registration_page_only_digid(self, mock_solo): + for oidc_enabled in [True, False]: + with self.subTest(oidc_enabled=oidc_enabled): + mock_solo.return_value.enabled = oidc_enabled - self.assertEqual(response.status_code, 200) - self.assertIsNone(response.html.find(id="registration-form")) + digid_url = ( + reverse("digid_oidc:init") + if oidc_enabled + else reverse("digid:login") + ) - digid_tag = response.html.find("a", title="Registreren met DigiD") - self.assertIsNotNone(digid_tag) - self.assertEqual( - digid_tag.attrs["href"], - furl(reverse("digid:login")) - .add({"next": reverse("profile:registration_necessary")}) - .url, - ) + response = self.app.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.html.find(id="registration-form")) + + digid_tag = response.html.find("a", title="Registreren met DigiD") + self.assertIsNotNone(digid_tag) + self.assertEqual( + digid_tag.attrs["href"], + furl(digid_url) + .add({"next": reverse("profile:registration_necessary")}) + .url, + ) def test_registration_page_only_digid_with_invite(self): invite = InviteFactory.create() @@ -422,24 +437,39 @@ class eHerkenningRegistrationTest(AssertRedirectsMixin, WebTest): def setUpTestData(cls): cms_tools.create_homepage() + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectEHerkenningConfig.get_solo" + ) @patch("open_inwoner.configurations.models.SiteConfiguration.get_solo") - def test_registration_page_eherkenning(self, mock_solo): + def test_registration_page_eherkenning(self, mock_solo, mock_eherkenning_config): mock_solo.return_value.eherkenning_enabled = True mock_solo.return_value.login_allow_registration = False - response = self.app.get(self.url) + for oidc_enabled in [True, False]: + with self.subTest(oidc_enabled=oidc_enabled): + mock_eherkenning_config.return_value.enabled = oidc_enabled - self.assertEqual(response.status_code, 200) - self.assertIsNone(response.html.find(id="registration-form")) + eherkenning_url = ( + reverse("eherkenning_oidc:init") + if oidc_enabled + else reverse("eherkenning:login") + ) - eherkenning_tag = response.html.find("a", title="Registreren met eHerkenning") - self.assertIsNotNone(eherkenning_tag) - self.assertEqual( - eherkenning_tag.attrs["href"], - furl(reverse("eherkenning:login")) - .add({"next": reverse("profile:registration_necessary")}) - .url, - ) + response = self.app.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.html.find(id="registration-form")) + + eherkenning_tag = response.html.find( + "a", title="Registreren met eHerkenning" + ) + self.assertIsNotNone(eherkenning_tag) + self.assertEqual( + eherkenning_tag.attrs["href"], + furl(eherkenning_url) + .add({"next": reverse("profile:registration_necessary")}) + .url, + ) @patch("open_inwoner.configurations.models.SiteConfiguration.get_solo") def test_registration_page_eherkenning_with_invite(self, mock_solo): @@ -1453,6 +1483,54 @@ def test_login(self): # Verify that the user has been authenticated self.assertIn("_auth_user_id", self.app.session) + def test_login_page_shows_correct_digid_login_url(self): + config = OpenIDConnectDigiDConfig.get_solo() + + for oidc_enabled in [True, False]: + with self.subTest(oidc_enabled=oidc_enabled): + config.enabled = oidc_enabled + config.save() + + login_url = ( + reverse("digid_oidc:init") + if oidc_enabled + else f"{reverse('digid:login')}?next=" + ) + + response = self.app.get(reverse("login")) + + digid_login_title = _("Inloggen met DigiD") + digid_login_link = response.pyquery(f"[title='{digid_login_title}']") + + self.assertEqual(digid_login_link.attr("href"), login_url) + + def test_login_page_shows_correct_eherkenning_login_url(self): + site_config = SiteConfiguration.get_solo() + site_config.eherkenning_enabled = True + site_config.save() + + config = OpenIDConnectEHerkenningConfig.get_solo() + + for oidc_enabled in [True, False]: + with self.subTest(oidc_enabled=oidc_enabled): + config.enabled = oidc_enabled + config.save() + + login_url = ( + reverse("eherkenning_oidc:init") + if oidc_enabled + else f"{reverse('eherkenning:login')}?next=" + ) + + response = self.app.get(reverse("login")) + + eherkenning_login_title = _("Inloggen met eHerkenning") + eherkenning_login_link = response.pyquery( + f"[title='{eherkenning_login_title}']" + ) + + self.assertEqual(eherkenning_login_link.attr("href"), login_url) + def test_login_for_inactive_user_shows_appropriate_message(self): # Change user to inactive self.user.is_active = False diff --git a/src/open_inwoner/accounts/tests/test_oidc_views.py b/src/open_inwoner/accounts/tests/test_oidc_views.py index efaef333f8..b1dac05f7a 100644 --- a/src/open_inwoner/accounts/tests/test_oidc_views.py +++ b/src/open_inwoner/accounts/tests/test_oidc_views.py @@ -4,10 +4,17 @@ from django.test import TestCase from django.urls import reverse +import requests_mock +from furl import furl from mozilla_django_oidc_db.models import OpenIDConnectConfig +from digid_eherkenning_oidc_generics.models import ( + OpenIDConnectDigiDConfig, + OpenIDConnectEHerkenningConfig, +) + from ..choices import LoginTypeChoices -from .factories import UserFactory +from .factories import DigidUserFactory, UserFactory, eHerkenningUserFactory User = get_user_model() @@ -19,7 +26,7 @@ class OIDCFlowTests(TestCase): @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") @patch( "mozilla_django_oidc_db.mixins.OpenIDConnectConfig.get_solo", - return_value=OpenIDConnectConfig(enabled=True), + return_value=OpenIDConnectConfig(id=1, enabled=True), ) def test_existing_email_updates_user( self, @@ -66,7 +73,7 @@ def test_existing_email_updates_user( @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") @patch( "mozilla_django_oidc_db.mixins.OpenIDConnectConfig.get_solo", - return_value=OpenIDConnectConfig(enabled=True), + return_value=OpenIDConnectConfig(id=1, enabled=True), ) def test_existing_case_sensitive_email_updates_user( self, @@ -115,7 +122,7 @@ def test_existing_case_sensitive_email_updates_user( @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") @patch( "mozilla_django_oidc_db.mixins.OpenIDConnectConfig.get_solo", - return_value=OpenIDConnectConfig(enabled=True), + return_value=OpenIDConnectConfig(id=1, enabled=True), ) def test_new_user_is_created_when_new_email( self, @@ -165,7 +172,7 @@ def test_error_page_direct_access_forbidden(self): @patch("mozilla_django_oidc_db.backends.OIDCAuthenticationBackend.get_token") @patch( "mozilla_django_oidc_db.mixins.OpenIDConnectConfig.get_solo", - return_value=OpenIDConnectConfig(enabled=True), + return_value=OpenIDConnectConfig(id=1, enabled=True), ) def test_error_first_cleared_after_succesful_login( self, @@ -207,3 +214,403 @@ def test_error_first_cleared_after_succesful_login( response = self.client.get(error_url) self.assertEqual(response.status_code, 403) + + +class DigiDOIDCFlowTests(TestCase): + @patch("open_inwoner.haalcentraal.signals.update_brp_data_in_db") + @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_existing_bsn_creates_no_new_user( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + mock_brp, + ): + # set up a user with a colliding email address + # sub is the oidc_id field in our db + mock_get_userinfo.return_value = { + "email": "existing_user@example.com", + "sub": "some_username", + "bsn": "123456782", + } + user = DigidUserFactory.create( + first_name="John", + last_name="Doe", + bsn="123456782", + email="user-123456782@localhost", + is_prepopulated=True, + ) + self.assertEqual(user.oidc_id, "") + 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"} + ) + + user.refresh_from_db() + + self.assertRedirects( + callback_response, reverse("pages-root"), fetch_redirect_response=False + ) + self.assertEqual(User.objects.count(), 1) + + db_user = User.objects.get() + + # User data was prepopulated, so this should not be called + mock_brp.assert_not_called() + self.assertEqual(db_user.id, user.id) + self.assertEqual(db_user.bsn, "123456782") + self.assertEqual(db_user.login_type, LoginTypeChoices.digid) + self.assertEqual(db_user.first_name, "John") + self.assertEqual(db_user.last_name, "Doe") + + @patch("open_inwoner.haalcentraal.signals.update_brp_data_in_db") + @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_new_user_is_created_when_new_bsn( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + mock_brp, + ): + # set up a user with a non existing email address + mock_get_userinfo.return_value = {"sub": "some_username", "bsn": "000000000"} + DigidUserFactory.create(bsn="123456782", email="existing_user@example.com") + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("digid_oidc:callback") + + self.assertFalse(User.objects.filter(email="new_user@example.com").exists()) + + # enter the login flow + callback_response = self.client.get( + callback_url, {"code": "mock", "state": "mock"} + ) + + self.assertRedirects( + callback_response, reverse("pages-root"), fetch_redirect_response=False + ) + new_user = User.objects.get(bsn="000000000") + + mock_brp.assert_called_with(new_user) + self.assertEqual(new_user.email, "user-000000000@localhost") + self.assertEqual(new_user.login_type, LoginTypeChoices.digid) + + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectDigiDConfig.get_solo", + return_value=OpenIDConnectDigiDConfig( + id=1, enabled=True, oidc_op_logout_endpoint="http://localhost:8080/logout" + ), + ) + def test_logout(self, mock_get_solo): + # set up a user with a non existing email address + user = DigidUserFactory.create( + bsn="123456782", email="existing_user@example.com" + ) + self.client.force_login(user) + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session["oidc_id_token"] = "foo" + session.save() + logout_url = reverse("digid_oidc:logout") + + self.assertFalse(User.objects.filter(email="new_user@example.com").exists()) + + # enter the logout flow + with requests_mock.Mocker() as m: + logout_endpoint_url = str( + furl("http://localhost:8080/logout").set( + { + "id_token_hint": "foo", + } + ) + ) + m.get(logout_endpoint_url) + logout_response = self.client.get(logout_url) + + self.assertEqual(len(m.request_history), 1) + self.assertEqual(m.request_history[0].url, logout_endpoint_url) + + self.assertRedirects( + logout_response, reverse("login"), fetch_redirect_response=False + ) + + self.assertNotIn("oidc_states", self.client.session) + self.assertNotIn("oidc_id_token", self.client.session) + + def test_error_page_direct_access_forbidden(self): + error_url = reverse("oidc-error") + + 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.OpenIDConnectDigiDConfig.get_solo", + return_value=OpenIDConnectDigiDConfig(id=1, enabled=True), + ) + def test_error_first_cleared_after_succesful_login( + 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-error"] = "some error" + session.save() + error_url = reverse("admin-oidc-error") + + with self.subTest("with error"): + response = self.client.get(error_url) + + self.assertEqual(response.status_code, 200) + + with self.subTest("after succesful login"): + 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("pages-root"), fetch_redirect_response=False + ) + + with self.subTest("check error page again"): + response = self.client.get(error_url) + + self.assertEqual(response.status_code, 403) + + +class eHerkenningOIDCFlowTests(TestCase): + @patch("open_inwoner.contrib.kvk.signals.KvKClient.retrieve_rsin_with_kvk") + @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_existing_kvk_creates_no_new_user( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + mock_kvk, + ): + mock_kvk.return_value = "123456789" + # set up a user with a colliding email address + # sub is the oidc_id field in our db + mock_get_userinfo.return_value = { + "email": "existing_user@example.com", + "sub": "some_username", + "kvk": "12345678", + } + user = DigidUserFactory.create( + first_name="John", + last_name="Doe", + kvk="12345678", + email="user-12345678@localhost", + is_prepopulated=True, + ) + self.assertEqual(user.oidc_id, "") + 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"} + ) + + user.refresh_from_db() + + self.assertRedirects( + callback_response, reverse("pages-root"), fetch_redirect_response=False + ) + self.assertEqual(User.objects.count(), 1) + + db_user = User.objects.get() + + # User data was prepopulated, so this should not be called + mock_kvk.assert_not_called() + self.assertEqual(db_user.id, user.id) + self.assertEqual(db_user.kvk, "12345678") + self.assertEqual(db_user.login_type, LoginTypeChoices.digid) + self.assertEqual(db_user.first_name, "John") + self.assertEqual(db_user.last_name, "Doe") + + @patch("open_inwoner.contrib.kvk.signals.KvKClient.retrieve_rsin_with_kvk") + @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_new_user_is_created_when_new_kvk( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + mock_kvk, + ): + mock_kvk.return_value = "123456789" + # set up a user with a non existing email address + mock_get_userinfo.return_value = {"sub": "some_username", "kvk": "00000000"} + eHerkenningUserFactory.create(kvk="12345678", email="existing_user@example.com") + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session.save() + callback_url = reverse("eherkenning_oidc:callback") + + self.assertFalse(User.objects.filter(email="new_user@example.com").exists()) + + # enter the login flow + callback_response = self.client.get( + callback_url, {"code": "mock", "state": "mock"} + ) + + self.assertRedirects( + callback_response, reverse("pages-root"), fetch_redirect_response=False + ) + new_user = User.objects.get(kvk="00000000") + + mock_kvk.assert_called_with("00000000") + self.assertEqual(new_user.email, "user-00000000@localhost") + self.assertEqual(new_user.rsin, "123456789") + self.assertEqual(new_user.login_type, LoginTypeChoices.eherkenning) + + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectEHerkenningConfig.get_solo", + return_value=OpenIDConnectEHerkenningConfig( + id=1, enabled=True, oidc_op_logout_endpoint="http://localhost:8080/logout" + ), + ) + def test_logout(self, mock_get_solo): + # set up a user with a non existing email address + user = eHerkenningUserFactory.create( + kvk="12345678", email="existing_user@example.com" + ) + self.client.force_login(user) + session = self.client.session + session["oidc_states"] = {"mock": {"nonce": "nonce"}} + session["oidc_id_token"] = "foo" + session.save() + logout_url = reverse("eherkenning_oidc:logout") + + self.assertFalse(User.objects.filter(email="new_user@example.com").exists()) + + # enter the logout flow + with requests_mock.Mocker() as m: + logout_endpoint_url = str( + furl("http://localhost:8080/logout").set( + { + "id_token_hint": "foo", + } + ) + ) + m.get(logout_endpoint_url) + logout_response = self.client.get(logout_url) + + self.assertEqual(len(m.request_history), 1) + self.assertEqual(m.request_history[0].url, logout_endpoint_url) + + self.assertRedirects( + logout_response, reverse("login"), fetch_redirect_response=False + ) + + self.assertNotIn("oidc_states", self.client.session) + self.assertNotIn("oidc_id_token", self.client.session) + + @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_error_first_cleared_after_succesful_login( + self, + mock_get_solo, + mock_get_token, + mock_verify_token, + mock_store_tokens, + mock_get_userinfo, + ): + mock_get_userinfo.return_value = { + "sub": "some_username", + "kvk": "12345678", + } + session = self.client.session + session["oidc-error"] = "some error" + session.save() + error_url = reverse("admin-oidc-error") + + with self.subTest("with error"): + response = self.client.get(error_url) + + self.assertEqual(response.status_code, 200) + + with self.subTest("after succesful login"): + 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("pages-root"), fetch_redirect_response=False + ) + + with self.subTest("check error page again"): + response = self.client.get(error_url) + + self.assertEqual(response.status_code, 403) diff --git a/src/open_inwoner/accounts/tests/test_profile_views.py b/src/open_inwoner/accounts/tests/test_profile_views.py index 5d6c4d09ae..372751127d 100644 --- a/src/open_inwoner/accounts/tests/test_profile_views.py +++ b/src/open_inwoner/accounts/tests/test_profile_views.py @@ -28,7 +28,12 @@ from ..choices import ContactTypeChoices, LoginTypeChoices from ..forms import BrpUserForm, UserForm from ..models import User -from .factories import ActionFactory, DigidUserFactory, DocumentFactory, UserFactory +from .factories import ( + ActionFactory, + DigidUserFactory, + UserFactory, + eHerkenningUserFactory, +) @override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") @@ -37,6 +42,8 @@ def setUp(self): self.url = reverse("profile:detail") self.return_url = reverse("logout") self.user = UserFactory(street="MyStreet") + self.digid_user = DigidUserFactory() + self.eherkenning_user = eHerkenningUserFactory() self.action_deleted = ActionFactory( name="deleted action, should not show up", @@ -57,6 +64,54 @@ def test_login_required(self): response = self.app.get(self.url) self.assertRedirects(response, f"{login_url}?next={self.url}") + def test_show_correct_logout_button_for_login_type_default(self): + response = self.app.get(self.url, user=self.user) + + logout_title = _("Logout") + logout_link = response.pyquery.find(f"[title='{logout_title}']") + + self.assertEqual(logout_link.attr("href"), reverse("logout")) + + @patch("digid_eherkenning_oidc_generics.models.OpenIDConnectDigiDConfig.get_solo") + def test_show_correct_logout_button_for_login_type_digid(self, mock_solo): + for oidc_enabled in [True, False]: + with self.subTest(oidc_enabled=oidc_enabled): + mock_solo.return_value.enabled = oidc_enabled + + logout_url = ( + reverse("digid_oidc:logout") + if oidc_enabled + else reverse("digid:logout") + ) + + response = self.app.get(self.url, user=self.digid_user) + + logout_title = _("Logout") + logout_link = response.pyquery.find(f"[title='{logout_title}']") + + self.assertEqual(logout_link.attr("href"), logout_url) + + @patch( + "digid_eherkenning_oidc_generics.models.OpenIDConnectEHerkenningConfig.get_solo" + ) + def test_show_correct_logout_button_for_login_type_eherkenning(self, mock_solo): + for oidc_enabled in [True, False]: + with self.subTest(oidc_enabled=oidc_enabled): + mock_solo.return_value.enabled = oidc_enabled + + logout_url = ( + reverse("eherkenning_oidc:logout") + if oidc_enabled + else reverse("eherkenning:logout") + ) + + response = self.app.get(self.url, user=self.eherkenning_user) + + logout_title = _("Logout") + logout_link = response.pyquery.find(f"[title='{logout_title}']") + + self.assertEqual(logout_link.attr("href"), logout_url) + def test_user_information_profile_page(self): response = self.app.get(self.url, user=self.user) From 5f179fff6979d5abab0be068016c971f829ee821 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Thu, 7 Dec 2023 12:11:03 +0100 Subject: [PATCH 8/9] :ok_hand: [#1902/1903] Process PR feedback tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 --- docker/keycloak/README.md | 2 +- .../backends.py | 2 +- .../registration_form.html | 6 ++--- .../accounts/tests/test_oidc_views.py | 16 +++++++------- src/open_inwoner/accounts/views/login.py | 4 ++++ .../accounts/views/registration.py | 11 +++++----- ...r_siteconfiguration_eherkenning_enabled.py | 22 +++++++++++++++++++ src/open_inwoner/configurations/models.py | 6 ++++- .../digid_eherkenning_oidc_login_failure.html | 4 ---- 9 files changed, 50 insertions(+), 23 deletions(-) create mode 100644 src/open_inwoner/configurations/migrations/0056_alter_siteconfiguration_eherkenning_enabled.py diff --git a/docker/keycloak/README.md b/docker/keycloak/README.md index bfc02689fd..3fc16a1290 100644 --- a/docker/keycloak/README.md +++ b/docker/keycloak/README.md @@ -44,4 +44,4 @@ To test the login flow, navigate to `http://127.0.0.1:8000/digid-oidc/` (not `localhost`, because this domain is not on the allowlist in the Keycloak config). Click `Inloggen met DigiD` and fill in `testuser` for both username and password -in the Keycloak login screen. If everything succeeded, you are now redirected back to the form. +in the Keycloak login screen. If everything succeeded, you are now logged in and redirected to the Open Inwoner home page. diff --git a/src/digid_eherkenning_oidc_generics/backends.py b/src/digid_eherkenning_oidc_generics/backends.py index 9c3f385f44..8e4f963646 100644 --- a/src/digid_eherkenning_oidc_generics/backends.py +++ b/src/digid_eherkenning_oidc_generics/backends.py @@ -75,7 +75,7 @@ class OIDCAuthenticationEHerkenningBackend( SoloConfigEHerkenningMixin, OIDCAuthenticationBackend ): """ - Allows logging in via OIDC with DigiD + Allows logging in via OIDC with eHerkenning """ login_type = LoginTypeChoices.eherkenning diff --git a/src/open_inwoner/accounts/templates/django_registration/registration_form.html b/src/open_inwoner/accounts/templates/django_registration/registration_form.html index f3024d2f77..140273acf9 100644 --- a/src/open_inwoner/accounts/templates/django_registration/registration_form.html +++ b/src/open_inwoner/accounts/templates/django_registration/registration_form.html @@ -5,13 +5,13 @@
{% render_grid %} - {% if settings.DIGID_ENABLED and digit_url %} + {% if settings.DIGID_ENABLED and digid_url %} {% render_column start=5 span=5 %} {% render_card direction='horizontal' tinted=True %} - - {% link bold=True href=digit_url text=_('Registreren met DigiD') secondary=True icon='arrow_forward' %} + {% link bold=True href=digid_url text=_('Registreren met DigiD') secondary=True icon='arrow_forward' %} {% endrender_card %} {% endrender_column %} {% endif %} diff --git a/src/open_inwoner/accounts/tests/test_oidc_views.py b/src/open_inwoner/accounts/tests/test_oidc_views.py index b1dac05f7a..58e817c702 100644 --- a/src/open_inwoner/accounts/tests/test_oidc_views.py +++ b/src/open_inwoner/accounts/tests/test_oidc_views.py @@ -435,9 +435,9 @@ def test_existing_kvk_creates_no_new_user( mock_verify_token, mock_store_tokens, mock_get_userinfo, - mock_kvk, + mock_retrieve_rsin_with_kvk, ): - mock_kvk.return_value = "123456789" + mock_retrieve_rsin_with_kvk.return_value = "123456789" # set up a user with a colliding email address # sub is the oidc_id field in our db mock_get_userinfo.return_value = { @@ -445,7 +445,7 @@ def test_existing_kvk_creates_no_new_user( "sub": "some_username", "kvk": "12345678", } - user = DigidUserFactory.create( + user = eHerkenningUserFactory.create( first_name="John", last_name="Doe", kvk="12345678", @@ -473,10 +473,10 @@ def test_existing_kvk_creates_no_new_user( db_user = User.objects.get() # User data was prepopulated, so this should not be called - mock_kvk.assert_not_called() + mock_retrieve_rsin_with_kvk.assert_not_called() self.assertEqual(db_user.id, user.id) self.assertEqual(db_user.kvk, "12345678") - self.assertEqual(db_user.login_type, LoginTypeChoices.digid) + self.assertEqual(db_user.login_type, LoginTypeChoices.eherkenning) self.assertEqual(db_user.first_name, "John") self.assertEqual(db_user.last_name, "Doe") @@ -496,9 +496,9 @@ def test_new_user_is_created_when_new_kvk( mock_verify_token, mock_store_tokens, mock_get_userinfo, - mock_kvk, + mock_retrieve_rsin_with_kvk, ): - mock_kvk.return_value = "123456789" + mock_retrieve_rsin_with_kvk.return_value = "123456789" # set up a user with a non existing email address mock_get_userinfo.return_value = {"sub": "some_username", "kvk": "00000000"} eHerkenningUserFactory.create(kvk="12345678", email="existing_user@example.com") @@ -519,7 +519,7 @@ def test_new_user_is_created_when_new_kvk( ) new_user = User.objects.get(kvk="00000000") - mock_kvk.assert_called_with("00000000") + mock_retrieve_rsin_with_kvk.assert_called_with("00000000") self.assertEqual(new_user.email, "user-00000000@localhost") self.assertEqual(new_user.rsin, "123456789") self.assertEqual(new_user.login_type, LoginTypeChoices.eherkenning) diff --git a/src/open_inwoner/accounts/views/login.py b/src/open_inwoner/accounts/views/login.py index 2ad38c5453..8070afc54c 100644 --- a/src/open_inwoner/accounts/views/login.py +++ b/src/open_inwoner/accounts/views/login.py @@ -17,6 +17,10 @@ from furl import furl from oath import totp +from digid_eherkenning_oidc_generics.models import ( + OpenIDConnectDigiDConfig, + OpenIDConnectEHerkenningConfig, +) from open_inwoner.configurations.models import SiteConfiguration from open_inwoner.utils.mixins import ThrottleMixin from open_inwoner.utils.views import LogMixin diff --git a/src/open_inwoner/accounts/views/registration.py b/src/open_inwoner/accounts/views/registration.py index 3a1ecff72e..3368d2a96c 100644 --- a/src/open_inwoner/accounts/views/registration.py +++ b/src/open_inwoner/accounts/views/registration.py @@ -5,7 +5,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 -from django.urls import reverse, reverse_lazy +from django.urls import NoReverseMatch, reverse from django.utils.translation import gettext as _ from django.views.generic import UpdateView @@ -99,17 +99,18 @@ def get_context_data(self, **kwargs): if invite_key else reverse("profile:registration_necessary") ) + try: config = OpenIDConnectDigiDConfig.get_solo() if config.enabled: digid_url = reverse("digid_oidc:init") else: digid_url = reverse("digid:login") - context["digit_url"] = ( + context["digid_url"] = ( furl(digid_url).add({"next": necessary_fields_url}).url ) - except: - context["digit_url"] = "" + except NoReverseMatch: + context["digid_url"] = "" try: config = OpenIDConnectEHerkenningConfig.get_solo() @@ -120,7 +121,7 @@ def get_context_data(self, **kwargs): context["eherkenning_url"] = ( furl(eherkenning_url).add({"next": necessary_fields_url}).url ) - except: + except NoReverseMatch: context["eherkenning_url"] = "" return context diff --git a/src/open_inwoner/configurations/migrations/0056_alter_siteconfiguration_eherkenning_enabled.py b/src/open_inwoner/configurations/migrations/0056_alter_siteconfiguration_eherkenning_enabled.py new file mode 100644 index 0000000000..8c5c95e0d9 --- /dev/null +++ b/src/open_inwoner/configurations/migrations/0056_alter_siteconfiguration_eherkenning_enabled.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-12-07 11:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("configurations", "0055_siteconfiguration_eherkenning_enabled"), + ] + + operations = [ + migrations.AlterField( + model_name="siteconfiguration", + name="eherkenning_enabled", + field=models.BooleanField( + default=False, + help_text="Whether users can log in with eHerkenning or not. By default, the SAML integration will be used for this, in order to use the OpenID Connect integration, navigate to `OpenID Connect configuration for eHerkenning` and enable it.", + verbose_name="eHerkenning authentication enabled", + ), + ), + ] diff --git a/src/open_inwoner/configurations/models.py b/src/open_inwoner/configurations/models.py index fdc8f258be..8800791ed7 100644 --- a/src/open_inwoner/configurations/models.py +++ b/src/open_inwoner/configurations/models.py @@ -489,7 +489,11 @@ class SiteConfiguration(SingletonModel): eherkenning_enabled = models.BooleanField( verbose_name=_("eHerkenning authentication enabled"), default=False, - help_text=_("Whether users can log in with eHerkenning or not."), + help_text=_( + "Whether users can log in with eHerkenning or not. " + "By default, the SAML integration will be used for this, in order to use " + "the OpenID Connect integration, navigate to `OpenID Connect configuration for eHerkenning` and enable it." + ), ) class Meta: diff --git a/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html b/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html index 807336236d..a5b55409e4 100644 --- a/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html +++ b/src/open_inwoner/templates/digid_eherkenning_oidc_login_failure.html @@ -1,10 +1,6 @@ {% extends 'master.html' %} {% load i18n %} {% block menu %}{% endblock %} - {% block content %} -

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

- - {% endblock content %} From 6a019ca91a9a2dc6c615b7a182efe400fb65dc65 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Thu, 7 Dec 2023 12:45:24 +0100 Subject: [PATCH 9/9] :recycle: [#1902/1903] Hash BSN when generating email for DigiD OIDC users tasks: * https://taiga.maykinmedia.nl/project/open-inwoner/task/1902 * https://taiga.maykinmedia.nl/project/open-inwoner/task/1903 --- src/digid_eherkenning_oidc_generics/backends.py | 9 ++++----- src/open_inwoner/accounts/tests/test_oidc_views.py | 13 +++++++++++-- src/open_inwoner/utils/hash.py | 8 +++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/digid_eherkenning_oidc_generics/backends.py b/src/digid_eherkenning_oidc_generics/backends.py index 8e4f963646..b5dba3406d 100644 --- a/src/digid_eherkenning_oidc_generics/backends.py +++ b/src/digid_eherkenning_oidc_generics/backends.py @@ -1,17 +1,14 @@ import logging -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import SuspiciousOperation from django.urls import reverse_lazy from mozilla_django_oidc_db.backends import ( OIDCAuthenticationBackend as _OIDCAuthenticationBackend, ) -from requests.exceptions import HTTPError, RequestException from open_inwoner.accounts.choices import LoginTypeChoices +from open_inwoner.utils.hash import generate_email_from_string -from .constants import DIGID_OIDC_AUTH_SESSION_KEY, EHERKENNING_OIDC_AUTH_SESSION_KEY from .mixins import SoloConfigDigiDMixin, SoloConfigEHerkenningMixin logger = logging.getLogger(__name__) @@ -49,7 +46,9 @@ def create_user(self, claims): user = self.UserModel.objects.create_user( **{ - self.UserModel.USERNAME_FIELD: "user-{}@localhost".format(unique_id), + self.UserModel.USERNAME_FIELD: generate_email_from_string( + unique_id, domain="localhost" + ), identifier_claim_name: unique_id, "login_type": self.login_type, } diff --git a/src/open_inwoner/accounts/tests/test_oidc_views.py b/src/open_inwoner/accounts/tests/test_oidc_views.py index 58e817c702..d2a4e7ecaf 100644 --- a/src/open_inwoner/accounts/tests/test_oidc_views.py +++ b/src/open_inwoner/accounts/tests/test_oidc_views.py @@ -1,3 +1,4 @@ +from hashlib import md5 from unittest.mock import patch from django.contrib.auth import get_user_model @@ -316,7 +317,11 @@ def test_new_user_is_created_when_new_bsn( new_user = User.objects.get(bsn="000000000") mock_brp.assert_called_with(new_user) - self.assertEqual(new_user.email, "user-000000000@localhost") + salt = "generate_email_from_bsn" + hashed_bsn = md5( + (salt + "000000000").encode(), usedforsecurity=False + ).hexdigest() + self.assertEqual(new_user.email, f"{hashed_bsn}@localhost") self.assertEqual(new_user.login_type, LoginTypeChoices.digid) @patch( @@ -520,7 +525,11 @@ def test_new_user_is_created_when_new_kvk( new_user = User.objects.get(kvk="00000000") mock_retrieve_rsin_with_kvk.assert_called_with("00000000") - self.assertEqual(new_user.email, "user-00000000@localhost") + salt = "generate_email_from_bsn" + hashed_bsn = md5( + (salt + "00000000").encode(), usedforsecurity=False + ).hexdigest() + self.assertEqual(new_user.email, f"{hashed_bsn}@localhost") self.assertEqual(new_user.rsin, "123456789") self.assertEqual(new_user.login_type, LoginTypeChoices.eherkenning) diff --git a/src/open_inwoner/utils/hash.py b/src/open_inwoner/utils/hash.py index da18bbf163..ac0f61a59e 100644 --- a/src/open_inwoner/utils/hash.py +++ b/src/open_inwoner/utils/hash.py @@ -1,12 +1,14 @@ from hashlib import md5, sha256 +from typing import Optional -def generate_email_from_string(value: str) -> str: +def generate_email_from_string( + value: str, domain: Optional[str] = "example.org" +) -> str: """generate email address based on string""" salt = "generate_email_from_bsn" hashed_bsn = md5((salt + value).encode(), usedforsecurity=False).hexdigest() - - return f"{hashed_bsn}@example.org" + return f"{hashed_bsn}@{domain}" def create_sha256_hash(val, salt=None):