feat: restore mustChangePassword first-login gate (pki software-complete)

Invite accounts default mustChangePassword=true; POST /password/change
clears the flag and offers optional TOTP; GET /check returns 403
must_change_password until change. Admin POST /admin/mark-must-change
re-marks owner invites. Also POST /2fa/skip. Fixes password-totp tests
(port 18039). All 19 npm tests green including health PKI/lab matrix.
This commit is contained in:
George Lambert 2026-10-14 01:59:03 -04:00
commit 10307334c5
4 changed files with 270 additions and 33 deletions

View file

@ -202,6 +202,5 @@ export function publicUser(u, session = null) {
mtlsEnrolled: !!u.mtlsEnrolled,
createdMs: u.createdMs,
mustChangePassword: !!u.mustChangePassword,
totpEnabled: !!u.totpEnabled,
};
}

View file

@ -7,7 +7,7 @@ import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
import { verifyPassword } from './passwords.js';
import { loadIam, saveIam } from './store.js';
import { loadIam, saveIam, OWNER_SEED_USERS } from './store.js';
import {
cookieHeader,
clearCookieHeader,
@ -19,6 +19,8 @@ import {
} from './sessions.js';
import { generateTotpSecret, totpUri, verifyTotp } from './totp.js';
const MIN_PASSWORD_LENGTH = 8;
/**
* Proxy-reported client cert (nginx/caddy headers only — no local crypto).
* See ENROLL.md for the header contract.
@ -143,7 +145,18 @@ function totpEnrollAllowed(method, pathname) {
if (pathname === '/enroll/csr/schema' && method === 'GET') return true;
if (pathname === '/enroll/csr' && method === 'POST') return true;
if (pathname === '/logout') return true;
if (pathname === '/2fa/setup' || pathname === '/2fa/enable') return method === 'POST';
if (pathname === '/password/change' && method === 'POST') return true;
if (pathname === '/2fa/setup' || pathname === '/2fa/enable' || pathname === '/2fa/skip') return method === 'POST';
if (pathname === '/roles' || pathname === '/roles/catalog') return method === 'GET';
return false;
}
/** Paths allowed while mustChangePassword is set (before first permanent password). */
function mustChangeAllowed(method, pathname) {
if (pathname === '/me' || pathname === '/enroll/status' || pathname === '/health' || pathname === '/logout') return true;
if (pathname === '/password/change' && method === 'POST') return true;
if (pathname === '/change-password' || pathname === '/enroll-2fa') return method === 'GET';
if (pathname === '/enroll/csr/schema' && method === 'GET') return true;
if (pathname === '/roles' || pathname === '/roles/catalog') return method === 'GET';
return false;
}
@ -384,15 +397,62 @@ 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 pwError(p) {
return typeof p !== 'string' || p.length < MIN_PASSWORD_LENGTH
? `password must be at least ${MIN_PASSWORD_LENGTH} characters`
: undefined;
}
/**
* Block staff doors while invite OTP is still in force.
* allowWhenMustChange: password/change and related first-login paths.
*/
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 (needsTotpEnroll(me)) {
json(403, {
ok: false,
@ -421,6 +481,15 @@ function withTotpFlags(user) {
};
}
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)));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
const extra = corsHeaders(req);
@ -451,9 +520,41 @@ const server = http.createServer(async (req, res) => {
}
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);
}
/* Brand kit static assets (tokens, verae-ui-kit) — no path escape */
if (req.method === 'GET' && (url.pathname === '/verae-ui-kit.css' || url.pathname === '/brand/tokens.css')) {
@ -475,8 +576,8 @@ const server = http.createServer(async (req, res) => {
return json(200, { roles: [...CATALOG_ROLES] });
}
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,
@ -490,7 +591,7 @@ const server = http.createServer(async (req, res) => {
const password = String(body.password || '');
const totpCode = body.totpCode != null ? String(body.totpCode).trim() : '';
const next = body.next || '/';
const wantsJson = (req.headers['content-type'] || '').includes('json');
const asJson = wantsJson(req) || (req.headers['content-type'] || '').includes('json');
if (loginLocked(req, username)) {
iam.log(username || 'unknown', 'login.lock', clientKey(req));
saveIam(iam);
@ -501,7 +602,7 @@ const server = http.createServer(async (req, res) => {
loginFail(req, username);
iam.log(username || 'unknown', 'login.fail', username);
saveIam(iam);
if (wantsJson) return json(401, { error: 'invalid username or password' });
if (asJson) return json(401, { error: 'invalid username or password' });
res.writeHead(302, { location: '/login?error=1', ...extra });
return res.end();
}
@ -509,7 +610,7 @@ const server = http.createServer(async (req, res) => {
if (!user.totpSecret || !verifyTotp(user.totpSecret, totpCode, Date.now())) {
iam.log(username, 'login.totp_required', username);
saveIam(iam);
if (wantsJson) return json(401, { error: 'totp_required' });
if (asJson) return json(401, { error: 'totp_required' });
res.writeHead(302, { location: '/login?error=totp', ...extra });
return res.end();
}
@ -517,7 +618,7 @@ const server = http.createServer(async (req, res) => {
if (user.mtlsEnrolled && mtlsEnforceEnabled() && !clientCertPresent(req)) {
iam.log(username, 'login.cert', username);
saveIam(iam);
if (wantsJson) return json(401, { error: 'cert' });
if (asJson) return json(401, { error: 'cert' });
res.writeHead(302, { location: '/login?error=cert', ...extra });
return res.end();
}
@ -527,9 +628,12 @@ const server = http.createServer(async (req, res) => {
const enroll = needsTotpEnroll(user);
iam.log(username, enroll ? 'login.ok.enroll_totp' : 'login.ok', username, currentRole || undefined);
saveIam(iam);
const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
let loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
if (user.mustChangePassword) {
loc = changePasswordUrl(loc);
}
const pub = withTotpFlags(publicUser(user, { currentRole }));
if (wantsJson) {
if (asJson) {
res.writeHead(200, {
'content-type': 'application/json',
'set-cookie': cookieHeader(token),
@ -539,6 +643,8 @@ const server = http.createServer(async (req, res) => {
JSON.stringify({
token,
user: pub,
mustChangePassword: !!user.mustChangePassword,
redirect: user.mustChangePassword ? loc : undefined,
totpRequired: totpRequiredPolicy(),
totpEnrollmentRequired: enroll,
}),
@ -547,6 +653,40 @@ const server = http.createServer(async (req, res) => {
res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc, ...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) {
const code = body.totpCode != null ? String(body.totpCode).trim() : '';
if (!me._raw.totpSecret || !verifyTotp(me._raw.totpSecret, code, Date.now())) {
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),
});
}
if (req.method === 'GET' && url.pathname === '/enroll/csr/schema') {
/* Fields-only contract for a future mint; never returns cert material. */
return json(200, ENROLL_CSR_SCHEMA);
@ -712,8 +852,8 @@ const server = http.createServer(async (req, res) => {
});
}
if (req.method === 'POST' && url.pathname === '/2fa/setup') {
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 user = iam.findById(me.id);
if (!user) return json(401, { ok: false });
if (user.totpEnabled) {
@ -743,8 +883,8 @@ const server = http.createServer(async (req, res) => {
});
}
if (req.method === 'POST' && url.pathname === '/2fa/enable') {
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 user = iam.findById(me.id);
if (!user) return json(401, { ok: false });
const body = await readBody(req);
@ -763,8 +903,8 @@ const server = http.createServer(async (req, res) => {
});
}
if (req.method === 'POST' && url.pathname === '/2fa/disable') {
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;
if (totpRequiredPolicy()) {
return json(403, {
error: 'totp_required_by_policy',
@ -785,6 +925,22 @@ const server = http.createServer(async (req, res) => {
saveIam(iam);
return json(200, { ok: true, totpEnabled: false, totpRequired: false });
}
if (req.method === 'POST' && url.pathname === '/2fa/skip') {
const me = gateMustChange(req, res, json, actorOf(req));
if (!me) return;
await readBody(req);
if (totpRequiredPolicy() && !me._raw.totpEnabled) {
return json(403, {
error: 'totp_enrollment_required',
totpRequired: true,
totpEnrollmentRequired: true,
reason: 'STAFF_TOTP_REQUIRED prevents skipping authenticator enrollment',
});
}
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 === '/logout') {
iam.revokeSession(tokenFromReq(req));
saveIam(iam);
@ -796,8 +952,8 @@ const server = http.createServer(async (req, res) => {
return res.end();
}
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;
if (needsTotpEnroll(me)) {
return json(403, {
ok: false,
@ -829,10 +985,10 @@ const server = http.createServer(async (req, res) => {
});
}
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;
if (needsTotpEnroll(me)) {
const { _token, _sess, ...partial } = me;
const { _token, _sess, _raw, ...partial } = me;
return json(403, {
ok: false,
error: 'totp_enrollment_required',
@ -848,15 +1004,28 @@ const server = http.createServer(async (req, res) => {
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: withTotpFlags(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 readable during must-change so change-password UI can show who is signed in. */
const { _token, _sess, _raw, ...user } = me;
return json(200, { user: withTotpFlags(user) });
}
/* Soft gate: must-change blocks staff doors until POST /password/change. */
{
const meEarly = actorOf(req);
if (meEarly && meEarly.mustChangePassword && !mustChangeAllowed(req.method, url.pathname)) {
return json(403, {
ok: false,
reason: 'must_change_password',
redirect: changePasswordUrl('/'),
username: meEarly.username,
});
}
}
/* Soft gate: any other authenticated JSON route while enrollment pending. */
{
const meEarly = actorOf(req);
@ -924,6 +1093,17 @@ 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,20 @@ export function newId() {
return `stu_${randomBytes(6).toString('hex')}`;
}
/** Default invite owner usernames for POST /admin/mark-must-change. */
export const OWNER_SEED_USERS = Object.freeze([
'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,totpSecret?:string,totpEnabled?:boolean,mtlsEnrolled?:boolean}>} */
/** @type {Array<{id:string,username:string,name:string,passwordHash:string,roles:string[],active:boolean,createdMs:number,mustChangePassword?:boolean,totpSecret?:string,totpEnabled?:boolean,mtlsEnrolled?:boolean}>} */
this.users = [];
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
this.audit = [];
@ -64,6 +75,8 @@ export class StaffIam {
passwordHash: hashPassword(s.password),
roles: s.roles,
active: true,
mustChangePassword: false,
totpEnabled: false,
createdMs: Date.now(),
});
}
@ -90,11 +103,13 @@ 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]);
/* New staff default mustChangePassword=true (invite OTP is one-time). */
const forceChange = mustChangePassword !== false;
const user = {
id: newId(),
username,
@ -102,10 +117,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,9 +142,14 @@ 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;
}
if (typeof patch.mtlsEnrolled === 'boolean') {
user.mtlsEnrolled = patch.mtlsEnrolled;
@ -151,6 +173,7 @@ export class StaffIam {
JSON.stringify({
roles: user.roles,
active: user.active,
mustChangePassword: !!user.mustChangePassword,
totpEnabled: !!user.totpEnabled,
mtlsEnrolled: !!user.mtlsEnrolled,
}),
@ -158,6 +181,41 @@ export class StaffIam {
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;
}
/** Mark invite OTPs unused / force change for named accounts. */
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;
delete u.totpSecret;
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;

View file

@ -30,7 +30,7 @@ test('mustChangePassword on create and publicUser', () => {
});
test('force change + optional totp enrollment via HTTP', async () => {
const port = 18029;
const port = 18039;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-'));
const store = path.join(dir, 'iam.json');
const child = spawn(process.execPath, ['src/server.js'], {