Compare commits
3
Commits
a16f811d50
...
b76035ebfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76035ebfe | ||
|
|
984d2e9d54 | ||
|
|
716b8d03e0 |
@@ -63,5 +63,11 @@ Or enrol a phone once: the secret in base32 is `IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE
|
|||||||
(`otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`). The e2e computes its
|
(`otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`). The e2e computes its
|
||||||
own code in `tests/e2e/medewerker-login.ts`.
|
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
|
**Fixture only.** A shared, committed secret is a demo convenience, never a production
|
||||||
posture — see the ADR's consequences.
|
posture — see the ADR's consequences.
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createHmac } from 'node:crypto';
|
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';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
// The medewerker realm enforces MFA (S-15c), so a staff login is two steps: password, then a TOTP
|
// 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.
|
// secret bytes — so the e2e can compute a valid code instead of enrolling an authenticator.
|
||||||
const OTP_SECRET = 'BIGMEDEWERKEROTPSEED';
|
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.
|
// 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 {
|
export function totp(secret = OTP_SECRET, at = Date.now()): string {
|
||||||
const counter = Buffer.alloc(8);
|
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 mac = createHmac('sha1', secret).update(counter).digest();
|
||||||
const offset = mac[mac.length - 1] & 0x0f;
|
const offset = mac[mac.length - 1] & 0x0f;
|
||||||
return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0');
|
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> {
|
export async function loginMedewerker(page: Page, username: string): Promise<void> {
|
||||||
await page.locator('#username').fill(username);
|
await page.locator('#username').fill(username);
|
||||||
await page.locator('#password').fill('test123');
|
await page.locator('#password').fill('test123');
|
||||||
await page.locator('#kc-login').click();
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
// Keycloak's conditional-OTP step. Its lookAheadWindow accepts the neighbouring counters, so a
|
// Keycloak's conditional-OTP step. Wait out the rest of the window if the counter we may spend is
|
||||||
// code computed just before a 30-second boundary still validates — no retry needed.
|
// still in the future; its lookAheadWindow would accept the code a moment early, but only by one
|
||||||
await page.locator('#otp').fill(totp());
|
// 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();
|
await page.locator('#kc-login').click();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user