Implements the user-store + invite-store + admin routes. The whole system is opt-in via siteConfig.authProviders.email.enabled = true; single-user TOTP-only installs see zero behavior change. Backend: - src/security/user-store.js: users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, defensive dataDir resolver. - src/security/invite-store.js: single-use tokens (SHA-256 hashed on disk), TTL, auto-prune, defensive dataDir resolver. - routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist, /admin/invites (CRUD), public /invites/:token (peek + accept). - routes/auth/index.js: wires userStore, gates admin router on email auth being enabled. - src/auth/providers/email.js: verify() enforces allowlist, creates user record, tags req.user; default-enabled flipped to opt-in. - src/auth/providers/totp.js: bootstraps system@totp.local admin on first verify so current DNS2 operator shows in /admin/users. - src/security/audit-logger.js: middleware adds userId/userEmail/ userRole/viaProvider to log details when req.user is tagged. - PUBLIC_ROUTES + CSRF allowlists updated for invite redemption. Frontend: - status/js/admin.js: modal overlay with users list (role-edit, delete), invite form (email/role/TTL), copy-link button, outstanding-invites list with revoke. Exports window.AdminPanel. - status/js/core/init.js: calls AdminPanel.attachTrigger so the Admin button only appears when /me returns isAdmin=true. Tests: 35 new tests across 3 files (user-store, invite-store, auth multistore integration). Full suite: 1298/1298 passing. Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased] section gets the DC-048 entry.
266 lines
7.8 KiB
JavaScript
266 lines
7.8 KiB
JavaScript
/**
|
|
* Invite store — DC-048.
|
|
*
|
|
* Single-use invite tokens with TTL. Admin generates an invite for an email;
|
|
* the system emails (or logs in dev) a magic-link-style URL containing the
|
|
* raw token. The recipient clicks → accepts → becomes an authorized user.
|
|
*
|
|
* Storage: data/invites.json. Atomic writes via tmp+rename.
|
|
*
|
|
* Token shape:
|
|
* - 32 random bytes, base64url-encoded (256 bits of entropy).
|
|
* - We store ONLY the SHA-256 hash on disk. The raw token lives in the
|
|
* email + in the URL query string; on the server we hash and look up.
|
|
* A read-only compromise of invites.json cannot forge acceptance.
|
|
*
|
|
* Lifecycle:
|
|
* - issue({ email, role, ttlMs, invitedBy }) → { id, token, expiresAt, ... }
|
|
* token is the only time the raw token will ever be returned.
|
|
* - peek(token) → { email, role, expiresAt, usedAt } | null
|
|
* (returns the public-safe info without consuming the token)
|
|
* - accept(token) → { ok: true, invite } | { ok: false, reason }
|
|
* reasons: 'not_found', 'expired', 'already_used'
|
|
* - revoke(id) → removes the invite by id (admin-only).
|
|
* - list() → all outstanding invites (admin-only).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const platformPaths = require('../../platform-paths');
|
|
|
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
|
|
|
|
function _nowMs() { return Date.now(); }
|
|
function _nowIso() { return new Date().toISOString(); }
|
|
|
|
function _atomicWriteJSON(filePath, data) {
|
|
const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
fs.renameSync(tmp, filePath);
|
|
}
|
|
|
|
function _readJSON(filePath, fallback) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') return fallback;
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function _defaultData() { return { invites: {} }; }
|
|
|
|
function _sha256(s) {
|
|
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
}
|
|
|
|
function createInviteStore(opts = {}) {
|
|
// Same defensive resolver as user-store — universal-deps test proxies
|
|
// can return function-typed values for property access.
|
|
const candidates = [
|
|
opts.dataDir,
|
|
opts.platformPaths && opts.platformPaths.dataDir,
|
|
platformPaths && platformPaths.dataDir,
|
|
];
|
|
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|
|
|| require('os').tmpdir();
|
|
const log = opts.log || { info() {}, warn() {}, error() {} };
|
|
|
|
const file = path.join(dataDir, 'invites.json');
|
|
|
|
let _mutex = Promise.resolve();
|
|
function _enqueue(fn) {
|
|
const next = _mutex.then(fn, fn);
|
|
_mutex = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
function _load() {
|
|
const data = _readJSON(file, _defaultData());
|
|
if (!data.invites || typeof data.invites !== 'object') data.invites = {};
|
|
return data;
|
|
}
|
|
function _save(data) { _atomicWriteJSON(file, data); }
|
|
|
|
function _prune(data) {
|
|
const cutoff = _nowMs() - PRUNE_AFTER_MS;
|
|
for (const id of Object.keys(data.invites)) {
|
|
const inv = data.invites[id];
|
|
if (!inv) { delete data.invites[id]; continue; }
|
|
const isTerminal = inv.usedAt || (inv.expiresAt && new Date(inv.expiresAt).getTime() < cutoff);
|
|
if (isTerminal) delete data.invites[id];
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Issue a new invite. Returns the raw token (only time it leaves the system).
|
|
*/
|
|
function issue({ email, role = 'operator', ttlMs = DEFAULT_TTL_MS, invitedBy = 'admin' } = {}) {
|
|
return _enqueue(() => {
|
|
if (typeof email !== 'string' || !email.includes('@')) {
|
|
return { ok: false, reason: 'invalid_email' };
|
|
}
|
|
const normalized = email.toLowerCase().trim();
|
|
const id = crypto.randomUUID();
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const hash = _sha256(token);
|
|
const issuedAt = _nowIso();
|
|
const expiresAt = new Date(_nowMs() + ttlMs).toISOString();
|
|
|
|
const data = _load();
|
|
_prune(data);
|
|
data.invites[id] = {
|
|
id,
|
|
hash,
|
|
email: normalized,
|
|
role,
|
|
invitedBy,
|
|
issuedAt,
|
|
expiresAt,
|
|
usedAt: null,
|
|
usedBy: null,
|
|
};
|
|
_save(data);
|
|
|
|
log.info && log.info('invite', 'invite issued', {
|
|
id, email: normalized, role, invitedBy, ttlMs,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
id,
|
|
token, // raw token — caller emails it
|
|
email: normalized,
|
|
role,
|
|
expiresAt,
|
|
ttlMs,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Public-safe peek. Does NOT consume the token.
|
|
* Returns null if not found, expired, or already used (same response
|
|
* for all three — enumeration prevention).
|
|
*/
|
|
function peek(token) {
|
|
if (!token || typeof token !== 'string') return null;
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const hash = _sha256(token);
|
|
const inv = _findByHash(data, hash);
|
|
if (!inv) return null;
|
|
if (inv.usedAt) return null;
|
|
if (new Date(inv.expiresAt).getTime() < _nowMs()) return null;
|
|
return {
|
|
id: inv.id,
|
|
email: inv.email,
|
|
role: inv.role,
|
|
expiresAt: inv.expiresAt,
|
|
issuedAt: inv.issuedAt,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Consume an invite token. Returns the invite record on success.
|
|
* After accept(), the invite is marked used (NOT deleted) so the admin
|
|
* can see who redeemed what. The auto-prune reaps it after 7 days.
|
|
*/
|
|
function accept(token, { acceptedBy } = {}) {
|
|
return _enqueue(() => {
|
|
if (!token || typeof token !== 'string') {
|
|
return { ok: false, reason: 'not_found' };
|
|
}
|
|
const data = _load();
|
|
const hash = _sha256(token);
|
|
const inv = _findByHash(data, hash);
|
|
if (!inv) return { ok: false, reason: 'not_found' };
|
|
if (inv.usedAt) return { ok: false, reason: 'already_used' };
|
|
if (new Date(inv.expiresAt).getTime() < _nowMs()) {
|
|
return { ok: false, reason: 'expired' };
|
|
}
|
|
|
|
inv.usedAt = _nowIso();
|
|
inv.usedBy = acceptedBy || null;
|
|
_save(data);
|
|
|
|
log.info && log.info('invite', 'invite accepted', {
|
|
id: inv.id, email: inv.email, role: inv.role, acceptedBy,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
invite: {
|
|
id: inv.id,
|
|
email: inv.email,
|
|
role: inv.role,
|
|
expiresAt: inv.expiresAt,
|
|
usedAt: inv.usedAt,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Admin-only. Revoke an outstanding invite by id.
|
|
*/
|
|
function revoke(id) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
if (!data.invites[id]) return { ok: false, reason: 'not_found' };
|
|
delete data.invites[id];
|
|
_save(data);
|
|
log.info && log.info('invite', 'invite revoked', { id });
|
|
return { ok: true };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Admin-only. List outstanding invites (excludes used/expired).
|
|
*/
|
|
function listOutstanding() {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
_prune(data);
|
|
_save(data);
|
|
const now = _nowMs();
|
|
return Object.values(data.invites)
|
|
.filter(inv => !inv.usedAt && new Date(inv.expiresAt).getTime() > now)
|
|
.sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt))
|
|
.map(inv => ({
|
|
id: inv.id,
|
|
email: inv.email,
|
|
role: inv.role,
|
|
invitedBy: inv.invitedBy,
|
|
issuedAt: inv.issuedAt,
|
|
expiresAt: inv.expiresAt,
|
|
}));
|
|
});
|
|
}
|
|
|
|
function _findByHash(data, hash) {
|
|
for (const id of Object.keys(data.invites)) {
|
|
const inv = data.invites[id];
|
|
if (inv && inv.hash === hash) return inv;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
issue,
|
|
peek,
|
|
accept,
|
|
revoke,
|
|
listOutstanding,
|
|
DEFAULT_TTL_MS,
|
|
};
|
|
}
|
|
|
|
module.exports = { createInviteStore, DEFAULT_TTL_MS }; |