S-15c · Enforce MFA on the medewerker (Keycloak) realm (#158)
Closes #132.
Staff logins (behandel + beheer portals) now need a second factor; the citizen realms are unchanged.
**How:** every seeded medewerker carries a TOTP credential, which activates Keycloak's stock *conditional OTP* step in both the browser flow and the direct grant — no custom browser-flow JSON in the export. `CONFIGURE_TOTP` is a default required action so a medewerker added later must enrol first. ADR-0031 records the choice and, explicitly, that the shared fixture secret is a demo posture only.
**Tests (red first, 30c5279):**
- `check_realms.py` asserts the medewerker password-only grant is **refused**, then that password + TOTP succeeds and still carries the `behandelaar` role. It failed with `[MFA NOT ENFORCED]` against the old export.
- The three medewerker e2e logins move to `loginMedewerker()` (`tests/e2e/medewerker-login.ts`), which submits Keycloak's OTP prompt. Both TOTP implementations (Python `hmac`, Node `crypto`) are ~6 lines of RFC 6238 — no new dependency.
Verified locally against Keycloak 26.1: password-only → `invalid_grant`, password + code → 200, and the browser flow's `#otp` prompt accepts a computed code and issues an auth code.
## Definition of Done
- [x] Failing test/verify committed first; implementation makes it pass.
- [x] Conventional Commits referencing the issue (`refs #132`).
- [ ] CI green (verify-stack compose smoke + relevant checks).
- [x] `docker compose up` reaches green health within 3 minutes (Keycloak change is import-time only).
- [x] Docs touched (runbook, synthetic-data, demo-script) + ADR-0031 + demo note.
- [x] Closed by the merging PR (`closes #132`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #158
This commit was merged in pull request #158.
This commit is contained in:
@@ -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,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 { 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