Turn lab IAM on, move portal to access-web, harden sessions and receipts.
Some checks are pending
offline / test (push) Waiting to run

Fleet and department doors now check STAFF_IAM_URL. Walkthrough: cs can
credit (agent is the IAM user) and cannot export; operator can fleet
POST; admin /me is owner. Portal is the public web door at :3021/portal/.
IAM sessions persist; login is rate-limited per user; receipt PDF is
branded. lan-134 stays disabled.
This commit is contained in:
George Lambert 2026-09-11 18:59:18 -04:00
parent d299d245e8
commit c32b65038a
20 changed files with 277 additions and 73 deletions

View file

@ -3,16 +3,17 @@ import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, expandRoles } from './roles.js';
import { allows, publicUser, ROLES, PERMISSIONS } from './roles.js';
import { verifyPassword } from './passwords.js';
import { loadIam, saveIam } from './store.js';
import {
issueSession,
getSession,
revokeSession,
cookieHeader,
clearCookieHeader,
tokenFromReq,
loginLocked,
loginFail,
loginOk,
clientKey,
} from './sessions.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -41,7 +42,7 @@ function readBody(req) {
function actorOf(req) {
const tok = tokenFromReq(req);
const s = getSession(tok);
const s = iam.getSession(tok);
if (!s) return null;
const u = iam.findById(s.userId);
if (!u || !u.active) return null;
@ -87,15 +88,22 @@ const server = http.createServer(async (req, res) => {
const username = String(body.username || '').trim();
const password = String(body.password || '');
const next = body.next || '/';
if (loginLocked(req, username)) {
iam.log(username || 'unknown', 'login.lock', clientKey(req));
saveIam(iam);
return json(429, { error: 'too many login attempts; try again in 10 minutes' });
}
const user = iam.findByUsername(username);
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
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' });
res.writeHead(302, { location: '/login?error=1' });
return res.end();
}
const token = issueSession(user.id);
loginOk(req, username);
const token = iam.issueSession(user.id);
iam.log(username, 'login.ok', username);
saveIam(iam);
const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
@ -110,7 +118,8 @@ const server = http.createServer(async (req, res) => {
return res.end();
}
if (req.method === 'POST' && url.pathname === '/logout') {
revokeSession(tokenFromReq(req));
iam.revokeSession(tokenFromReq(req));
saveIam(iam);
if ((req.headers['content-type'] || '').includes('json')) {
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() });
return res.end(JSON.stringify({ ok: true }));

View file

@ -1,31 +1,6 @@
import { randomBytes } from 'node:crypto';
const TTL_MS = 12 * 60 * 60 * 1000;
const sessions = new Map();
export function issueSession(userId) {
const token = randomBytes(24).toString('hex');
sessions.set(token, { userId, exp: Date.now() + TTL_MS });
return token;
}
export function getSession(token) {
if (!token) return null;
const s = sessions.get(token);
if (!s) return null;
if (s.exp < Date.now()) {
sessions.delete(token);
return null;
}
return s;
}
export function revokeSession(token) {
if (token) sessions.delete(token);
}
export function cookieHeader(token) {
let s = `staff_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(TTL_MS / 1000)}`;
const ttl = 12 * 60 * 60;
let s = `staff_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`;
const domain = process.env.STAFF_COOKIE_DOMAIN;
if (domain) s += `; Domain=${domain}`;
if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure';
@ -46,3 +21,39 @@ export function tokenFromReq(req) {
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
return m ? m[1] : '';
}
const fails = new Map();
export function clientKey(req) {
return String(req.headers?.['x-forwarded-for'] || req.socket?.remoteAddress || 'local').split(',')[0].trim();
}
function failKey(req, username) {
return `${clientKey(req)}:${String(username || '').toLowerCase()}`;
}
/** True if this IP+username is currently locked out. */
export function loginLocked(req, username) {
const k = failKey(req, username);
const s = fails.get(k);
if (!s) return false;
if (Date.now() > s.until) {
fails.delete(k);
return false;
}
return s.n >= 8;
}
export function loginFail(req, username) {
const k = failKey(req, username);
const now = Date.now();
let s = fails.get(k);
if (!s || now > s.until) s = { n: 0, until: now + 10 * 60 * 1000 };
s.n += 1;
fails.set(k, s);
return s.n >= 8;
}
export function loginOk(req, username) {
fails.delete(failKey(req, username));
}

View file

@ -18,6 +18,8 @@ export class StaffIam {
this.users = [];
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
this.audit = [];
/** @type {Record<string,{userId:string,exp:number}>} */
this.sessions = {};
}
seed() {
@ -131,14 +133,47 @@ export class StaffIam {
return user;
}
issueSession(userId, ttlMs = 12 * 60 * 60 * 1000) {
const token = randomBytes(24).toString('hex');
this.sessions[token] = { userId, exp: Date.now() + ttlMs };
this.pruneSessions();
return token;
}
getSession(token) {
if (!token) return null;
this.pruneSessions();
const s = this.sessions[token];
if (!s) return null;
if (s.exp < Date.now()) {
delete this.sessions[token];
return null;
}
return s;
}
revokeSession(token) {
if (token) delete this.sessions[token];
}
pruneSessions() {
const now = Date.now();
for (const [k, s] of Object.entries(this.sessions)) {
if (!s || s.exp < now) delete this.sessions[k];
}
}
dump() {
return { users: this.users, audit: this.audit };
this.pruneSessions();
return { users: this.users, audit: this.audit, sessions: this.sessions };
}
load(raw) {
if (!raw || typeof raw !== 'object') return this;
this.users = Array.isArray(raw.users) ? raw.users : [];
this.audit = Array.isArray(raw.audit) ? raw.audit : [];
this.sessions = raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {};
this.pruneSessions();
return this;
}
}