Files
dashcaddy/dashcaddy-api/src/security/invite-store.js
T
hermes 321334cd33
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-048: multi-user bootstrap + admin invites (opt-in)
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.
2026-07-20 17:44:11 -07:00

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 };