diff --git a/infra/keycloak/check_realms.py b/infra/keycloak/check_realms.py index e338d29..4cb0965 100644 --- a/infra/keycloak/check_realms.py +++ b/infra/keycloak/check_realms.py @@ -1,19 +1,25 @@ #!/usr/bin/env python3 """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" CLIENT = "big-portal" 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 = [ - ("digid", "jan-burger", "bsn", "123456782"), - ("eherkenning", "acme-ondernemer", "kvk", "12345678"), - ("eidas", "pierre-dupont", "eidas_id", "FR/NL"), - ("medewerker", "merel-behandelaar", "__roles__", "behandelaar"), + ("digid", "jan-burger", "bsn", "123456782", False), + ("eherkenning", "acme-ondernemer", "kvk", "12345678", False), + ("eidas", "pierre-dupont", "eidas_id", "FR/NL", False), + ("medewerker", "merel-behandelaar", "__roles__", "behandelaar", True), ] @@ -23,10 +29,17 @@ def decode(jwt): 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({ "grant_type": "password", "client_id": CLIENT, - "username": user, "password": PWD, "scope": "openid", + "username": user, "password": PWD, "scope": "openid", **extra, }).encode() req = urllib.request.Request( f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data, @@ -35,11 +48,27 @@ def grant(realm, user): 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(): 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: - at = decode(grant(realm, user)["access_token"]) + at = decode(grant(realm, user, **extra)["access_token"]) if claim == "__roles__": val = at.get("realm_access", {}).get("roles", []) good = expect in val diff --git a/tests/e2e/catalogus.spec.ts b/tests/e2e/catalogus.spec.ts index d7876c5..bb6e85f 100644 --- a/tests/e2e/catalogus.spec.ts +++ b/tests/e2e/catalogus.spec.ts @@ -1,4 +1,5 @@ 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 // 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 }) => { await page.goto('http://beheer/'); - // The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel). - await page.locator('#username').fill('bram-beheerder'); - await page.locator('#password').fill('test123'); - await page.locator('#kc-login').click(); + // The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel), + // which enforces MFA: password, then a TOTP code. + await loginMedewerker(page, 'bram-beheerder'); await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible(); diff --git a/tests/e2e/default-fill.spec.ts b/tests/e2e/default-fill.spec.ts index 8ff8d03..637fe57 100644 --- a/tests/e2e/default-fill.spec.ts +++ b/tests/e2e/default-fill.spec.ts @@ -1,4 +1,5 @@ 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. // 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 }) => { await page.goto('http://beheer/'); - // Keycloak medewerker-realm login (same realm as behandel). - await page.locator('#username').fill('bram-beheerder'); - await page.locator('#password').fill('test123'); - await page.locator('#kc-login').click(); + // Keycloak medewerker-realm login (same realm as behandel) — password + enforced TOTP. + await loginMedewerker(page, 'bram-beheerder'); await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible(); diff --git a/tests/e2e/medewerker-login.ts b/tests/e2e/medewerker-login.ts new file mode 100644 index 0000000..93bd879 --- /dev/null +++ b/tests/e2e/medewerker-login.ts @@ -0,0 +1,27 @@ +import { createHmac } from 'node:crypto'; +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'; + +// 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 / 1000 / 30))); + 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'); +} + +export async function loginMedewerker(page: Page, username: string): Promise { + await page.locator('#username').fill(username); + await page.locator('#password').fill('test123'); + await page.locator('#kc-login').click(); + + // Keycloak's conditional-OTP step. Its lookAheadWindow accepts the neighbouring counters, so a + // code computed just before a 30-second boundary still validates — no retry needed. + await page.locator('#otp').fill(totp()); + await page.locator('#kc-login').click(); +} diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 27167e5..a7a9c0e 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -1,4 +1,5 @@ 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 // 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 // medewerker realm (a different Keycloak realm than the citizen's digid session). await staff.goto('http://behandel/'); - await staff.locator('#username').fill('merel-behandelaar'); - await staff.locator('#password').fill('test123'); - await staff.locator('#kc-login').click(); + // That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP. + await loginMedewerker(staff, 'merel-behandelaar'); await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();