#!/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`); });