S-15c · Enforce MFA on the medewerker (Keycloak) realm #158

Merged
not merged 7 commits from feat/132-medewerker-mfa into main 2026-09-04 08:27:53 +00:00
2 changed files with 40 additions and 4 deletions
Showing only changes of commit 984d2e9d54 - Show all commits
+6
View File
@@ -63,5 +63,11 @@ Or enrol a phone once: the secret in base32 is `IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE
(`otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`). The e2e computes its
own code in `tests/e2e/medewerker-login.ts`.
**A code is single-use.** Keycloak's `otpPolicyCodeReusable` defaults to false, so it refuses a
code it has already accepted — a second login as the same medewerker inside the same 30-second
window fails with `invalid_grant` / *Invalid user credentials*, even though the code is current.
Nothing to fix in the realm: wait for the next window, or spend the following counter, which is
what `nextUnusedCounter` in `tests/e2e/medewerker-login.ts` does for back-to-back specs.
**Fixture only.** A shared, committed secret is a demo convenience, never a production
posture — see the ADR's consequences.
+34 -4
View File
@@ -1,4 +1,7 @@
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
@@ -6,22 +9,49 @@ import type { Page } from '@playwright/test';
// 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 / 1000 / 30)));
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. 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());
// 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();
}