test(infra): medewerker login must be refused without a second factor (refs #132)

The keycloak smoke check now asserts that a password-only grant on the medewerker
realm is rejected and that a TOTP code completes it. The e2e medewerker logins move
to a shared helper that submits Keycloak's OTP challenge. Both fail against the
current realm export, which enforces no MFA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
not
2026-09-03 09:07:45 +02:00
co-authored by Claude Opus 5
parent 94720f0fcb
commit 30c5279e90
5 changed files with 77 additions and 22 deletions
+40 -11
View File
@@ -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
+4 -4
View File
@@ -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();
+3 -4
View File
@@ -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();
+27
View File
@@ -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<void> {
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();
}
+3 -3
View File
@@ -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();