Initial import of verae-staff-iam from zapier monorepo
This commit is contained in:
commit
84fd73fe95
14 changed files with 969 additions and 0 deletions
47
src/client.js
Normal file
47
src/client.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/** HTTP client used by department doors and the admin console. */
|
||||
|
||||
export function iamBase() {
|
||||
return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function iamCheck(req, permission) {
|
||||
const base = iamBase();
|
||||
if (!base) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
return { ok: Boolean(r && r.ok), skipped: false, legacy: true };
|
||||
}
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
|
||||
const r = await fetch(`${base}/check${q}`, {
|
||||
headers: {
|
||||
cookie: req.headers.cookie || '',
|
||||
authorization: req.headers.authorization || '',
|
||||
},
|
||||
}).catch(() => null);
|
||||
if (!r) return { ok: false, status: 502 };
|
||||
const body = await r.json().catch(() => ({}));
|
||||
return { ok: r.ok, status: r.status, ...body };
|
||||
}
|
||||
|
||||
export function iamLoginUrl(next) {
|
||||
const base = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
|
||||
return `${base}/login?next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
export async function denyOrRedirect(req, res, { permission, html, json }) {
|
||||
const out = await iamCheck(req, permission);
|
||||
if (out.ok) return out;
|
||||
if (html) {
|
||||
res.writeHead(302, { location: iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`) });
|
||||
res.end();
|
||||
return out;
|
||||
}
|
||||
json(out.status === 403 ? 403 : 401, {
|
||||
error: out.reason || 'unauthorized',
|
||||
permission: permission || undefined,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
26
src/passwords.js
Normal file
26
src/passwords.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const N = 16384;
|
||||
const R = 8;
|
||||
const P = 1;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
export function hashPassword(password) {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(String(password), salt, KEY_LEN, { N, r: R, p: P });
|
||||
return `scrypt:${N}:${R}:${P}:${salt.toString('base64')}:${hash.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password, stored) {
|
||||
const parts = String(stored || '').split(':');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||
const [, n, r, p, saltB64, hashB64] = parts;
|
||||
const expected = Buffer.from(hashB64, 'base64');
|
||||
if (!expected.length) return false;
|
||||
const actual = scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
|
||||
N: Number(n),
|
||||
r: Number(r),
|
||||
p: Number(p),
|
||||
});
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
98
src/roles.js
Normal file
98
src/roles.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/** Internal staff roles → permissions. Default deny on unknown. */
|
||||
|
||||
export const PERMISSIONS = Object.freeze([
|
||||
'iam.users.read',
|
||||
'iam.users.write',
|
||||
'admin.pricing',
|
||||
'admin.tiers',
|
||||
'admin.customers',
|
||||
'admin.statement',
|
||||
'admin.invoices',
|
||||
'admin.reports',
|
||||
'admin.system',
|
||||
'cs.review',
|
||||
'cs.credit',
|
||||
'sales.review',
|
||||
'sales.quote',
|
||||
'accounting.review',
|
||||
'accounting.export',
|
||||
'staff.plane',
|
||||
'fleet.operate',
|
||||
]);
|
||||
|
||||
export const ROLES = Object.freeze({
|
||||
owner: {
|
||||
title: 'Owner',
|
||||
permissions: ['*'],
|
||||
},
|
||||
'iam-admin': {
|
||||
title: 'IAM admin',
|
||||
permissions: ['iam.users.read', 'iam.users.write'],
|
||||
},
|
||||
'billing-admin': {
|
||||
title: 'Billing admin',
|
||||
permissions: [
|
||||
'admin.pricing',
|
||||
'admin.tiers',
|
||||
'admin.customers',
|
||||
'admin.statement',
|
||||
'admin.invoices',
|
||||
'admin.reports',
|
||||
'admin.system',
|
||||
],
|
||||
},
|
||||
cs: {
|
||||
title: 'Customer service',
|
||||
permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'],
|
||||
},
|
||||
sales: {
|
||||
title: 'Sales',
|
||||
permissions: ['sales.review', 'sales.quote', 'admin.statement', 'staff.plane'],
|
||||
},
|
||||
accounting: {
|
||||
title: 'Accounting',
|
||||
permissions: ['accounting.review', 'accounting.export', 'admin.statement', 'admin.invoices', 'staff.plane'],
|
||||
},
|
||||
operator: {
|
||||
title: 'Fleet operator',
|
||||
permissions: ['fleet.operate'],
|
||||
},
|
||||
viewer: {
|
||||
title: 'Read-only staff',
|
||||
permissions: ['cs.review', 'sales.review', 'accounting.review', 'admin.statement', 'admin.reports', 'admin.system'],
|
||||
},
|
||||
});
|
||||
|
||||
export function expandRoles(roles) {
|
||||
const set = new Set();
|
||||
for (const id of roles || []) {
|
||||
const def = ROLES[id];
|
||||
if (!def) continue;
|
||||
for (const p of def.permissions) {
|
||||
if (p === '*') return ['*'];
|
||||
set.add(p);
|
||||
}
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
export function allows(permissions, need) {
|
||||
if (!need) return true;
|
||||
const list = permissions || [];
|
||||
if (list.includes('*')) return true;
|
||||
return list.includes(need);
|
||||
}
|
||||
|
||||
export function publicUser(u) {
|
||||
if (!u) return null;
|
||||
const roles = [...(u.roles || [])];
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name || u.username,
|
||||
active: u.active !== false,
|
||||
roles,
|
||||
permissions: expandRoles(roles),
|
||||
createdMs: u.createdMs,
|
||||
};
|
||||
}
|
||||
189
src/server.js
Normal file
189
src/server.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#!/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`);
|
||||
});
|
||||
59
src/sessions.js
Normal file
59
src/sessions.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export function cookieHeader(token) {
|
||||
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';
|
||||
return s;
|
||||
}
|
||||
|
||||
export function clearCookieHeader() {
|
||||
let s = 'staff_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0';
|
||||
const domain = process.env.STAFF_COOKIE_DOMAIN;
|
||||
if (domain) s += `; Domain=${domain}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function tokenFromReq(req) {
|
||||
const bearer = req.headers?.authorization;
|
||||
if (bearer?.startsWith('Bearer ')) return bearer.slice(7);
|
||||
const raw = req.headers?.cookie || '';
|
||||
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));
|
||||
}
|
||||
201
src/store.js
Normal file
201
src/store.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { hashPassword } from './passwords.js';
|
||||
import { ROLES } from './roles.js';
|
||||
|
||||
export function storePath() {
|
||||
return process.env.STAFF_IAM_PATH || path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'staff-iam.json');
|
||||
}
|
||||
|
||||
export function newId() {
|
||||
return `stu_${randomBytes(6).toString('hex')}`;
|
||||
}
|
||||
|
||||
export class StaffIam {
|
||||
constructor() {
|
||||
/** @type {Array<{id:string,username:string,name:string,passwordHash:string,roles:string[],active:boolean,createdMs:number}>} */
|
||||
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() {
|
||||
if (this.users.length) return this;
|
||||
const seeds = [
|
||||
{
|
||||
username: process.env.IAM_OWNER_USER || process.env.ADMIN_USER || 'admin',
|
||||
password: process.env.IAM_OWNER_PASSWORD || process.env.ADMIN_KEY || 'admin-dev-key',
|
||||
name: 'Owner',
|
||||
roles: ['owner'],
|
||||
},
|
||||
{
|
||||
username: process.env.IAM_CS_USER || 'cs',
|
||||
password: process.env.IAM_CS_PASSWORD || 'cs-dev-key',
|
||||
name: 'Customer service',
|
||||
roles: ['cs'],
|
||||
},
|
||||
{
|
||||
username: process.env.IAM_SALES_USER || 'sales',
|
||||
password: process.env.IAM_SALES_PASSWORD || 'sales-dev-key',
|
||||
name: 'Sales',
|
||||
roles: ['sales'],
|
||||
},
|
||||
{
|
||||
username: process.env.IAM_ACCT_USER || 'accounting',
|
||||
password: process.env.IAM_ACCT_PASSWORD || 'acct-dev-key',
|
||||
name: 'Accounting',
|
||||
roles: ['accounting'],
|
||||
},
|
||||
{
|
||||
username: process.env.IAM_OPS_USER || 'operator',
|
||||
password: process.env.IAM_OPS_PASSWORD || 'fleet-dev-key',
|
||||
name: 'Fleet operator',
|
||||
roles: ['operator'],
|
||||
},
|
||||
];
|
||||
for (const s of seeds) {
|
||||
this.users.push({
|
||||
id: newId(),
|
||||
username: s.username,
|
||||
name: s.name,
|
||||
passwordHash: hashPassword(s.password),
|
||||
roles: s.roles,
|
||||
active: true,
|
||||
createdMs: Date.now(),
|
||||
});
|
||||
}
|
||||
this.log('system', 'seed', null, `${this.users.length} users`);
|
||||
return this;
|
||||
}
|
||||
|
||||
log(actor, action, target, detail) {
|
||||
this.audit.unshift({
|
||||
t: new Date().toISOString(),
|
||||
actor: actor || 'system',
|
||||
action,
|
||||
target: target || undefined,
|
||||
detail: detail || undefined,
|
||||
});
|
||||
this.audit = this.audit.slice(0, 400);
|
||||
}
|
||||
|
||||
findByUsername(username) {
|
||||
return this.users.find((u) => u.username === username);
|
||||
}
|
||||
|
||||
findById(id) {
|
||||
return this.users.find((u) => u.id === id);
|
||||
}
|
||||
|
||||
create({ username, password, name, roles, actor }) {
|
||||
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]);
|
||||
const user = {
|
||||
id: newId(),
|
||||
username,
|
||||
name: name || username,
|
||||
passwordHash: hashPassword(password),
|
||||
roles: cleanRoles.length ? cleanRoles : ['viewer'],
|
||||
active: true,
|
||||
createdMs: Date.now(),
|
||||
};
|
||||
this.users.push(user);
|
||||
this.log(actor, 'user.create', username, user.roles.join(','));
|
||||
return user;
|
||||
}
|
||||
|
||||
update(username, patch, actor) {
|
||||
const user = this.findByUsername(username);
|
||||
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
|
||||
if (patch.name !== undefined) user.name = String(patch.name);
|
||||
if (Array.isArray(patch.roles)) {
|
||||
user.roles = patch.roles.filter((r) => ROLES[r]);
|
||||
}
|
||||
if (typeof patch.active === 'boolean') {
|
||||
if (patch.active === false) {
|
||||
const owners = this.users.filter((u) => u.active && u.roles.includes('owner'));
|
||||
if (user.roles.includes('owner') && owners.length <= 1) {
|
||||
throw Object.assign(new Error('cannot deactivate the last owner'), { status: 400 });
|
||||
}
|
||||
}
|
||||
user.active = patch.active;
|
||||
}
|
||||
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);
|
||||
}
|
||||
this.log(actor, 'user.update', username, JSON.stringify({ roles: user.roles, active: user.active }));
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadIam() {
|
||||
const iam = new StaffIam();
|
||||
const p = storePath();
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
iam.load(JSON.parse(fs.readFileSync(p, 'utf8')));
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
if (!iam.users.length) iam.seed();
|
||||
return iam;
|
||||
}
|
||||
|
||||
export function saveIam(iam) {
|
||||
const p = storePath();
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(iam.dump(), null, 2));
|
||||
fs.renameSync(tmp, p);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue