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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user