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:
parent
b8670c8e18
commit
0078a64b07
11 changed files with 839 additions and 28 deletions
14
README.md
14
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
94
public/change-password.html
Normal file
94
public/change-password.html
Normal 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
101
public/enroll-2fa.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
240
src/server.js
240
src/server.js
|
|
@ -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 });
|
||||
|
|
|
|||
110
src/store.js
110
src/store.js
|
|
@ -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
76
src/totp.js
Normal 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
165
test/password-totp.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue