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.
189 lines
6.9 KiB
JavaScript
189 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
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 } from './roles.js';
|
|
import { verifyPassword } from './passwords.js';
|
|
import { loadIam, saveIam } from './store.js';
|
|
import {
|
|
cookieHeader,
|
|
clearCookieHeader,
|
|
tokenFromReq,
|
|
loginLocked,
|
|
loginFail,
|
|
loginOk,
|
|
clientKey,
|
|
} from './sessions.js';
|
|
|
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
|
const PORT = Number(process.env.PORT || 3028);
|
|
const iam = loadIam();
|
|
saveIam(iam);
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
if ((req.headers['content-type'] || '').includes('json')) {
|
|
try {
|
|
resolve(JSON.parse(text || '{}'));
|
|
} catch {
|
|
resolve({});
|
|
}
|
|
return;
|
|
}
|
|
resolve(Object.fromEntries(new URLSearchParams(text)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function actorOf(req) {
|
|
const tok = tokenFromReq(req);
|
|
const s = iam.getSession(tok);
|
|
if (!s) return null;
|
|
const u = iam.findById(s.userId);
|
|
if (!u || !u.active) return null;
|
|
return publicUser(u);
|
|
}
|
|
|
|
function requirePerm(req, res, json, perm) {
|
|
const me = actorOf(req);
|
|
if (!me) {
|
|
json(401, { ok: false, reason: 'not signed in' });
|
|
return null;
|
|
}
|
|
if (!allows(me.permissions, perm)) {
|
|
iam.log(me.username, 'deny', perm, req.url);
|
|
saveIam(iam);
|
|
json(403, { ok: false, reason: 'missing permission', permission: perm, username: me.username });
|
|
return null;
|
|
}
|
|
return me;
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
|
|
const json = (code, obj) => {
|
|
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
try {
|
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
return json(200, { ok: true, role: 'verae-staff-iam', users: iam.users.length });
|
|
}
|
|
if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) {
|
|
const me = actorOf(req);
|
|
const file = me ? 'app.html' : 'login.html';
|
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
return res.end(fs.readFileSync(path.join(PUBLIC, file)));
|
|
}
|
|
if (req.method === 'GET' && url.pathname === '/roles') {
|
|
return json(200, { roles: ROLES, permissions: PERMISSIONS });
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/login') {
|
|
const body = await readBody(req);
|
|
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();
|
|
}
|
|
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 : '/';
|
|
if ((req.headers['content-type'] || '').includes('json')) {
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json',
|
|
'set-cookie': cookieHeader(token),
|
|
});
|
|
return res.end(JSON.stringify({ token, user: publicUser(user) }));
|
|
}
|
|
res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc });
|
|
return res.end();
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/logout') {
|
|
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 }));
|
|
}
|
|
res.writeHead(302, { 'set-cookie': clearCookieHeader(), location: '/login' });
|
|
return res.end();
|
|
}
|
|
if (req.method === 'GET' && url.pathname === '/check') {
|
|
const me = actorOf(req);
|
|
if (!me) return json(401, { ok: false, reason: 'not signed in' });
|
|
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 });
|
|
}
|
|
return json(200, { ok: true, user: me, permission: need || null });
|
|
}
|
|
if (req.method === 'GET' && url.pathname === '/me') {
|
|
const me = actorOf(req);
|
|
if (!me) return json(401, { ok: false });
|
|
return json(200, { user: me });
|
|
}
|
|
if (req.method === 'GET' && url.pathname === '/users') {
|
|
const me = requirePerm(req, res, json, 'iam.users.read');
|
|
if (!me) return;
|
|
return json(200, { users: iam.users.map(publicUser) });
|
|
}
|
|
if (req.method === 'GET' && url.pathname === '/audit') {
|
|
const me = requirePerm(req, res, json, 'iam.users.read');
|
|
if (!me) return;
|
|
return json(200, { audit: iam.audit.slice(0, 200) });
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/users') {
|
|
const me = requirePerm(req, res, json, 'iam.users.write');
|
|
if (!me) return;
|
|
const body = await readBody(req);
|
|
try {
|
|
const user = iam.create({ ...body, actor: me.username });
|
|
saveIam(iam);
|
|
return json(201, publicUser(user));
|
|
} catch (err) {
|
|
return json(err.status || 400, { error: err.message });
|
|
}
|
|
}
|
|
const upd = url.pathname.match(/^\/users\/([^/]+)$/);
|
|
if (req.method === 'PUT' && upd) {
|
|
const me = requirePerm(req, res, json, 'iam.users.write');
|
|
if (!me) return;
|
|
const body = await readBody(req);
|
|
try {
|
|
const user = iam.update(decodeURIComponent(upd[1]), body, me.username);
|
|
saveIam(iam);
|
|
return json(200, publicUser(user));
|
|
} catch (err) {
|
|
return json(err.status || 400, { error: err.message });
|
|
}
|
|
}
|
|
json(404, { error: 'not found' });
|
|
} catch (err) {
|
|
json(500, { error: err.message });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
process.stdout.write(`verae-staff-iam http://0.0.0.0:${PORT}/ users=${iam.users.length}\n`);
|
|
});
|