feat: force first-login password change + optional TOTP

New staff accounts default to mustChangePassword=true. After login,
/check and staff doors return 403 must_change_password until
POST /password/change succeeds; then /enroll-2fa offers optional
authenticator setup (skip allowed). Login requires totpCode when
enabled. Admin POST /admin/mark-must-change re-marks invite owners.
This commit is contained in:
George Lambert 2026-10-05 15:39:34 -04:00
commit 0078a64b07
11 changed files with 839 additions and 28 deletions

View file

@ -65,3 +65,17 @@ Sessions carry `capabilities` (assigned roles) and `currentRole` (active interfa
CORS allows credentialed calls from `*.zapier.georgelambert.org` so department doors can call IAM directly.
## First login (force password change + optional authenticator)
New staff accounts are created with `mustChangePassword=true` (invite OTP is one-time).
After `/login` the session cannot use staff doors (`GET /check` returns `403 must_change_password`)
until the user completes:
1. `GET /change-password` → `POST /password/change` `{ currentPassword, newPassword }`
2. Optional `GET /enroll-2fa` → `POST /2fa/setup` → `POST /2fa/enable { code }`, or `POST /2fa/skip`
When authenticator is enabled, subsequent logins require `totpCode` (or `401 totp_required`).
Admin: `POST /admin/mark-must-change` `{ usernames?: string[] }` (defaults to the seven owner invites)
re-marks accounts so their invite OTP cannot skip the change-password step.

View file

@ -100,7 +100,9 @@ async function drawUsers() {
<td><b>${u.name}</b><div class="muted">${u.username}</div></td>
<td>${u.roles.map((r) => `<span class="pill">${r}</span>`).join(' ')}</td>
<td class="muted">${u.permissions.includes('*') ? 'all' : u.permissions.join(', ')}</td>
<td><span class="pill ${u.active ? '' : 'off'}">${u.active ? 'active' : 'inactive'}</span></td>
<td><span class="pill ${u.active ? '' : 'off'}">${u.active ? 'active' : 'inactive'}</span>
${u.mustChangePassword ? '<span class="pill off">must change pw</span>' : ''}
${u.totpEnabled ? '<span class="pill">TOTP</span>' : ''}</td>
<td><button class="btn ghost" data-edit="${u.username}">Edit</button></td>
</tr>`).join('')}</tbody></table>
</div>
@ -216,6 +218,10 @@ function paintSwitcher(user) {
$('who').textContent = `${user.name} · ${user.username} · ${(user.capabilities || user.roles).join(', ')}` +
(user.currentRole ? ` · active ${user.currentRole}` : '');
paintSwitcher(user);
if (user.mustChangePassword) {
location.href = '/change-password?next=' + encodeURIComponent('/');
return;
}
if (!user.permissions.includes('*') && !user.permissions.includes('iam.users.read')) {
$('users').innerHTML = `<div class="card">Signed in as ${user.username}. You do not have iam.users.read — ask an owner to grant IAM admin (iam-admin / owner) to manage staff roles.</div>`;
return;

View file

@ -0,0 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Choose a new password · Staff IAM</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root { --accent:#4f46e5; --bg:#f4f5fb; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; --danger:#dc2626; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; }
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); padding:1.5rem; }
.card { width:min(420px,100%); background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
h1 { margin:0 0 .25rem; font-size:1.25rem; }
p { color:var(--muted); margin:0 0 1rem; }
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
button:disabled { opacity:.6; cursor:wait; }
.err { color:var(--danger); min-height:1.2em; margin-top:.6rem; }
.who { font-size:12px; color:var(--muted); margin-bottom:.5rem; }
#totp-wrap { display:none; }
</style>
</head>
<body>
<section id="auth">
<form class="card" id="form">
<h1>Choose a new password</h1>
<p>Your invite password is one-time. Set a permanent password before using staff doors.</p>
<div class="who" id="who"></div>
<label for="current">Current (invite) password</label>
<input id="current" type="password" autocomplete="current-password" required/>
<label for="nextpw">New password</label>
<input id="nextpw" type="password" autocomplete="new-password" minlength="8" required/>
<label for="confirm">Confirm new password</label>
<input id="confirm" type="password" autocomplete="new-password" minlength="8" required/>
<div id="totp-wrap">
<label for="totp">Authenticator code</label>
<input id="totp" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="123456"/>
</div>
<p class="err" id="err"></p>
<button type="submit" id="go">Save new password</button>
</form>
</section>
<script>
const q = new URLSearchParams(location.search);
const next = q.get('next') || '/';
const err = document.getElementById('err');
(async () => {
const r = await fetch('/me', { credentials: 'same-origin' });
if (r.status === 401) { location.href = '/login?next=' + encodeURIComponent('/change-password?next=' + encodeURIComponent(next)); return; }
const { user } = await r.json();
document.getElementById('who').textContent = 'Signed in as ' + user.username + (user.name ? ' · ' + user.name : '');
if (user.totpEnabled) document.getElementById('totp-wrap').style.display = 'block';
if (!user.mustChangePassword) {
// Already cleared — send them on (or offer 2FA if not enrolled).
location.href = user.totpEnabled ? next : ('/enroll-2fa?next=' + encodeURIComponent(next));
}
})().catch((e) => { err.textContent = e.message || String(e); });
document.getElementById('form').onsubmit = async (ev) => {
ev.preventDefault();
err.textContent = '';
const currentPassword = document.getElementById('current').value;
const newPassword = document.getElementById('nextpw').value;
const confirm = document.getElementById('confirm').value;
if (newPassword !== confirm) { err.textContent = 'New passwords do not match.'; return; }
const totpCode = document.getElementById('totp').value.trim();
const btn = document.getElementById('go');
btn.disabled = true;
try {
const r = await fetch('/password/change', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword, next, ...(totpCode ? { totpCode } : {}) }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) {
if (body.error === 'totp_required') {
document.getElementById('totp-wrap').style.display = 'block';
throw new Error('Enter the code from your authenticator app.');
}
throw new Error(body.error || body.reason || 'Could not change password');
}
location.href = body.redirect || (body.offerTotp ? ('/enroll-2fa?next=' + encodeURIComponent(next)) : next);
} catch (e) {
err.textContent = e.message || String(e);
btn.disabled = false;
}
};
</script>
</body>
</html>

101
public/enroll-2fa.html Normal file
View file

@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Authenticator (optional) · Staff IAM</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root { --accent:#4f46e5; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; --danger:#dc2626; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; }
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); padding:1.5rem; }
.card { width:min(440px,100%); background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
h1 { margin:0 0 .25rem; font-size:1.25rem; }
p { color:var(--muted); margin:0 0 .9rem; }
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
button { width:100%; margin-top:.75rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
button.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
.err { color:var(--danger); min-height:1.2em; }
.secret { font-family:ui-monospace,Menlo,monospace; font-size:12px; word-break:break-all; background:#f4f5fb; padding:.6rem .75rem; border-radius:8px; }
#qr { display:block; margin:0.6rem auto; width:180px; height:180px; background:#f4f5fb; border-radius:8px; }
#setup { display:none; }
</style>
</head>
<body>
<section id="auth">
<div class="card">
<h1>Add an authenticator? <span style="font-weight:500;color:var(--muted);font-size:.85rem">(optional)</span></h1>
<p>Protect staff sign-in with Google Authenticator, 1Password, Authy, or any TOTP app. You can skip and enable later from Staff IAM.</p>
<div id="offer">
<button type="button" id="start">Set up authenticator</button>
<button type="button" class="ghost" id="skip">Not now — continue</button>
</div>
<div id="setup">
<canvas id="qr" width="180" height="180" aria-label="QR code"></canvas>
<p class="secret" id="secret"></p>
<p>Scan the QR, or enter the secret manually. Then type the 6-digit code to confirm.</p>
<label for="code">Authenticator code</label>
<input id="code" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="123456"/>
<p class="err" id="err"></p>
<button type="button" id="enable">Enable authenticator</button>
<button type="button" class="ghost" id="skip2">Skip for now</button>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script>
const q = new URLSearchParams(location.search);
const next = q.get('next') || '/';
const err = document.getElementById('err');
async function goNext() { location.href = next; }
async function skip() {
await fetch('/2fa/skip', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: '{}' });
goNext();
}
document.getElementById('skip').onclick = skip;
document.getElementById('skip2').onclick = skip;
(async () => {
const r = await fetch('/me', { credentials: 'same-origin' });
if (r.status === 401) { location.href = '/login'; return; }
const { user } = await r.json();
if (user.mustChangePassword) {
location.href = '/change-password?next=' + encodeURIComponent(next);
return;
}
if (user.totpEnabled) goNext();
})().catch((e) => { err.textContent = e.message || String(e); });
document.getElementById('start').onclick = async () => {
err.textContent = '';
const r = await fetch('/2fa/setup', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: '{}' });
const body = await r.json().catch(() => ({}));
if (!r.ok) { err.textContent = body.error || 'setup failed'; return; }
document.getElementById('offer').style.display = 'none';
document.getElementById('setup').style.display = 'block';
document.getElementById('secret').textContent = body.secret;
if (window.QRCode) {
QRCode.toCanvas(document.getElementById('qr'), body.uri, { width: 180, margin: 1 }, () => {});
}
};
document.getElementById('enable').onclick = async () => {
err.textContent = '';
const code = document.getElementById('code').value.trim();
const r = await fetch('/2fa/enable', {
method: 'POST', credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) { err.textContent = body.error || 'Could not enable'; return; }
goNext();
};
</script>
</body>
</html>

View file

@ -16,12 +16,14 @@
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
button:disabled { opacity:.6; }
.err { color:#dc2626; min-height:1.2em; }
#totp-group { display:none; }
</style>
</head>
<body>
<section id="auth">
<form class="card" method="post" action="/login">
<form class="card" id="form">
<h1>Staff sign-in</h1>
<p>Named account. Roles decide CS, sales, accounting, fleet, and billing admin.</p>
<input type="hidden" name="next" id="next"/>
@ -29,14 +31,55 @@
<input id="username" name="username" autocomplete="username" required/>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required/>
<div id="totp-group">
<label for="totp">Authenticator code</label>
<input id="totp" name="totpCode" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="123456"/>
</div>
<p class="err" id="err"></p>
<button type="submit">Sign in</button>
<button type="submit" id="go">Sign in</button>
</form>
</section>
<script>
const q = new URLSearchParams(location.search);
document.getElementById('next').value = q.get('next') || '/';
if (q.get('error')) document.getElementById('err').textContent = 'Invalid username or password.';
const err = document.getElementById('err');
if (q.get('error') === '1') err.textContent = 'Invalid username or password.';
if (q.get('error') === 'totp') {
document.getElementById('totp-group').style.display = 'block';
err.textContent = 'Enter the code from your authenticator app.';
}
document.getElementById('form').onsubmit = async (ev) => {
ev.preventDefault();
err.textContent = '';
const btn = document.getElementById('go');
btn.disabled = true;
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const totpCode = document.getElementById('totp').value.trim();
const next = document.getElementById('next').value || '/';
try {
const r = await fetch('/login', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password, next, ...(totpCode ? { totpCode } : {}) }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) {
if (body.error === 'totp_required' || body.totpRequired) {
document.getElementById('totp-group').style.display = 'block';
document.getElementById('totp').focus();
throw new Error(totpCode ? 'Authenticator code is not right — try the current one.' : 'Enter the code from your authenticator app.');
}
throw new Error(body.error || 'Invalid username or password.');
}
location.href = body.redirect || (body.mustChangePassword ? ('/change-password?next=' + encodeURIComponent(next)) : next);
} catch (e) {
err.textContent = e.message || String(e);
btn.disabled = false;
}
};
</script>
</body>
</html>

View file

@ -35,13 +35,19 @@ export async function denyOrRedirect(req, res, { permission, html, json }) {
const out = await iamCheck(req, permission);
if (out.ok) return out;
if (html) {
res.writeHead(302, { location: iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`) });
const base = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
let loc = iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`);
if (out.reason === 'must_change_password') {
loc = `${base}/change-password?next=${encodeURIComponent(`http://${req.headers.host || '127.0.0.1'}/`)}`;
}
res.writeHead(302, { location: loc });
res.end();
return out;
}
json(out.status === 403 ? 403 : 401, {
error: out.reason || 'unauthorized',
permission: permission || undefined,
redirect: out.redirect,
});
return out;
}

View file

@ -175,5 +175,7 @@ export function publicUser(u, session = null) {
permissions: expandRoles(roles),
interfaces: interfacesForRoles(roles),
createdMs: u.createdMs,
mustChangePassword: !!u.mustChangePassword,
totpEnabled: !!u.totpEnabled,
};
}

View file

@ -5,7 +5,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
import { verifyPassword } from './passwords.js';
import { loadIam, saveIam } from './store.js';
import { generateTotpSecret, totpUri, verifyTotp } from './totp.js';
import { loadIam, saveIam, OWNER_SEED_USERS } from './store.js';
import {
cookieHeader,
clearCookieHeader,
@ -18,6 +19,7 @@ import {
const PORT = Number(process.env.PORT || 3028);
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const MIN_PASSWORD_LENGTH = 8;
const iam = loadIam();
async function readBody(req) {
@ -42,7 +44,11 @@ function corsHeaders(req) {
try {
const host = new URL(origin).hostname.toLowerCase();
const cookie = (process.env.STAFF_COOKIE_DOMAIN || '.zapier.georgelambert.org').replace(/^\./, '').toLowerCase();
ok = host === cookie || host.endsWith(`.${cookie}`) || host.endsWith('.georgelambert.org');
ok =
host === cookie ||
host.endsWith(`.${cookie}`) ||
host.endsWith('.georgelambert.org') ||
host.endsWith('.verae.ai');
} catch {
ok = false;
}
@ -62,15 +68,49 @@ function actorOf(req) {
if (!sess) return null;
const u = iam.findById(sess.userId);
if (!u || !u.active) return null;
return { ...publicUser(u, sess), _token: token, _sess: sess };
return { ...publicUser(u, sess), _token: token, _sess: sess, _raw: u };
}
function requirePerm(req, res, json, perm) {
const me = actorOf(req);
function changePasswordUrl(next) {
const q = next ? `?next=${encodeURIComponent(next)}` : '';
return `/change-password${q}`;
}
function enroll2faUrl(next) {
const q = next ? `?next=${encodeURIComponent(next)}` : '';
return `/enroll-2fa${q}`;
}
function wantsJson(req) {
return (req.headers['content-type'] || '').includes('json') || (req.headers.accept || '').includes('application/json');
}
function gateMustChange(req, res, json, me, { allowWhenMustChange = false } = {}) {
if (!me) {
json(401, { ok: false, reason: 'not signed in' });
return null;
}
if (me.mustChangePassword && !allowWhenMustChange) {
const next = req.headers.referer || '/';
if (!wantsJson(req) && req.method === 'GET') {
res.writeHead(302, { location: changePasswordUrl(next), ...corsHeaders(req) });
res.end();
return null;
}
json(403, {
ok: false,
reason: 'must_change_password',
redirect: changePasswordUrl('/'),
username: me.username,
});
return null;
}
return me;
}
function requirePerm(req, res, json, perm) {
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return null;
if (!allows(me.permissions, perm)) {
iam.log(me.username, 'deny', perm, req.url);
saveIam(iam);
@ -80,6 +120,22 @@ function requirePerm(req, res, json, perm) {
return me;
}
function serveHtml(res, file, extra) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...extra });
res.end(fs.readFileSync(path.join(PUBLIC, file)));
}
function pwError(p) {
return typeof p !== 'string' || p.length < MIN_PASSWORD_LENGTH
? `password must be at least ${MIN_PASSWORD_LENGTH} characters`
: undefined;
}
function totpOk(user, code) {
if (!user.totpEnabled) return true;
return typeof code === 'string' && !!user.totpSecret && verifyTotp(user.totpSecret, code.trim());
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
const extra = corsHeaders(req);
@ -95,18 +151,44 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'verae-staff-iam', users: iam.users.length });
}
// ---- HTML doors ----
if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) {
const me = actorOf(req);
if (me?.mustChangePassword) {
res.writeHead(302, { location: changePasswordUrl(url.searchParams.get('next') || '/'), ...extra });
return res.end();
}
const file = me ? 'app.html' : 'login.html';
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...extra });
return res.end(fs.readFileSync(path.join(PUBLIC, file)));
return serveHtml(res, file, extra);
}
if (req.method === 'GET' && url.pathname === '/change-password') {
const me = actorOf(req);
if (!me) {
res.writeHead(302, { location: `/login?next=${encodeURIComponent('/change-password')}`, ...extra });
return res.end();
}
return serveHtml(res, 'change-password.html', extra);
}
if (req.method === 'GET' && url.pathname === '/enroll-2fa') {
const me = actorOf(req);
if (!me) {
res.writeHead(302, { location: '/login', ...extra });
return res.end();
}
if (me.mustChangePassword) {
res.writeHead(302, { location: changePasswordUrl(url.searchParams.get('next') || '/'), ...extra });
return res.end();
}
return serveHtml(res, 'enroll-2fa.html', extra);
}
if (req.method === 'GET' && url.pathname === '/roles') {
return json(200, { roles: ROLES, permissions: PERMISSIONS, interfaces: ROLE_INTERFACES });
}
if (req.method === 'GET' && url.pathname === '/interfaces') {
const me = actorOf(req);
if (!me) return json(401, { ok: false, reason: 'not signed in' });
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
return json(200, {
ok: true,
currentRole: me.currentRole,
@ -114,10 +196,13 @@ const server = http.createServer(async (req, res) => {
interfaces: me.interfaces,
});
}
// ---- Login ----
if (req.method === 'POST' && url.pathname === '/login') {
const body = await readBody(req);
const username = String(body.username || '').trim();
const password = String(body.password || '');
const totpCode = body.totpCode;
const next = body.next || '/';
if (loginLocked(req, username)) {
iam.log(username || 'unknown', 'login.lock', clientKey(req));
@ -129,28 +214,48 @@ const server = http.createServer(async (req, res) => {
loginFail(req, username);
iam.log(username || 'unknown', 'login.fail', username);
saveIam(iam);
if ((req.headers['content-type'] || '').includes('json')) return json(401, { error: 'invalid username or password' });
if (wantsJson(req) || (req.headers['content-type'] || '').includes('json')) {
return json(401, { error: 'invalid username or password' });
}
res.writeHead(302, { location: '/login?error=1', ...extra });
return res.end();
}
if (user.totpEnabled && !totpOk(user, totpCode)) {
if ((req.headers['content-type'] || '').includes('json') || wantsJson(req)) {
return json(401, { error: 'totp_required', totpRequired: true });
}
res.writeHead(302, { location: '/login?error=totp', ...extra });
return res.end();
}
loginOk(req, username);
const currentRole = inferRoleFromNext(next, user.roles);
const token = iam.issueSession(user.id, { currentRole });
iam.log(username, 'login.ok', username, currentRole || undefined);
saveIam(iam);
const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
const pub = publicUser(user, { currentRole });
let loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
if (user.mustChangePassword) {
loc = changePasswordUrl(loc);
}
if ((req.headers['content-type'] || '').includes('json')) {
res.writeHead(200, {
'content-type': 'application/json',
'set-cookie': cookieHeader(token),
...extra,
});
return res.end(JSON.stringify({ token, user: pub }));
return res.end(
JSON.stringify({
token,
user: pub,
mustChangePassword: !!user.mustChangePassword,
redirect: user.mustChangePassword ? loc : undefined,
}),
);
}
res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc, ...extra });
return res.end();
}
if (req.method === 'POST' && url.pathname === '/logout') {
iam.revokeSession(tokenFromReq(req));
saveIam(iam);
@ -161,9 +266,92 @@ const server = http.createServer(async (req, res) => {
res.writeHead(302, { 'set-cookie': clearCookieHeader(), location: '/login', ...extra });
return res.end();
}
// ---- Self-service password change (allowed while mustChangePassword) ----
if (req.method === 'POST' && url.pathname === '/password/change') {
const me = gateMustChange(req, res, json, actorOf(req), { allowWhenMustChange: true });
if (!me) return;
const body = await readBody(req);
const currentPassword = String(body.currentPassword || '');
const newPassword = String(body.newPassword || body.password || '');
const err = pwError(newPassword);
if (err) return json(400, { error: err });
if (!verifyPassword(currentPassword, me._raw.passwordHash)) {
return json(401, { error: 'current password is incorrect' });
}
if (currentPassword === newPassword) {
return json(400, { error: 'new password must be different from the current password' });
}
if (me._raw.totpEnabled && !totpOk(me._raw, body.totpCode)) {
return json(400, { error: 'totp_required', totpRequired: true });
}
iam.changeOwnPassword(me.id, newPassword, me.username);
iam.revokeUserSessions(me.id, me._token);
saveIam(iam);
const next = typeof body.next === 'string' && body.next ? body.next : '/';
const offer2fa = !me._raw.totpEnabled;
return json(200, {
ok: true,
mustChangePassword: false,
offerTotp: offer2fa,
redirect: offer2fa ? enroll2faUrl(next) : next,
user: publicUser(iam.findById(me.id), me._sess),
});
}
// ---- Optional authenticator enrollment ----
if (req.method === 'POST' && url.pathname === '/2fa/setup') {
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
if (me._raw.totpEnabled) return json(400, { error: 'totp already enabled' });
const secret = generateTotpSecret();
iam.beginTotpSetup(me.id, secret);
saveIam(iam);
const uri = totpUri(secret, me.username);
return json(200, { ok: true, secret, uri, account: me.username, issuer: 'Verae Staff' });
}
if (req.method === 'POST' && url.pathname === '/2fa/enable') {
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
const body = await readBody(req);
const secret = me._raw.totpPendingSecret;
if (!secret) return json(400, { error: 'call /2fa/setup first' });
if (!verifyTotp(secret, String(body.code || '').trim())) {
return json(400, { error: 'invalid authenticator code' });
}
iam.enableTotp(me.id, secret);
saveIam(iam);
return json(200, { ok: true, totpEnabled: true, user: publicUser(iam.findById(me.id), me._sess) });
}
if (req.method === 'POST' && url.pathname === '/2fa/disable') {
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
const body = await readBody(req);
if (!me._raw.totpEnabled) return json(400, { error: 'totp not enabled' });
if (!totpOk(me._raw, body.code)) return json(400, { error: 'invalid authenticator code' });
iam.disableTotp(me.id);
saveIam(iam);
return json(200, { ok: true, totpEnabled: false, user: publicUser(iam.findById(me.id), me._sess) });
}
if (req.method === 'POST' && url.pathname === '/2fa/skip') {
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
await readBody(req);
// Clear any pending setup secret; enrollment remains optional.
if (me._raw.totpPendingSecret) {
me._raw.totpPendingSecret = undefined;
iam.log(me.username, 'totp.skip', me.username);
saveIam(iam);
} else {
iam.log(me.username, 'totp.skip', me.username);
saveIam(iam);
}
return json(200, { ok: true, skipped: true, totpEnabled: !!me._raw.totpEnabled });
}
if (req.method === 'POST' && (url.pathname === '/session/role' || url.pathname === '/me/role')) {
const me = actorOf(req);
if (!me) return json(401, { ok: false, reason: 'not signed in' });
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
const body = await readBody(req);
const role = String(body.role || body.currentRole || '').trim();
if (!role || !me.roles.includes(role)) {
@ -186,22 +374,24 @@ const server = http.createServer(async (req, res) => {
interfaces: interfacesForRoles(user.roles),
});
}
if (req.method === 'GET' && url.pathname === '/check') {
const me = actorOf(req);
if (!me) return json(401, { ok: false, reason: 'not signed in' });
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
const need = url.searchParams.get('permission');
if (need && !allows(me.permissions, need)) {
iam.log(me.username, 'deny', need, 'check');
saveIam(iam);
return json(403, { ok: false, reason: 'missing permission', permission: need, username: me.username });
}
const { _token, _sess, ...user } = me;
const { _token, _sess, _raw, ...user } = me;
return json(200, { ok: true, user, permission: need || null });
}
if (req.method === 'GET' && url.pathname === '/me') {
const me = actorOf(req);
if (!me) return json(401, { ok: false });
const { _token, _sess, ...user } = me;
// /me is readable during must-change so the change-password UI can show who is signed in.
const { _token, _sess, _raw, ...user } = me;
return json(200, { user });
}
if (req.method === 'GET' && url.pathname === '/users') {
@ -239,6 +429,20 @@ const server = http.createServer(async (req, res) => {
return json(err.status || 400, { error: err.message });
}
}
// Admin helper: mark invite accounts must-change (owners / OTP unused).
if (req.method === 'POST' && url.pathname === '/admin/mark-must-change') {
const me = requirePerm(req, res, json, 'iam.users.write');
if (!me) return;
const body = await readBody(req);
const names = Array.isArray(body.usernames) && body.usernames.length
? body.usernames
: [...OWNER_SEED_USERS];
const n = iam.markMustChange(names);
saveIam(iam);
return json(200, { ok: true, marked: n, usernames: names });
}
json(404, { error: 'not found' });
} catch (err) {
json(500, { error: err.message });

View file

@ -12,9 +12,11 @@ export function newId() {
return `stu_${randomBytes(6).toString('hex')}`;
}
const OWNER_SEED_USERS = new Set(['andrew', 'james', 'jim', 'joseph', 'george', 'marchon', 'vaidehi']);
export class StaffIam {
constructor() {
/** @type {Array<{id:string,username:string,name:string,passwordHash:string,roles:string[],active:boolean,createdMs:number}>} */
/** @type {Array<Record<string, any>>} */
this.users = [];
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
this.audit = [];
@ -64,6 +66,8 @@ export class StaffIam {
passwordHash: hashPassword(s.password),
roles: s.roles,
active: true,
mustChangePassword: false,
totpEnabled: false,
createdMs: Date.now(),
});
}
@ -90,11 +94,12 @@ export class StaffIam {
return this.users.find((u) => u.id === id);
}
create({ username, password, name, roles, actor }) {
create({ username, password, name, roles, actor, mustChangePassword }) {
if (!/^[a-zA-Z0-9_.-]+$/.test(username || '')) throw Object.assign(new Error('bad username'), { status: 400 });
if (!password || String(password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 });
if (this.findByUsername(username)) throw Object.assign(new Error('username already exists'), { status: 409 });
const cleanRoles = (roles || []).filter((r) => ROLES[r]);
const forceChange = mustChangePassword !== false; // default true for new staff
const user = {
id: newId(),
username,
@ -102,10 +107,12 @@ export class StaffIam {
passwordHash: hashPassword(password),
roles: cleanRoles.length ? cleanRoles : ['viewer'],
active: true,
mustChangePassword: forceChange,
totpEnabled: false,
createdMs: Date.now(),
};
this.users.push(user);
this.log(actor, 'user.create', username, user.roles.join(','));
this.log(actor, 'user.create', username, `${user.roles.join(',')};mustChange=${forceChange}`);
return user;
}
@ -125,14 +132,88 @@ export class StaffIam {
}
user.active = patch.active;
}
if (typeof patch.mustChangePassword === 'boolean') {
user.mustChangePassword = patch.mustChangePassword;
}
if (patch.password) {
if (String(patch.password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 });
user.passwordHash = hashPassword(patch.password);
// Admin-set password requires the user to pick their own on next login.
user.mustChangePassword = patch.mustChangePassword === false ? false : true;
user.totpPendingSecret = undefined;
}
this.log(actor, 'user.update', username, JSON.stringify({ roles: user.roles, active: user.active }));
this.log(actor, 'user.update', username, JSON.stringify({
roles: user.roles,
active: user.active,
mustChangePassword: !!user.mustChangePassword,
}));
return user;
}
/** Self-service password change; clears mustChangePassword. */
changeOwnPassword(userId, newPassword, actor) {
const user = this.findById(userId);
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
if (!newPassword || String(newPassword).length < 8) {
throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 });
}
user.passwordHash = hashPassword(newPassword);
user.mustChangePassword = false;
this.log(actor || user.username, 'password.change', user.username);
return user;
}
beginTotpSetup(userId, secret) {
const user = this.findById(userId);
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
user.totpPendingSecret = secret;
this.log(user.username, 'totp.setup', user.username);
return user;
}
enableTotp(userId, secret) {
const user = this.findById(userId);
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
user.totpSecret = secret;
user.totpEnabled = true;
user.totpPendingSecret = undefined;
this.log(user.username, 'totp.enable', user.username);
return user;
}
disableTotp(userId) {
const user = this.findById(userId);
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
user.totpSecret = undefined;
user.totpPendingSecret = undefined;
user.totpEnabled = false;
this.log(user.username, 'totp.disable', user.username);
return user;
}
/** Mark invite OTPs as unused / force change for known owner invites. */
markMustChange(usernames) {
const set = new Set((usernames || []).map((u) => String(u).toLowerCase()));
let n = 0;
for (const u of this.users) {
if (set.has(String(u.username).toLowerCase())) {
u.mustChangePassword = true;
u.totpEnabled = false;
u.totpSecret = undefined;
u.totpPendingSecret = undefined;
n += 1;
this.log('system', 'user.mustChange', u.username, 'invite OTP unused until password change');
}
}
return n;
}
revokeUserSessions(userId, exceptToken) {
for (const [tok, s] of Object.entries(this.sessions)) {
if (s && s.userId === userId && tok !== exceptToken) delete this.sessions[tok];
}
}
issueSession(userId, opts = {}) {
const ttlMs = typeof opts === 'number' ? opts : (opts.ttlMs ?? 12 * 60 * 60 * 1000);
const currentRole = typeof opts === 'object' && opts ? opts.currentRole || null : null;
@ -180,7 +261,7 @@ export class StaffIam {
load(raw) {
if (!raw || typeof raw !== 'object') return this;
this.users = Array.isArray(raw.users) ? raw.users : [];
this.users = Array.isArray(raw.users) ? raw.users.map(normalizeUser) : [];
this.audit = Array.isArray(raw.audit) ? raw.audit : [];
this.sessions = raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {};
this.pruneSessions();
@ -188,6 +269,15 @@ export class StaffIam {
}
}
function normalizeUser(u) {
if (!u || typeof u !== 'object') return u;
return {
...u,
mustChangePassword: !!u.mustChangePassword,
totpEnabled: !!u.totpEnabled,
};
}
export function loadIam() {
const iam = new StaffIam();
const p = storePath();
@ -199,6 +289,14 @@ export function loadIam() {
}
}
if (!iam.users.length) iam.seed();
// One-shot env hook for invite markings (also available via admin API).
if (process.env.IAM_MARK_MUST_CHANGE) {
const names = process.env.IAM_MARK_MUST_CHANGE.split(',').map((s) => s.trim()).filter(Boolean);
if (names.length) {
const n = iam.markMustChange(names);
if (n) saveIam(iam);
}
}
return iam;
}
@ -209,3 +307,5 @@ export function saveIam(iam) {
fs.writeFileSync(tmp, JSON.stringify(iam.dump(), null, 2));
fs.renameSync(tmp, p);
}
export { OWNER_SEED_USERS };

76
src/totp.js Normal file
View file

@ -0,0 +1,76 @@
import { createHmac, randomBytes } from 'node:crypto';
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buf) {
let bits = 0;
let value = 0;
let out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s) {
let bits = 0;
let value = 0;
const out = [];
for (const ch of String(s || '')
.toUpperCase()
.replace(/=+$/, '')) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error(`invalid base32 character: ${ch}`);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
export function hotp(secret, counter, digits = 6) {
const key = base32Decode(secret);
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac('sha1', key).update(msg).digest();
const offset = h[h.length - 1] & 0x0f;
const code =
(((h[offset] & 0x7f) << 24) |
(h[offset + 1] << 16) |
(h[offset + 2] << 8) |
h[offset + 3]) %
10 ** digits;
return String(code).padStart(digits, '0');
}
export function totp(secret, atMs, stepSec = 30, digits = 6) {
return hotp(secret, Math.floor(atMs / 1000 / stepSec), digits);
}
export function verifyTotp(secret, code, atMs = Date.now(), window = 1) {
if (!/^\d{6}$/.test(String(code || ''))) return false;
const c = String(code);
for (let w = -window; w <= window; w++) {
if (totp(secret, atMs + w * 30_000) === c) return true;
}
return false;
}
/** 160-bit secret, base32 without padding (authenticator-app standard). */
export function generateTotpSecret() {
return base32Encode(randomBytes(20));
}
export function totpUri(secret, account, issuer = 'Verae Staff') {
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}

165
test/password-totp.test.js Normal file
View file

@ -0,0 +1,165 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateTotpSecret, totp, verifyTotp } from '../src/totp.js';
import { StaffIam } from '../src/store.js';
import { publicUser } from '../src/roles.js';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
test('totp round-trip', () => {
const secret = generateTotpSecret();
assert.match(secret, /^[A-Z2-7]+$/);
const code = totp(secret, Date.now());
assert.match(code, /^\d{6}$/);
assert.equal(verifyTotp(secret, code), true);
assert.equal(verifyTotp(secret, '000000'), false);
});
test('mustChangePassword on create and publicUser', () => {
const iam = new StaffIam().seed();
const u = iam.create({ username: 'patty', password: 'invite-otp-99', name: 'Patty', roles: ['viewer'], actor: 'admin' });
assert.equal(u.mustChangePassword, true);
assert.equal(publicUser(u).mustChangePassword, true);
iam.changeOwnPassword(u.id, 'permanent-pass-1', 'patty');
assert.equal(iam.findById(u.id).mustChangePassword, false);
});
test('force change + optional totp enrollment via HTTP', async () => {
const port = 18029;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-'));
const store = path.join(dir, 'iam.json');
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(port), STAFF_IAM_PATH: store },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 600));
try {
// Create invite user as admin
const login = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'admin-dev-key' }),
});
assert.equal(login.status, 200);
const { token } = await login.json();
const hdr = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
const created = await fetch(`http://127.0.0.1:${port}/users`, {
method: 'POST',
headers: hdr,
body: JSON.stringify({ username: 'invited', password: 'invite-otp-1', name: 'Invited', roles: ['cs'] }),
});
assert.equal(created.status, 201);
const inv = await created.json();
assert.equal(inv.mustChangePassword, true);
// Login as invited
const invLogin = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'invited', password: 'invite-otp-1', next: 'https://cs.example/' }),
});
assert.equal(invLogin.status, 200);
const invBody = await invLogin.json();
assert.equal(invBody.mustChangePassword, true);
assert.match(invBody.redirect, /change-password/);
const invTok = invBody.token;
const invHdr = { authorization: `Bearer ${invTok}`, 'content-type': 'application/json' };
// Door check blocked
const blocked = await fetch(`http://127.0.0.1:${port}/check?permission=cs.credit`, { headers: invHdr });
assert.equal(blocked.status, 403);
const blockedBody = await blocked.json();
assert.equal(blockedBody.reason, 'must_change_password');
// Change password
const ch = await fetch(`http://127.0.0.1:${port}/password/change`, {
method: 'POST',
headers: invHdr,
body: JSON.stringify({ currentPassword: 'invite-otp-1', newPassword: 'new-secure-99', next: 'https://cs.example/' }),
});
assert.equal(ch.status, 200);
const chBody = await ch.json();
assert.equal(chBody.mustChangePassword, false);
assert.equal(chBody.offerTotp, true);
assert.match(chBody.redirect, /enroll-2fa/);
// Now check works
const ok = await fetch(`http://127.0.0.1:${port}/check?permission=cs.credit`, { headers: invHdr });
assert.equal(ok.status, 200);
// Optional TOTP setup + enable
const setup = await fetch(`http://127.0.0.1:${port}/2fa/setup`, { method: 'POST', headers: invHdr, body: '{}' });
assert.equal(setup.status, 200);
const { secret } = await setup.json();
const code = totp(secret, Date.now());
const en = await fetch(`http://127.0.0.1:${port}/2fa/enable`, {
method: 'POST',
headers: invHdr,
body: JSON.stringify({ code }),
});
assert.equal(en.status, 200);
// Login now requires totp
const again = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'invited', password: 'new-secure-99' }),
});
assert.equal(again.status, 401);
assert.equal((await again.json()).error, 'totp_required');
const withTotp = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: 'invited',
password: 'new-secure-99',
totpCode: totp(secret, Date.now()),
}),
});
assert.equal(withTotp.status, 200);
assert.equal((await withTotp.json()).user.totpEnabled, true);
// Skip path for a second user
await fetch(`http://127.0.0.1:${port}/users`, {
method: 'POST',
headers: hdr,
body: JSON.stringify({ username: 'skipper', password: 'invite-otp-2', name: 'Skip', roles: ['viewer'] }),
});
const sLogin = await (await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'skipper', password: 'invite-otp-2' }),
})).json();
await fetch(`http://127.0.0.1:${port}/password/change`, {
method: 'POST',
headers: { authorization: `Bearer ${sLogin.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ currentPassword: 'invite-otp-2', newPassword: 'skip-pass-99' }),
});
const skipped = await fetch(`http://127.0.0.1:${port}/2fa/skip`, {
method: 'POST',
headers: { authorization: `Bearer ${sLogin.token}`, 'content-type': 'application/json' },
body: '{}',
});
assert.equal(skipped.status, 200);
assert.equal((await skipped.json()).skipped, true);
} finally {
child.kill('SIGTERM');
}
});
test('markMustChange sets flag on named owners', () => {
const iam = new StaffIam().seed();
iam.create({ username: 'andrew', password: 'invite-andrew', name: 'A', roles: ['owner'], actor: 'admin', mustChangePassword: false });
assert.equal(iam.findByUsername('andrew').mustChangePassword, false);
const n = iam.markMustChange(['andrew']);
assert.equal(n, 1);
assert.equal(iam.findByUsername('andrew').mustChangePassword, true);
});