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.
407 lines
14 KiB
JavaScript
407 lines
14 KiB
JavaScript
/**
|
|
* User store — DC-048.
|
|
*
|
|
* Tracks who is allowed to log in to a DashCaddy instance, and what role each
|
|
* authenticated user has. Replaces the "one implicit operator" model that
|
|
* DC-046/047 shipped with.
|
|
*
|
|
* TWO files (both live under platformPaths.dataDir):
|
|
*
|
|
* data/users.json — every user that has ever authenticated.
|
|
* Shape: {
|
|
* users: {
|
|
* [userId]: {
|
|
* id, email, displayName, role,
|
|
* createdBy, createdAt,
|
|
* lastLoginAt, lastLoginIp, loginCount
|
|
* }
|
|
* },
|
|
* order: [userId, ...]
|
|
* }
|
|
*
|
|
* data/authorized-users.json — the ALLOWLIST. Emails on this list may log in.
|
|
* The bootstrap user (first-ever login) is
|
|
* implicitly authorized even if the file is
|
|
* empty. Shape: { emails: ["a@x.com", ...] }
|
|
*
|
|
* Bootstrap rule: the FIRST email to ever successfully authenticate is
|
|
* automatically granted role "admin" AND implicitly added to the allowlist.
|
|
* This is recorded by writing a sentinel file `data/.bootstrapped` with the
|
|
* admin email so we never bootstrap twice (e.g. after a restore from backup).
|
|
*
|
|
* Atomic writes: every persistence op writes to a .tmp file then renames.
|
|
* process restart loses nothing in flight because rename is atomic on POSIX.
|
|
*
|
|
* Concurrency: a single in-process mutex serializes mutating ops. We don't
|
|
* need cross-process locks because this API is single-instance by design.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const platformPaths = require('../../platform-paths');
|
|
|
|
const ROLES = Object.freeze({
|
|
ADMIN: 'admin',
|
|
OPERATOR: 'operator',
|
|
VIEWER: 'viewer',
|
|
});
|
|
|
|
// All roles recognized by the system. Used for validation only.
|
|
const VALID_ROLES = new Set(Object.values(ROLES));
|
|
|
|
// Email shape — same pragmatic regex as the email provider.
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
function _nowIso() { return new Date().toISOString(); }
|
|
function _isEmail(s) { return typeof s === 'string' && EMAIL_RE.test(s); }
|
|
|
|
function _defaultUsers() { return { users: {}, order: [] }; }
|
|
function _defaultAllowlist() { return { emails: [] }; }
|
|
|
|
// Coerce a candidate to a writable string dataDir; return null otherwise.
|
|
// Used by the factory's resolver to ignore test proxies / function-typed
|
|
// values from universal-deps that the `||` short-circuit can't filter.
|
|
function _resolveDataDir(opts) {
|
|
const candidates = [
|
|
opts.dataDir,
|
|
opts.platformPaths && opts.platformPaths.dataDir,
|
|
platformPaths && platformPaths.dataDir,
|
|
];
|
|
for (const c of candidates) {
|
|
if (typeof c === 'string' && c.length > 0) return c;
|
|
}
|
|
return require('os').tmpdir();
|
|
}
|
|
|
|
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;
|
|
// Corrupt file: log and return fallback so the API keeps serving.
|
|
// The next mutation will rewrite the file cleanly.
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory. One user-store per process.
|
|
*
|
|
* @param {Object} [opts]
|
|
* @param {string} [opts.dataDir] — override for tests
|
|
* @param {Object} [opts.log] — structured logger
|
|
*/
|
|
function createUserStore(opts = {}) {
|
|
// Resolve dataDir defensively — universal-deps test proxies can return
|
|
// function-typed values for property access, which `||` won't filter.
|
|
const dataDir = _resolveDataDir(opts);
|
|
const log = opts.log || { info() {}, warn() {}, error() {} };
|
|
|
|
const usersFile = path.join(dataDir, 'users.json');
|
|
const allowlistFile = path.join(dataDir, 'authorized-users.json');
|
|
const bootstrapSentinel = path.join(dataDir, '.bootstrapped');
|
|
|
|
let _mutex = Promise.resolve();
|
|
|
|
function _enqueue(fn) {
|
|
const next = _mutex.then(fn, fn);
|
|
// Swallow errors on the chain so one failure doesn't poison subsequent ops.
|
|
_mutex = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
function _loadUsers() {
|
|
const data = _readJSON(usersFile, _defaultUsers());
|
|
if (!data.users || typeof data.users !== 'object') data.users = {};
|
|
if (!Array.isArray(data.order)) data.order = Object.keys(data.users);
|
|
return data;
|
|
}
|
|
|
|
function _loadAllowlist() {
|
|
const data = _readJSON(allowlistFile, _defaultAllowlist());
|
|
if (!Array.isArray(data.emails)) data.emails = [];
|
|
return data;
|
|
}
|
|
|
|
function _saveUsers(data) { _atomicWriteJSON(usersFile, data); }
|
|
function _saveAllowlist(data) { _atomicWriteJSON(allowlistFile, data); }
|
|
|
|
function _bootstrapDone() {
|
|
try { return fs.existsSync(bootstrapSentinel); }
|
|
catch { return false; }
|
|
}
|
|
|
|
function _writeBootstrapSentinel(adminEmail) {
|
|
_atomicWriteJSON(bootstrapSentinel, {
|
|
bootstrappedAt: _nowIso(),
|
|
adminEmail: adminEmail.toLowerCase(),
|
|
});
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Authenticate-or-create a user from an email. Implements the DC-048
|
|
* bootstrap rule and the authorized-users allowlist.
|
|
*
|
|
* Returns one of:
|
|
* { ok: true, user, role, isBootstrap }
|
|
* { ok: false, reason: 'not_authorized' }
|
|
*
|
|
* Reasons:
|
|
* 'not_authorized' — email not in allowlist AND bootstrap already happened.
|
|
*
|
|
* If bootstrap hasn't happened yet (no users file, no .bootstrapped sentinel),
|
|
* the first email that successfully passes shape validation becomes admin
|
|
* AND gets added to the allowlist atomically.
|
|
*/
|
|
function login({ email, ip, displayName, createdBy } = {}) {
|
|
return _enqueue(() => {
|
|
if (!_isEmail(email)) {
|
|
return { ok: false, reason: 'invalid_email' };
|
|
}
|
|
const normalized = email.toLowerCase().trim();
|
|
|
|
const users = _loadUsers();
|
|
const allowlist = _loadAllowlist();
|
|
|
|
// Existing user → just bump login counters.
|
|
const existing = _findUserByEmail(users, normalized);
|
|
if (existing) {
|
|
existing.lastLoginAt = _nowIso();
|
|
existing.lastLoginIp = ip || '';
|
|
existing.loginCount = (existing.loginCount || 0) + 1;
|
|
_saveUsers(users);
|
|
log.info && log.info('user', 'login existing user', {
|
|
userId: existing.id, email: normalized, role: existing.role,
|
|
});
|
|
return { ok: true, user: existing, role: existing.role, isBootstrap: false };
|
|
}
|
|
|
|
// New email. Allow if (a) bootstrap hasn't happened, or (b) allowlisted.
|
|
const bootstrapPending = !_bootstrapDone() && users.order.length === 0;
|
|
const onAllowlist = allowlist.emails.includes(normalized);
|
|
|
|
if (!bootstrapPending && !onAllowlist) {
|
|
log.info && log.info('user', 'login denied — not on allowlist', { email: normalized });
|
|
return { ok: false, reason: 'not_authorized' };
|
|
}
|
|
|
|
// Bootstrap path: first-ever user becomes admin.
|
|
const isBootstrap = bootstrapPending;
|
|
const role = isBootstrap ? ROLES.ADMIN : ROLES.OPERATOR;
|
|
|
|
const newUser = {
|
|
id: crypto.randomUUID(),
|
|
email: normalized,
|
|
displayName: displayName || normalized.split('@')[0],
|
|
role,
|
|
createdBy: createdBy || (isBootstrap ? 'bootstrap' : 'invite'),
|
|
createdAt: _nowIso(),
|
|
lastLoginAt: _nowIso(),
|
|
lastLoginIp: ip || '',
|
|
loginCount: 1,
|
|
};
|
|
users.users[newUser.id] = newUser;
|
|
users.order.unshift(newUser.id);
|
|
|
|
// If bootstrap: implicitly allowlist + write sentinel.
|
|
if (isBootstrap) {
|
|
if (!allowlist.emails.includes(normalized)) {
|
|
allowlist.emails.push(normalized);
|
|
}
|
|
_saveAllowlist(allowlist);
|
|
_writeBootstrapSentinel(normalized);
|
|
}
|
|
|
|
_saveUsers(users);
|
|
|
|
log.info && log.info('user', isBootstrap ? 'bootstrap admin created' : 'invited user created', {
|
|
userId: newUser.id, email: normalized, role,
|
|
});
|
|
|
|
return { ok: true, user: newUser, role, isBootstrap };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add an email to the allowlist WITHOUT creating a user record. Used when
|
|
* admin pre-authorizes someone who hasn't logged in yet.
|
|
*
|
|
* Returns { ok: true, alreadyExisted: boolean }.
|
|
*/
|
|
function addToAllowlist(email) {
|
|
return _enqueue(() => {
|
|
if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' };
|
|
const normalized = email.toLowerCase().trim();
|
|
const allowlist = _loadAllowlist();
|
|
if (allowlist.emails.includes(normalized)) {
|
|
return { ok: true, alreadyExisted: true };
|
|
}
|
|
allowlist.emails.push(normalized);
|
|
_saveAllowlist(allowlist);
|
|
log.info && log.info('user', 'added to allowlist', { email: normalized });
|
|
return { ok: true, alreadyExisted: false };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove an email from the allowlist. Does NOT delete the user record
|
|
* (so the admin can read the login history) — but future logins by that
|
|
* email will be rejected unless bootstrap re-runs (which it won't).
|
|
*/
|
|
function removeFromAllowlist(email) {
|
|
return _enqueue(() => {
|
|
if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' };
|
|
const normalized = email.toLowerCase().trim();
|
|
const allowlist = _loadAllowlist();
|
|
const idx = allowlist.emails.indexOf(normalized);
|
|
if (idx === -1) return { ok: true, alreadyRemoved: true };
|
|
allowlist.emails.splice(idx, 1);
|
|
_saveAllowlist(allowlist);
|
|
log.info && log.info('user', 'removed from allowlist', { email: normalized });
|
|
return { ok: true, alreadyRemoved: false };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update an existing user's role. Role must be in VALID_ROLES.
|
|
* Returns { ok: true } or { ok: false, reason }.
|
|
*/
|
|
function setRole(userId, role) {
|
|
return _enqueue(() => {
|
|
if (!VALID_ROLES.has(role)) return { ok: false, reason: 'invalid_role' };
|
|
const users = _loadUsers();
|
|
const u = users.users[userId];
|
|
if (!u) return { ok: false, reason: 'not_found' };
|
|
u.role = role;
|
|
_saveUsers(users);
|
|
log.info && log.info('user', 'role updated', { userId, role });
|
|
return { ok: true };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a user record AND remove from allowlist. Cannot delete the last
|
|
* admin (you'd lock yourself out). Returns { ok: true } or { ok: false, reason }.
|
|
*/
|
|
function deleteUser(userId) {
|
|
return _enqueue(() => {
|
|
const users = _loadUsers();
|
|
const u = users.users[userId];
|
|
if (!u) return { ok: false, reason: 'not_found' };
|
|
|
|
// Count remaining admins.
|
|
const remainingAdmins = users.order
|
|
.map(id => users.users[id])
|
|
.filter(x => x && x.role === ROLES.ADMIN && x.id !== userId).length;
|
|
if (u.role === ROLES.ADMIN && remainingAdmins === 0) {
|
|
return { ok: false, reason: 'last_admin' };
|
|
}
|
|
|
|
delete users.users[userId];
|
|
users.order = users.order.filter(id => id !== userId);
|
|
|
|
// Also remove from allowlist so re-invite is a clean slate.
|
|
const allowlist = _loadAllowlist();
|
|
const idx = allowlist.emails.indexOf(u.email);
|
|
if (idx !== -1) {
|
|
allowlist.emails.splice(idx, 1);
|
|
_saveAllowlist(allowlist);
|
|
}
|
|
|
|
_saveUsers(users);
|
|
log.info && log.info('user', 'user deleted', { userId, email: u.email });
|
|
return { ok: true };
|
|
});
|
|
}
|
|
|
|
function listUsers() {
|
|
return _enqueue(() => {
|
|
const users = _loadUsers();
|
|
return users.order
|
|
.map(id => users.users[id])
|
|
.filter(Boolean);
|
|
});
|
|
}
|
|
|
|
function listAllowlist() {
|
|
return _enqueue(() => {
|
|
const allowlist = _loadAllowlist();
|
|
return [...allowlist.emails];
|
|
});
|
|
}
|
|
|
|
function getUser(userId) {
|
|
return _enqueue(() => {
|
|
const users = _loadUsers();
|
|
return users.users[userId] || null;
|
|
});
|
|
}
|
|
|
|
function getUserByEmail(email) {
|
|
return _enqueue(() => {
|
|
if (!_isEmail(email)) return null;
|
|
const users = _loadUsers();
|
|
return _findUserByEmail(users, email.toLowerCase().trim()) || null;
|
|
});
|
|
}
|
|
|
|
function isBootstrapComplete() {
|
|
return _enqueue(() => _bootstrapDone());
|
|
}
|
|
|
|
/**
|
|
* Helper for the auth system: given an email, return whether the user
|
|
* is allowed to attempt login (allowlist OR bootstrap-pending). Used by
|
|
* the email provider's `authorizedEmails()` dependency.
|
|
*/
|
|
function isEmailAuthorized(email) {
|
|
return _enqueue(() => {
|
|
if (!_isEmail(email)) return false;
|
|
const normalized = email.toLowerCase().trim();
|
|
const allowlist = _loadAllowlist();
|
|
if (allowlist.emails.includes(normalized)) return true;
|
|
const users = _loadUsers();
|
|
// Bootstrap path: if no users yet, the first login is implicitly allowed.
|
|
return users.order.length === 0 && !_bootstrapDone();
|
|
});
|
|
}
|
|
|
|
function _findUserByEmail(users, normalizedEmail) {
|
|
for (const id of users.order) {
|
|
const u = users.users[id];
|
|
if (u && u.email === normalizedEmail) return u;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
login,
|
|
addToAllowlist,
|
|
removeFromAllowlist,
|
|
setRole,
|
|
deleteUser,
|
|
listUsers,
|
|
listAllowlist,
|
|
getUser,
|
|
getUserByEmail,
|
|
isBootstrapComplete,
|
|
isEmailAuthorized,
|
|
// Constants for callers
|
|
ROLES,
|
|
VALID_ROLES,
|
|
};
|
|
}
|
|
|
|
module.exports = { createUserStore, ROLES, VALID_ROLES }; |