Files
selfphoneng/scripts/e2e/m11-t1-totp.mjs
T
iamdoubz 8c750c67ca
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 27s
auth: two-step sign-in (TOTP) for admins, with recovery codes
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1
step) with node:crypto only, checked against the RFC's test vectors.
Migration 0003: users.totp_recovery_hashes and totp_last_step.

Admins turn it on from My account (the header's "Account"): scan the
QR or type the key, confirm a code, get 10 recovery codes once. Login
asks for the code only after the password is right (401 totp_required,
no session); a wrong code is a failed login (rate limit, audit). A
step signs in once (totp_last_step updated atomically) and a recovery
code once (array_remove guarded by any()). Off and new codes need the
password; another admin can turn it off for a lost phone, which signs
that user out. Users list shows it (twoStep).

Also, found by the full e2e chain on one shared database:
- Linphone tokens: only the exact token issued is accepted. The last
  base64 character can carry ignored bits, so another spelling decoded
  to the same signed token.
- Test scripts no longer assume a fresh system: m3-t3 picks Dan in the
  test box, m8-check dials whatever slot the park took (findslot =
  next), m10-t4 uses the admin's existing extension, m11-t1 clears the
  public IP on its own first (local nets are in the transports while
  one is set, and transports change only on a restart).

e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes.

Task: M11-T1
2026-10-19 15:41:53 -05:00

85 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T1 two-step sign-in in a browser: the admin turns it on from My account (scans the key,
// enters a code, gets 10 recovery codes), signs out, signs in with password then a code, then with
// a recovery code, and turns it off again (later scripts sign in with the password alone).
// Codes come from the api's own TOTP module, like an authenticator app would make them.
// Runs after m1-t4.mjs.
// Usage: node scripts/e2e/m11-t1-totp.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { launch } from './cdp.mjs';
const { stepAt, totpCode } = await import('../../apps/api/src/auth/totp.ts');
const [base, email, pw, shotsDir] = process.argv.slice(2);
const p = await launch({ shotsDir });
const signIn = async (code) => {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`document.body.innerText.includes('Authentication code')`, 'asked for the code');
await p.fill('Authentication code', code);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`, 'signed in');
};
const signOut = async () => {
await p.click('Sign out');
await p.waitFor(`location.pathname === '/login'`);
};
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`);
// Turn it on.
await p.click('Account');
await p.waitFor(`document.body.innerText.includes('Turn on two-step sign-in')`, 'status');
await p.click('Turn on two-step sign-in');
await p.waitFor(`!!document.querySelector('img[alt="Authenticator setup QR code"]')`, 'QR');
const secret = await p.evaluate(
`[...document.querySelectorAll('span.font-mono')].map(s => s.textContent).find(t => /^[A-Z2-7]{32}$/.test(t))`,
);
assert.match(secret, /^[A-Z2-7]{32}$/);
await p.fill('2. Enter the 6-digit code it shows', '000000');
await p.click('Turn on');
await p.waitFor(`document.body.innerText.includes('doesn’t match')`, 'wrong code refused');
await p.fill('2. Enter the 6-digit code it shows', totpCode(secret, stepAt()));
await p.click('Turn on');
await p.waitFor(`!!document.querySelector('ul[aria-label="Recovery codes"]')`, 'codes');
const codes = await p.evaluate(
`[...document.querySelectorAll('ul[aria-label="Recovery codes"] li')].map(l => l.textContent)`,
);
assert.equal(codes.length, 10);
await p.shot('m11-t1-02-recovery-codes');
console.log('two-step sign-in on from My account (a wrong code refused); 10 recovery codes');
// Sign in with a code (the next step: the current one was spent turning it on).
await signOut();
await signIn(totpCode(secret, stepAt() + 1));
console.log('signed in with password, then the authenticator code');
// And with a recovery code.
await signOut();
await signIn(codes[0]);
await p.click('Account');
await p.waitFor(`document.body.innerText.includes('9 recovery codes left')`, '9 left');
console.log('signed in with a recovery code; 9 left');
// Off again.
await p.fill('Your password', pw);
await p.click('Turn off');
await p.waitFor(`document.body.innerText.includes('Turn on two-step sign-in')`, 'off');
console.log('turned off with the password');
console.log('M11-T1 (two-step sign-in): PASS');
} catch (e) {
await p.shot('m11-t1-totp-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
}