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
58 lines
2.7 KiB
TypeScript
58 lines
2.7 KiB
TypeScript
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();
|
|
}
|