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:
parent
64da073787
commit
10307334c5
4 changed files with 270 additions and 33 deletions
|
|
@ -202,6 +202,5 @@ export function publicUser(u, session = null) {
|
||||||
mtlsEnrolled: !!u.mtlsEnrolled,
|
mtlsEnrolled: !!u.mtlsEnrolled,
|
||||||
createdMs: u.createdMs,
|
createdMs: u.createdMs,
|
||||||
mustChangePassword: !!u.mustChangePassword,
|
mustChangePassword: !!u.mustChangePassword,
|
||||||
totpEnabled: !!u.totpEnabled,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
236
src/server.js
236
src/server.js
|
|
@ -7,7 +7,7 @@ import { spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
|
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
|
||||||
import { verifyPassword } from './passwords.js';
|
import { verifyPassword } from './passwords.js';
|
||||||
import { loadIam, saveIam } from './store.js';
|
import { loadIam, saveIam, OWNER_SEED_USERS } from './store.js';
|
||||||
import {
|
import {
|
||||||
cookieHeader,
|
cookieHeader,
|
||||||
clearCookieHeader,
|
clearCookieHeader,
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
} from './sessions.js';
|
} from './sessions.js';
|
||||||
import { generateTotpSecret, totpUri, verifyTotp } from './totp.js';
|
import { generateTotpSecret, totpUri, verifyTotp } from './totp.js';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy-reported client cert (nginx/caddy headers only — no local crypto).
|
* Proxy-reported client cert (nginx/caddy headers only — no local crypto).
|
||||||
* See ENROLL.md for the header contract.
|
* 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/schema' && method === 'GET') return true;
|
||||||
if (pathname === '/enroll/csr' && method === 'POST') return true;
|
if (pathname === '/enroll/csr' && method === 'POST') return true;
|
||||||
if (pathname === '/logout') 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';
|
if (pathname === '/roles' || pathname === '/roles/catalog') return method === 'GET';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -384,15 +397,62 @@ function actorOf(req) {
|
||||||
if (!sess) return null;
|
if (!sess) return null;
|
||||||
const u = iam.findById(sess.userId);
|
const u = iam.findById(sess.userId);
|
||||||
if (!u || !u.active) return null;
|
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) {
|
function changePasswordUrl(next) {
|
||||||
const me = actorOf(req);
|
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) {
|
if (!me) {
|
||||||
json(401, { ok: false, reason: 'not signed in' });
|
json(401, { ok: false, reason: 'not signed in' });
|
||||||
return null;
|
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)) {
|
if (needsTotpEnroll(me)) {
|
||||||
json(403, {
|
json(403, {
|
||||||
ok: false,
|
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 server = http.createServer(async (req, res) => {
|
||||||
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
|
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
|
||||||
const extra = corsHeaders(req);
|
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 === '/')) {
|
if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) {
|
||||||
const me = actorOf(req);
|
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';
|
const file = me ? 'app.html' : 'login.html';
|
||||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...extra });
|
return serveHtml(res, file, extra);
|
||||||
return res.end(fs.readFileSync(path.join(PUBLIC, file)));
|
}
|
||||||
|
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 */
|
/* 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')) {
|
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] });
|
return json(200, { roles: [...CATALOG_ROLES] });
|
||||||
}
|
}
|
||||||
if (req.method === 'GET' && url.pathname === '/interfaces') {
|
if (req.method === 'GET' && url.pathname === '/interfaces') {
|
||||||
const me = actorOf(req);
|
const me = gateMustChange(req, res, json, actorOf(req));
|
||||||
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
if (!me) return;
|
||||||
return json(200, {
|
return json(200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
currentRole: me.currentRole,
|
currentRole: me.currentRole,
|
||||||
|
|
@ -490,7 +591,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
const password = String(body.password || '');
|
const password = String(body.password || '');
|
||||||
const totpCode = body.totpCode != null ? String(body.totpCode).trim() : '';
|
const totpCode = body.totpCode != null ? String(body.totpCode).trim() : '';
|
||||||
const next = body.next || '/';
|
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)) {
|
if (loginLocked(req, username)) {
|
||||||
iam.log(username || 'unknown', 'login.lock', clientKey(req));
|
iam.log(username || 'unknown', 'login.lock', clientKey(req));
|
||||||
saveIam(iam);
|
saveIam(iam);
|
||||||
|
|
@ -501,7 +602,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
loginFail(req, username);
|
loginFail(req, username);
|
||||||
iam.log(username || 'unknown', 'login.fail', username);
|
iam.log(username || 'unknown', 'login.fail', username);
|
||||||
saveIam(iam);
|
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 });
|
res.writeHead(302, { location: '/login?error=1', ...extra });
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
|
|
@ -509,7 +610,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (!user.totpSecret || !verifyTotp(user.totpSecret, totpCode, Date.now())) {
|
if (!user.totpSecret || !verifyTotp(user.totpSecret, totpCode, Date.now())) {
|
||||||
iam.log(username, 'login.totp_required', username);
|
iam.log(username, 'login.totp_required', username);
|
||||||
saveIam(iam);
|
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 });
|
res.writeHead(302, { location: '/login?error=totp', ...extra });
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
|
|
@ -517,7 +618,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (user.mtlsEnrolled && mtlsEnforceEnabled() && !clientCertPresent(req)) {
|
if (user.mtlsEnrolled && mtlsEnforceEnabled() && !clientCertPresent(req)) {
|
||||||
iam.log(username, 'login.cert', username);
|
iam.log(username, 'login.cert', username);
|
||||||
saveIam(iam);
|
saveIam(iam);
|
||||||
if (wantsJson) return json(401, { error: 'cert' });
|
if (asJson) return json(401, { error: 'cert' });
|
||||||
res.writeHead(302, { location: '/login?error=cert', ...extra });
|
res.writeHead(302, { location: '/login?error=cert', ...extra });
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
|
|
@ -527,9 +628,12 @@ const server = http.createServer(async (req, res) => {
|
||||||
const enroll = needsTotpEnroll(user);
|
const enroll = needsTotpEnroll(user);
|
||||||
iam.log(username, enroll ? 'login.ok.enroll_totp' : 'login.ok', username, currentRole || undefined);
|
iam.log(username, enroll ? 'login.ok.enroll_totp' : 'login.ok', username, currentRole || undefined);
|
||||||
saveIam(iam);
|
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 }));
|
const pub = withTotpFlags(publicUser(user, { currentRole }));
|
||||||
if (wantsJson) {
|
if (asJson) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'set-cookie': cookieHeader(token),
|
'set-cookie': cookieHeader(token),
|
||||||
|
|
@ -539,6 +643,8 @@ const server = http.createServer(async (req, res) => {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
token,
|
token,
|
||||||
user: pub,
|
user: pub,
|
||||||
|
mustChangePassword: !!user.mustChangePassword,
|
||||||
|
redirect: user.mustChangePassword ? loc : undefined,
|
||||||
totpRequired: totpRequiredPolicy(),
|
totpRequired: totpRequiredPolicy(),
|
||||||
totpEnrollmentRequired: enroll,
|
totpEnrollmentRequired: enroll,
|
||||||
}),
|
}),
|
||||||
|
|
@ -547,6 +653,40 @@ const server = http.createServer(async (req, res) => {
|
||||||
res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc, ...extra });
|
res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc, ...extra });
|
||||||
return res.end();
|
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') {
|
if (req.method === 'GET' && url.pathname === '/enroll/csr/schema') {
|
||||||
/* Fields-only contract for a future mint; never returns cert material. */
|
/* Fields-only contract for a future mint; never returns cert material. */
|
||||||
return json(200, ENROLL_CSR_SCHEMA);
|
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') {
|
if (req.method === 'POST' && url.pathname === '/2fa/setup') {
|
||||||
const me = actorOf(req);
|
const me = gateMustChange(req, res, json, actorOf(req));
|
||||||
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
if (!me) return;
|
||||||
const user = iam.findById(me.id);
|
const user = iam.findById(me.id);
|
||||||
if (!user) return json(401, { ok: false });
|
if (!user) return json(401, { ok: false });
|
||||||
if (user.totpEnabled) {
|
if (user.totpEnabled) {
|
||||||
|
|
@ -743,8 +883,8 @@ const server = http.createServer(async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/2fa/enable') {
|
if (req.method === 'POST' && url.pathname === '/2fa/enable') {
|
||||||
const me = actorOf(req);
|
const me = gateMustChange(req, res, json, actorOf(req));
|
||||||
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
if (!me) return;
|
||||||
const user = iam.findById(me.id);
|
const user = iam.findById(me.id);
|
||||||
if (!user) return json(401, { ok: false });
|
if (!user) return json(401, { ok: false });
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
|
|
@ -763,8 +903,8 @@ const server = http.createServer(async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/2fa/disable') {
|
if (req.method === 'POST' && url.pathname === '/2fa/disable') {
|
||||||
const me = actorOf(req);
|
const me = gateMustChange(req, res, json, actorOf(req));
|
||||||
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
if (!me) return;
|
||||||
if (totpRequiredPolicy()) {
|
if (totpRequiredPolicy()) {
|
||||||
return json(403, {
|
return json(403, {
|
||||||
error: 'totp_required_by_policy',
|
error: 'totp_required_by_policy',
|
||||||
|
|
@ -785,6 +925,22 @@ const server = http.createServer(async (req, res) => {
|
||||||
saveIam(iam);
|
saveIam(iam);
|
||||||
return json(200, { ok: true, totpEnabled: false, totpRequired: false });
|
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') {
|
if (req.method === 'POST' && url.pathname === '/logout') {
|
||||||
iam.revokeSession(tokenFromReq(req));
|
iam.revokeSession(tokenFromReq(req));
|
||||||
saveIam(iam);
|
saveIam(iam);
|
||||||
|
|
@ -796,8 +952,8 @@ const server = http.createServer(async (req, res) => {
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && (url.pathname === '/session/role' || url.pathname === '/me/role')) {
|
if (req.method === 'POST' && (url.pathname === '/session/role' || url.pathname === '/me/role')) {
|
||||||
const me = actorOf(req);
|
const me = gateMustChange(req, res, json, actorOf(req));
|
||||||
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
if (!me) return;
|
||||||
if (needsTotpEnroll(me)) {
|
if (needsTotpEnroll(me)) {
|
||||||
return json(403, {
|
return json(403, {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -829,10 +985,10 @@ const server = http.createServer(async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (req.method === 'GET' && url.pathname === '/check') {
|
if (req.method === 'GET' && url.pathname === '/check') {
|
||||||
const me = actorOf(req);
|
const me = gateMustChange(req, res, json, actorOf(req));
|
||||||
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
if (!me) return;
|
||||||
if (needsTotpEnroll(me)) {
|
if (needsTotpEnroll(me)) {
|
||||||
const { _token, _sess, ...partial } = me;
|
const { _token, _sess, _raw, ...partial } = me;
|
||||||
return json(403, {
|
return json(403, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'totp_enrollment_required',
|
error: 'totp_enrollment_required',
|
||||||
|
|
@ -848,15 +1004,28 @@ const server = http.createServer(async (req, res) => {
|
||||||
saveIam(iam);
|
saveIam(iam);
|
||||||
return json(403, { ok: false, reason: 'missing permission', permission: need, username: me.username });
|
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 });
|
return json(200, { ok: true, user: withTotpFlags(user), permission: need || null });
|
||||||
}
|
}
|
||||||
if (req.method === 'GET' && url.pathname === '/me') {
|
if (req.method === 'GET' && url.pathname === '/me') {
|
||||||
const me = actorOf(req);
|
const me = actorOf(req);
|
||||||
if (!me) return json(401, { ok: false });
|
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) });
|
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. */
|
/* Soft gate: any other authenticated JSON route while enrollment pending. */
|
||||||
{
|
{
|
||||||
const meEarly = actorOf(req);
|
const meEarly = actorOf(req);
|
||||||
|
|
@ -924,6 +1093,17 @@ const server = http.createServer(async (req, res) => {
|
||||||
return json(err.status || 400, { error: err.message });
|
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' });
|
json(404, { error: 'not found' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(500, { error: err.message });
|
json(500, { error: err.message });
|
||||||
|
|
|
||||||
64
src/store.js
64
src/store.js
|
|
@ -12,9 +12,20 @@ export function newId() {
|
||||||
return `stu_${randomBytes(6).toString('hex')}`;
|
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 {
|
export class StaffIam {
|
||||||
constructor() {
|
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 = [];
|
this.users = [];
|
||||||
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
|
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
|
||||||
this.audit = [];
|
this.audit = [];
|
||||||
|
|
@ -64,6 +75,8 @@ export class StaffIam {
|
||||||
passwordHash: hashPassword(s.password),
|
passwordHash: hashPassword(s.password),
|
||||||
roles: s.roles,
|
roles: s.roles,
|
||||||
active: true,
|
active: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
totpEnabled: false,
|
||||||
createdMs: Date.now(),
|
createdMs: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -90,11 +103,13 @@ export class StaffIam {
|
||||||
return this.users.find((u) => u.id === id);
|
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 (!/^[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 (!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 });
|
if (this.findByUsername(username)) throw Object.assign(new Error('username already exists'), { status: 409 });
|
||||||
const cleanRoles = (roles || []).filter((r) => ROLES[r]);
|
const cleanRoles = (roles || []).filter((r) => ROLES[r]);
|
||||||
|
/* New staff default mustChangePassword=true (invite OTP is one-time). */
|
||||||
|
const forceChange = mustChangePassword !== false;
|
||||||
const user = {
|
const user = {
|
||||||
id: newId(),
|
id: newId(),
|
||||||
username,
|
username,
|
||||||
|
|
@ -102,10 +117,12 @@ export class StaffIam {
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
roles: cleanRoles.length ? cleanRoles : ['viewer'],
|
roles: cleanRoles.length ? cleanRoles : ['viewer'],
|
||||||
active: true,
|
active: true,
|
||||||
|
mustChangePassword: forceChange,
|
||||||
|
totpEnabled: false,
|
||||||
createdMs: Date.now(),
|
createdMs: Date.now(),
|
||||||
};
|
};
|
||||||
this.users.push(user);
|
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;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,9 +142,14 @@ export class StaffIam {
|
||||||
}
|
}
|
||||||
user.active = patch.active;
|
user.active = patch.active;
|
||||||
}
|
}
|
||||||
|
if (typeof patch.mustChangePassword === 'boolean') {
|
||||||
|
user.mustChangePassword = patch.mustChangePassword;
|
||||||
|
}
|
||||||
if (patch.password) {
|
if (patch.password) {
|
||||||
if (String(patch.password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 });
|
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);
|
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') {
|
if (typeof patch.mtlsEnrolled === 'boolean') {
|
||||||
user.mtlsEnrolled = patch.mtlsEnrolled;
|
user.mtlsEnrolled = patch.mtlsEnrolled;
|
||||||
|
|
@ -151,6 +173,7 @@ export class StaffIam {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
active: user.active,
|
active: user.active,
|
||||||
|
mustChangePassword: !!user.mustChangePassword,
|
||||||
totpEnabled: !!user.totpEnabled,
|
totpEnabled: !!user.totpEnabled,
|
||||||
mtlsEnrolled: !!user.mtlsEnrolled,
|
mtlsEnrolled: !!user.mtlsEnrolled,
|
||||||
}),
|
}),
|
||||||
|
|
@ -158,6 +181,41 @@ export class StaffIam {
|
||||||
return user;
|
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 = {}) {
|
issueSession(userId, opts = {}) {
|
||||||
const ttlMs = typeof opts === 'number' ? opts : (opts.ttlMs ?? 12 * 60 * 60 * 1000);
|
const ttlMs = typeof opts === 'number' ? opts : (opts.ttlMs ?? 12 * 60 * 60 * 1000);
|
||||||
const currentRole = typeof opts === 'object' && opts ? opts.currentRole || null : null;
|
const currentRole = typeof opts === 'object' && opts ? opts.currentRole || null : null;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ test('mustChangePassword on create and publicUser', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('force change + optional totp enrollment via HTTP', async () => {
|
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 dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-'));
|
||||||
const store = path.join(dir, 'iam.json');
|
const store = path.join(dir, 'iam.json');
|
||||||
const child = spawn(process.execPath, ['src/server.js'], {
|
const child = spawn(process.execPath, ['src/server.js'], {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue