S-15c · Enforce MFA on the medewerker (Keycloak) realm #158
@@ -0,0 +1,49 @@
|
|||||||
|
# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-03
|
||||||
|
- **Slice:** S-15c (Gitea #132)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's
|
||||||
|
default-fill: the highest-privilege logins in the platform. The medewerker realm protected
|
||||||
|
them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock
|
||||||
|
brokers that carry their own assurance levels. A reference application that demonstrates a
|
||||||
|
government architecture should show MFA on the staff realm.
|
||||||
|
|
||||||
|
Two things had to be decided: **how** to enforce OTP in a realm export, and **how the
|
||||||
|
automated checks and a human demo obtain a code** — the e2e drives a real browser login and
|
||||||
|
`make keycloak-smoke` drives a real password grant, so neither can scan a QR.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing
|
||||||
|
Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`.
|
||||||
|
|
||||||
|
Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP*
|
||||||
|
subflow that fires when the user has an OTP credential. Seeding the credential therefore
|
||||||
|
turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines
|
||||||
|
of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required
|
||||||
|
action**, so a medewerker created later must enrol before their first login.
|
||||||
|
|
||||||
|
**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all
|
||||||
|
medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib
|
||||||
|
`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in
|
||||||
|
about six lines — no OTP dependency on either side, and no enrolment step in the tests.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A password alone no longer yields a token on the medewerker realm; `check_realms.py`
|
||||||
|
asserts that refusal, so the enforcement cannot silently regress.
|
||||||
|
- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP
|
||||||
|
form. New staff specs must use it.
|
||||||
|
- **The secret is public.** It is a demo fixture and worthless outside this synthetic
|
||||||
|
stack, in the same class as the committed `test123` passwords and the mock DigiD broker.
|
||||||
|
A real deployment enrols per-user authenticators (or federates to DigiD Machtigen /
|
||||||
|
eHerkenning at the required assurance level) and seeds no credentials at all.
|
||||||
|
- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional
|
||||||
|
subflow is what fires. A medewerker whose OTP credential were removed would fall back to
|
||||||
|
the required action at next login (enrol, then challenge) rather than skipping MFA — an
|
||||||
|
acceptable equivalence for this purpose, and the reason the required action is set.
|
||||||
|
- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block.
|
||||||
+36
-4
@@ -140,7 +140,8 @@ zaaktype cache). Store is in-memory: an edit reverts to the configured env on re
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
|
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
||||||
|
# → "Default-fill" tab → change a value → Opslaan.
|
||||||
open http://localhost:8143/default-fill
|
open http://localhost:8143/default-fill
|
||||||
#
|
#
|
||||||
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
||||||
@@ -161,7 +162,8 @@ directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) co
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
|
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
||||||
|
# → the catalogus lists the published zaaktypen.
|
||||||
open http://localhost:8143
|
open http://localhost:8143
|
||||||
#
|
#
|
||||||
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
||||||
@@ -304,7 +306,8 @@ make verify-local # → "OK — a fresh local stack completed the flow with
|
|||||||
|
|
||||||
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||||
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||||
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
# test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at
|
||||||
|
# http://localhost:8141.
|
||||||
```
|
```
|
||||||
|
|
||||||
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||||
@@ -589,7 +592,7 @@ or **afwijzen** — which also completes the Beoordelen task so the process adva
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
||||||
# http://localhost:8142/ → merel-behandelaar / test123
|
# http://localhost:8142/ → merel-behandelaar / test123 + OTP
|
||||||
#
|
#
|
||||||
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
||||||
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
||||||
@@ -812,3 +815,32 @@ make verify-domain # → "the timed-out registration's zaak was cancelled to
|
|||||||
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
|
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
|
||||||
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
|
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
|
||||||
job for redelivery rather than diverging the two (ADR-0019).
|
job for redelivery rather than diverging the two (ADR-0019).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-15c — MFA on the medewerker realm (#132, ADR-0031)
|
||||||
|
|
||||||
|
**Outcome:** staff logins (behandel + beheer portals) need a **second factor**. The medewerker realm
|
||||||
|
seeds every medewerker with a TOTP credential, so Keycloak's conditional-OTP step challenges them in
|
||||||
|
both the browser flow and the direct grant; a password alone no longer yields a token. `CONFIGURE_TOTP`
|
||||||
|
is a default required action, so a medewerker added later must enrol first. Citizen realms (digid,
|
||||||
|
eherkenning, eidas) are unchanged — they mock brokers that carry their own assurance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Manual: log in to the behandel portal. After username + password Keycloak asks for a code.
|
||||||
|
python3 infra/keycloak/check_realms.py otp # a valid code, right now
|
||||||
|
open http://localhost:8142 # merel-behandelaar / test123 + that code
|
||||||
|
#
|
||||||
|
# 2. Automated: the realm smoke check asserts the password alone is REFUSED, then that
|
||||||
|
# password + TOTP succeeds and still carries the behandelaar role:
|
||||||
|
make keycloak-smoke # → "medewerker merel-behandelaar password-only login refused [OK]"
|
||||||
|
#
|
||||||
|
# 3. End-to-end: every staff login in the e2e goes through the OTP prompt (loginMedewerker):
|
||||||
|
make verify-e2e # → registration.spec (behandelaar approves), catalogus.spec, default-fill.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** the seeded `otp` credential in `infra/keycloak/realms/medewerker-realm.json` activates
|
||||||
|
Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture secret is shared and
|
||||||
|
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
|
||||||
|
(ADR-0031).
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ login per realm and asserts the identifying claim:
|
|||||||
| eidas | pierre-dupont | `eidas_id` |
|
| eidas | pierre-dupont | `eidas_id` |
|
||||||
| medewerker | merel-behandelaar | role `behandelaar` |
|
| medewerker | merel-behandelaar | role `behandelaar` |
|
||||||
|
|
||||||
|
The medewerker row also asserts that the password **alone** is refused — that realm
|
||||||
|
enforces MFA (below).
|
||||||
|
|
||||||
All test users / credentials are in [../synthetic-data.md](../synthetic-data.md).
|
All test users / credentials are in [../synthetic-data.md](../synthetic-data.md).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -35,3 +38,36 @@ All test users / credentials are in [../synthetic-data.md](../synthetic-data.md)
|
|||||||
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
|
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
|
||||||
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
|
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
|
||||||
claim); `medewerker` roles come through `realm_access.roles`.
|
claim); `medewerker` roles come through `realm_access.roles`.
|
||||||
|
|
||||||
|
## MFA on the medewerker realm (S-15c)
|
||||||
|
|
||||||
|
Staff logins (behandel + beheer portals) need a second factor; citizen/company realms
|
||||||
|
(digid, eherkenning, eidas) do not. Two halves in `medewerker-realm.json`:
|
||||||
|
|
||||||
|
- Every seeded medewerker carries a **TOTP credential** with the fixture secret
|
||||||
|
`BIGMEDEWERKEROTPSEED`, so Keycloak's built-in *conditional OTP* step fires on every
|
||||||
|
login — browser flow (an `#otp` prompt after the password) and direct grant (a `totp`
|
||||||
|
form field) alike.
|
||||||
|
- `CONFIGURE_TOTP` is a **default required action**, so any medewerker added later must
|
||||||
|
enrol an authenticator before the first login.
|
||||||
|
|
||||||
|
See [../architecture/adr-0031-mfa-on-the-medewerker-realm.md](../architecture/adr-0031-mfa-on-the-medewerker-realm.md).
|
||||||
|
|
||||||
|
### Getting a code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 infra/keycloak/check_realms.py otp # prints a valid 6-digit code right now
|
||||||
|
```
|
||||||
|
|
||||||
|
Or enrol a phone once: the secret in base32 is `IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`
|
||||||
|
(`otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`). The e2e computes its
|
||||||
|
own code in `tests/e2e/medewerker-login.ts`.
|
||||||
|
|
||||||
|
**A code is single-use.** Keycloak's `otpPolicyCodeReusable` defaults to false, so it refuses a
|
||||||
|
code it has already accepted — a second login as the same medewerker inside the same 30-second
|
||||||
|
window fails with `invalid_grant` / *Invalid user credentials*, even though the code is current.
|
||||||
|
Nothing to fix in the realm: wait for the next window, or spend the following counter, which is
|
||||||
|
what `nextUnusedCounter` in `tests/e2e/medewerker-login.ts` does for back-to-back specs.
|
||||||
|
|
||||||
|
**Fixture only.** A shared, committed secret is a demo convenience, never a production
|
||||||
|
posture — see the ADR's consequences.
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ All test users share the password **`test123`**.
|
|||||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||||
| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` |
|
| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` |
|
||||||
|
| `medewerker` | Internal staff | `bram-beheerder` | role `beheerder` |
|
||||||
|
|
||||||
|
`medewerker` users additionally need a **second factor**: that realm enforces MFA (S-15c,
|
||||||
|
ADR-0031). All three share the fixture TOTP secret `BIGMEDEWERKEROTPSEED`; print a current
|
||||||
|
code with `python3 infra/keycloak/check_realms.py otp`.
|
||||||
|
|
||||||
The identifying claims are injected via OIDC protocol mappers on `big-portal`
|
The identifying claims are injected via OIDC protocol mappers on `big-portal`
|
||||||
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
|
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
|
||||||
@@ -32,5 +37,8 @@ curl -s -X POST \
|
|||||||
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token
|
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a `medewerker` user, add `-d totp=$(python3 infra/keycloak/check_realms.py otp)` —
|
||||||
|
without it the grant is refused with `invalid_grant`.
|
||||||
|
|
||||||
Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm
|
Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm
|
||||||
automatically.
|
automatically.
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
|
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
|
||||||
and returns its expected identifying claim. Stdlib only. Exits non-zero on failure.
|
and returns its expected identifying claim. The medewerker realm additionally enforces
|
||||||
|
MFA (S-15c), so its login must be refused without a TOTP code. Stdlib only.
|
||||||
|
Exits non-zero on failure.
|
||||||
"""
|
"""
|
||||||
import base64, json, sys, urllib.error, urllib.parse, urllib.request
|
import base64, hashlib, hmac, json, struct, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://localhost:8180"
|
BASE = "http://localhost:8180"
|
||||||
CLIENT = "big-portal"
|
CLIENT = "big-portal"
|
||||||
PWD = "test123"
|
PWD = "test123"
|
||||||
|
|
||||||
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains
|
# Fixture TOTP secret seeded into every medewerker in infra/keycloak/realms/medewerker-realm.json.
|
||||||
|
# Keycloak HMACs the raw secret bytes, so no base32 decoding is involved.
|
||||||
|
OTP_SECRET = b"BIGMEDEWERKEROTPSEED"
|
||||||
|
|
||||||
|
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains, mfa-enforced
|
||||||
CHECKS = [
|
CHECKS = [
|
||||||
("digid", "jan-burger", "bsn", "123456782"),
|
("digid", "jan-burger", "bsn", "123456782", False),
|
||||||
("eherkenning", "acme-ondernemer", "kvk", "12345678"),
|
("eherkenning", "acme-ondernemer", "kvk", "12345678", False),
|
||||||
("eidas", "pierre-dupont", "eidas_id", "FR/NL"),
|
("eidas", "pierre-dupont", "eidas_id", "FR/NL", False),
|
||||||
("medewerker", "merel-behandelaar", "__roles__", "behandelaar"),
|
("medewerker", "merel-behandelaar", "__roles__", "behandelaar", True),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -23,10 +29,17 @@ def decode(jwt):
|
|||||||
return json.loads(base64.urlsafe_b64decode(p))
|
return json.loads(base64.urlsafe_b64decode(p))
|
||||||
|
|
||||||
|
|
||||||
def grant(realm, user):
|
def totp(secret=OTP_SECRET, period=30, digits=6):
|
||||||
|
"""RFC 6238 code: HMAC-SHA1 over the 30-second counter, dynamically truncated."""
|
||||||
|
mac = hmac.new(secret, struct.pack(">Q", int(time.time()) // period), hashlib.sha1).digest()
|
||||||
|
o = mac[-1] & 0x0F
|
||||||
|
return str((struct.unpack(">I", mac[o:o + 4])[0] & 0x7FFFFFFF) % 10 ** digits).zfill(digits)
|
||||||
|
|
||||||
|
|
||||||
|
def grant(realm, user, **extra):
|
||||||
data = urllib.parse.urlencode({
|
data = urllib.parse.urlencode({
|
||||||
"grant_type": "password", "client_id": CLIENT,
|
"grant_type": "password", "client_id": CLIENT,
|
||||||
"username": user, "password": PWD, "scope": "openid",
|
"username": user, "password": PWD, "scope": "openid", **extra,
|
||||||
}).encode()
|
}).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
|
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
|
||||||
@@ -35,11 +48,27 @@ def grant(realm, user):
|
|||||||
return json.loads(r.read())
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def second_factor_refused(realm, user):
|
||||||
|
"""The password alone must not yield a token on an MFA-enforced realm."""
|
||||||
|
try:
|
||||||
|
grant(realm, user)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code in (400, 401)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ok = True
|
ok = True
|
||||||
for realm, user, claim, expect in CHECKS:
|
for realm, user, claim, expect, mfa in CHECKS:
|
||||||
|
extra = {}
|
||||||
|
if mfa:
|
||||||
|
refused = second_factor_refused(realm, user)
|
||||||
|
ok = ok and refused
|
||||||
|
print(f"{realm:12} {user:18} password-only login refused "
|
||||||
|
f"[{'OK' if refused else 'MFA NOT ENFORCED'}]")
|
||||||
|
extra = {"totp": totp()}
|
||||||
try:
|
try:
|
||||||
at = decode(grant(realm, user)["access_token"])
|
at = decode(grant(realm, user, **extra)["access_token"])
|
||||||
if claim == "__roles__":
|
if claim == "__roles__":
|
||||||
val = at.get("realm_access", {}).get("roles", [])
|
val = at.get("realm_access", {}).get("roles", [])
|
||||||
good = expect in val
|
good = expect in val
|
||||||
@@ -57,4 +86,9 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
# `check_realms.py otp` prints a current code for the fixture secret — what a human demoing
|
||||||
|
# the medewerker portals types at Keycloak's OTP prompt (docs/runbooks/keycloak.md).
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "otp":
|
||||||
|
print(totp())
|
||||||
|
else:
|
||||||
|
main()
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
"realm": "medewerker",
|
"realm": "medewerker",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"displayName": "Medewerkers",
|
"displayName": "Medewerkers",
|
||||||
|
"requiredActions": [
|
||||||
|
{
|
||||||
|
"alias": "CONFIGURE_TOTP",
|
||||||
|
"name": "Configure OTP",
|
||||||
|
"providerId": "CONFIGURE_TOTP",
|
||||||
|
"enabled": true,
|
||||||
|
"defaultAction": true,
|
||||||
|
"priority": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
"roles": {
|
"roles": {
|
||||||
"realm": [
|
"realm": [
|
||||||
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
||||||
@@ -43,7 +53,15 @@
|
|||||||
"lastName": "Behandelaar",
|
"lastName": "Behandelaar",
|
||||||
"email": "merel@big.example.nl",
|
"email": "merel@big.example.nl",
|
||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
"credentials": [
|
||||||
|
{ "type": "password", "value": "test123", "temporary": false },
|
||||||
|
{
|
||||||
|
"type": "otp",
|
||||||
|
"userLabel": "seeded TOTP (fixture)",
|
||||||
|
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
|
||||||
|
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
"realmRoles": ["behandelaar"]
|
"realmRoles": ["behandelaar"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,7 +71,15 @@
|
|||||||
"lastName": "Teamlead",
|
"lastName": "Teamlead",
|
||||||
"email": "tom@big.example.nl",
|
"email": "tom@big.example.nl",
|
||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
"credentials": [
|
||||||
|
{ "type": "password", "value": "test123", "temporary": false },
|
||||||
|
{
|
||||||
|
"type": "otp",
|
||||||
|
"userLabel": "seeded TOTP (fixture)",
|
||||||
|
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
|
||||||
|
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
"realmRoles": ["behandelaar", "teamlead"]
|
"realmRoles": ["behandelaar", "teamlead"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,7 +89,15 @@
|
|||||||
"lastName": "Beheerder",
|
"lastName": "Beheerder",
|
||||||
"email": "bram@big.example.nl",
|
"email": "bram@big.example.nl",
|
||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
"credentials": [
|
||||||
|
{ "type": "password", "value": "test123", "temporary": false },
|
||||||
|
{
|
||||||
|
"type": "otp",
|
||||||
|
"userLabel": "seeded TOTP (fixture)",
|
||||||
|
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
|
||||||
|
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
"realmRoles": ["beheerder"]
|
"realmRoles": ["beheerder"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { loginMedewerker } from './medewerker-login';
|
||||||
|
|
||||||
// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the
|
// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the
|
||||||
// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the
|
// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the
|
||||||
@@ -7,10 +8,9 @@ import { expect, test } from '@playwright/test';
|
|||||||
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
|
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
|
||||||
await page.goto('http://beheer/');
|
await page.goto('http://beheer/');
|
||||||
|
|
||||||
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel).
|
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel),
|
||||||
await page.locator('#username').fill('bram-beheerder');
|
// which enforces MFA: password, then a TOTP code.
|
||||||
await page.locator('#password').fill('test123');
|
await loginMedewerker(page, 'bram-beheerder');
|
||||||
await page.locator('#kc-login').click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { loginMedewerker } from './medewerker-login';
|
||||||
|
|
||||||
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
|
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
|
||||||
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
|
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
|
||||||
@@ -6,10 +7,8 @@ import { expect, test } from '@playwright/test';
|
|||||||
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
||||||
await page.goto('http://beheer/');
|
await page.goto('http://beheer/');
|
||||||
|
|
||||||
// Keycloak medewerker-realm login (same realm as behandel).
|
// Keycloak medewerker-realm login (same realm as behandel) — password + enforced TOTP.
|
||||||
await page.locator('#username').fill('bram-beheerder');
|
await loginMedewerker(page, 'bram-beheerder');
|
||||||
await page.locator('#password').fill('test123');
|
|
||||||
await page.locator('#kc-login').click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { OTP_PERIOD_MS, nextUnusedCounter } from './medewerker-login';
|
||||||
|
|
||||||
|
// Pure check of the TOTP counter guard in loginMedewerker — no browser, no stack. Keycloak refuses
|
||||||
|
// a code it has already accepted (its otpPolicyCodeReusable defaults to false), so two logins as
|
||||||
|
// the same medewerker inside one 30-second window must not spend the same counter twice (#132).
|
||||||
|
test('a login never spends a TOTP counter this medewerker already used', () => {
|
||||||
|
const now = 3 * OTP_PERIOD_MS + 1_000; // 1 second into counter 3
|
||||||
|
|
||||||
|
expect(nextUnusedCounter(now, -1)).toBe(3); // nothing spent yet → the current counter
|
||||||
|
expect(nextUnusedCounter(now, 3)).toBe(4); // the current counter is spent → the next one
|
||||||
|
expect(nextUnusedCounter(now, 4)).toBe(5); // two logins already in this window → the one after
|
||||||
|
expect(nextUnusedCounter(now + OTP_PERIOD_MS, 3)).toBe(4); // window moved on → current again
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// The medewerker realm enforces MFA (S-15c), so a staff login is two steps: password, then a TOTP
|
||||||
|
// code. The realm export seeds every medewerker with this fixture secret — Keycloak HMACs the raw
|
||||||
|
// secret bytes — so the e2e can compute a valid code instead of enrolling an authenticator.
|
||||||
|
const OTP_SECRET = 'BIGMEDEWERKEROTPSEED';
|
||||||
|
|
||||||
|
export const OTP_PERIOD_MS = 30_000;
|
||||||
|
|
||||||
|
// RFC 6238 TOTP: HMAC-SHA1 over the 30-second counter, dynamically truncated to 6 digits.
|
||||||
|
export function totp(secret = OTP_SECRET, at = Date.now()): string {
|
||||||
|
const counter = Buffer.alloc(8);
|
||||||
|
counter.writeBigUInt64BE(BigInt(Math.floor(at / OTP_PERIOD_MS)));
|
||||||
|
const mac = createHmac('sha1', secret).update(counter).digest();
|
||||||
|
const offset = mac[mac.length - 1] & 0x0f;
|
||||||
|
return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keycloak refuses a TOTP code it has already accepted (its otpPolicyCodeReusable defaults to
|
||||||
|
// false), so two logins as the same medewerker inside one 30-second window would both submit the
|
||||||
|
// same code and the second is rejected. Spend the first counter this medewerker has left.
|
||||||
|
export function nextUnusedCounter(now: number, spent: number): number {
|
||||||
|
return Math.max(Math.floor(now / OTP_PERIOD_MS), spent + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The spent counter lives on disk rather than in module state: Playwright starts a fresh worker
|
||||||
|
// process for a retry, which would otherwise forget it and resubmit the rejected code.
|
||||||
|
function spendCounter(username: string): number {
|
||||||
|
const file = join(tmpdir(), `otp-counter-${username}`);
|
||||||
|
let spent = -1;
|
||||||
|
try {
|
||||||
|
spent = Number(readFileSync(file, 'utf8')) || -1;
|
||||||
|
} catch {
|
||||||
|
// first login as this medewerker in this run
|
||||||
|
}
|
||||||
|
const counter = nextUnusedCounter(Date.now(), spent);
|
||||||
|
writeFileSync(file, String(counter));
|
||||||
|
return counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginMedewerker(page: Page, username: string): Promise<void> {
|
||||||
|
await page.locator('#username').fill(username);
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
// Keycloak's conditional-OTP step. Wait out the rest of the window if the counter we may spend is
|
||||||
|
// still in the future; its lookAheadWindow would accept the code a moment early, but only by one
|
||||||
|
// counter — waiting keeps a third login in the same window valid too.
|
||||||
|
const counter = spendCounter(username);
|
||||||
|
await page.waitForTimeout(Math.max(0, counter * OTP_PERIOD_MS - Date.now()));
|
||||||
|
await page.locator('#otp').fill(totp(OTP_SECRET, counter * OTP_PERIOD_MS));
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, request, test } from '@playwright/test';
|
import { expect, request, test } from '@playwright/test';
|
||||||
|
import { loginMedewerker } from './medewerker-login';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
|
||||||
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
|
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
|
||||||
@@ -76,9 +77,8 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
|
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
|
||||||
// medewerker realm (a different Keycloak realm than the citizen's digid session).
|
// medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||||
await staff.goto('http://behandel/');
|
await staff.goto('http://behandel/');
|
||||||
await staff.locator('#username').fill('merel-behandelaar');
|
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
|
||||||
await staff.locator('#password').fill('test123');
|
await loginMedewerker(staff, 'merel-behandelaar');
|
||||||
await staff.locator('#kc-login').click();
|
|
||||||
|
|
||||||
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user