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.
341 lines
14 KiB
JavaScript
341 lines
14 KiB
JavaScript
/**
|
|
* Admin + me routes — DC-048.
|
|
*
|
|
* Mounted at /api/v1/auth. All `/admin/*` routes require the session to
|
|
* belong to a user with role 'admin'. `/me` requires any authenticated session.
|
|
*
|
|
* Endpoints:
|
|
* GET /me — current user (id, email, role, isAdmin)
|
|
* GET /admin/users — list all users
|
|
* POST /admin/users — pre-authorize an email (allowlist)
|
|
* PATCH /admin/users/:id — change a user's role
|
|
* DELETE /admin/users/:id — delete user + remove from allowlist
|
|
* GET /admin/allowlist — list authorized emails
|
|
* GET /admin/invites — list outstanding invites
|
|
* POST /admin/invites — issue a new invite (returns raw token ONCE)
|
|
* DELETE /admin/invites/:id — revoke an invite
|
|
*
|
|
* POST /invites/accept — PUBLIC — redeem an invite token,
|
|
* create user, set session cookie
|
|
* GET /invites/:token — PUBLIC — peek at an invite (email,
|
|
* role, expires) without consuming it.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const platformPaths = require('../../platform-paths');
|
|
const { createUserStore } = require('../../src/security/user-store');
|
|
const { createInviteStore } = require('../../src/security/invite-store');
|
|
const emailSender = require('../../src/auth/providers/email-sender');
|
|
const { ValidationError, NotFoundError, ForbiddenError } = require('../../src/utilities/errors');
|
|
const { ok, successMessage } = require('../../src/utils/responses');
|
|
|
|
/**
|
|
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
|
|
* _resolvePublicUrl logic — kept duplicated (not extracted) because the two
|
|
* callers have slightly different link paths and the duplication is smaller
|
|
* than the abstraction would be.
|
|
*/
|
|
function _buildInviteUrl(req, siteConfig, token) {
|
|
if (siteConfig && siteConfig.publicBaseUrl) {
|
|
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
|
|
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
|
|
}
|
|
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
|
|
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|
|
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
|
|
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
|
|
}
|
|
|
|
function _requireAdmin(req, _res, next) {
|
|
if (!req.user || req.user.role !== 'admin') {
|
|
return next(new ForbiddenError('Admin role required'));
|
|
}
|
|
next();
|
|
}
|
|
|
|
function _buildEmailText({ acceptUrl, ttlHours, role }) {
|
|
return [
|
|
'Hi,',
|
|
'',
|
|
'You\'ve been invited to join a DashCaddy instance as a ' + role + '.',
|
|
'Click the link below within ' + ttlHours + ' hours to accept:',
|
|
'',
|
|
acceptUrl,
|
|
'',
|
|
'This link is single-use. If you weren\'t expecting this invitation,',
|
|
'you can safely ignore this email.',
|
|
'',
|
|
'— DashCaddy',
|
|
].join('\n');
|
|
}
|
|
|
|
function _buildEmailHtml({ acceptUrl, ttlHours, role }) {
|
|
return [
|
|
'<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;">',
|
|
'<h2 style="margin:0 0 12px">You\'re invited to DashCaddy</h2>',
|
|
'<p>You\'ve been invited to join as <strong>' + role + '</strong>.</p>',
|
|
'<p>Click the button below within ' + ttlHours + ' hours to accept:</p>',
|
|
'<p style="margin:24px 0"><a href="' + acceptUrl + '" style="background:#1f2937;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none;display:inline-block">Accept invitation</a></p>',
|
|
'<p style="color:#6b7280;font-size:12px">If the button doesn\'t work, paste this link into your browser:<br><span style="word-break:break-all">' + acceptUrl + '</span></p>',
|
|
'<p style="color:#6b7280;font-size:12px">If you weren\'t expecting this, you can ignore this email.</p>',
|
|
'</body></html>',
|
|
].join('\n');
|
|
}
|
|
|
|
module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }) {
|
|
const router = express.Router();
|
|
// user-store / invite-store handle their own defensive dataDir resolution
|
|
// (they ignore Proxy/function values from universal-deps test deps).
|
|
const resolvedDataDir = dataDir || (platformPaths && platformPaths.dataDir);
|
|
const userStore = createUserStore({ dataDir: resolvedDataDir, log });
|
|
const inviteStore = createInviteStore({ dataDir: resolvedDataDir, log });
|
|
|
|
// ── /me ───────────────────────────────────────────────────────────────
|
|
|
|
router.get('/me', asyncHandler(async (req, res) => {
|
|
if (!req.user || !req.user.id) {
|
|
// Legacy session without user attribution. Return the bare role
|
|
// (defaults to admin for backwards-compat) but signal via
|
|
// `legacy: true` so the UI knows.
|
|
return ok(res, {
|
|
user: null,
|
|
authenticated: session ? session.isSessionValid(req) : false,
|
|
role: 'admin', // legacy: assume operator-level access
|
|
legacy: true,
|
|
});
|
|
}
|
|
const stored = await userStore.getUser(req.user.id);
|
|
return ok(res, {
|
|
user: stored
|
|
? {
|
|
id: stored.id,
|
|
email: stored.email,
|
|
displayName: stored.displayName,
|
|
role: stored.role,
|
|
isAdmin: stored.role === 'admin',
|
|
createdAt: stored.createdAt,
|
|
lastLoginAt: stored.lastLoginAt,
|
|
loginCount: stored.loginCount,
|
|
}
|
|
: null,
|
|
authenticated: true,
|
|
role: req.user.role,
|
|
legacy: false,
|
|
});
|
|
}, 'auth-me'));
|
|
|
|
// ── /admin/users ──────────────────────────────────────────────────────
|
|
|
|
router.get('/admin/users', _requireAdmin, asyncHandler(async (_req, res) => {
|
|
const users = await userStore.listUsers();
|
|
return ok(res, { users });
|
|
}, 'auth-admin-users-list'));
|
|
|
|
router.post('/admin/users', _requireAdmin, asyncHandler(async (req, res) => {
|
|
const { email, role } = req.body || {};
|
|
if (!email) throw new ValidationError('email is required', 'email');
|
|
if (role && !userStore.VALID_ROLES.has(role)) {
|
|
throw new ValidationError('Invalid role', 'role');
|
|
}
|
|
const result = await userStore.addToAllowlist(email);
|
|
if (!result.ok) throw new ValidationError(result.reason, 'email');
|
|
// If a role was provided AND the user already exists, also set the role.
|
|
if (role) {
|
|
const existing = await userStore.getUserByEmail(email);
|
|
if (existing) {
|
|
await userStore.setRole(existing.id, role);
|
|
}
|
|
}
|
|
return ok(res, {
|
|
email: email.toLowerCase(),
|
|
alreadyExisted: result.alreadyExisted,
|
|
});
|
|
}, 'auth-admin-users-create'));
|
|
|
|
router.patch('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => {
|
|
const { role } = req.body || {};
|
|
if (!role || !userStore.VALID_ROLES.has(role)) {
|
|
throw new ValidationError('Invalid role', 'role');
|
|
}
|
|
const result = await userStore.setRole(req.params.id, role);
|
|
if (!result.ok) {
|
|
throw result.reason === 'not_found'
|
|
? new NotFoundError('User not found')
|
|
: new ValidationError(result.reason, 'role');
|
|
}
|
|
return successMessage(res, 'Role updated');
|
|
}, 'auth-admin-users-update'));
|
|
|
|
router.delete('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => {
|
|
const result = await userStore.deleteUser(req.params.id);
|
|
if (!result.ok) {
|
|
if (result.reason === 'not_found') throw new NotFoundError('User not found');
|
|
if (result.reason === 'last_admin') {
|
|
throw new ValidationError('Cannot delete the last admin');
|
|
}
|
|
throw new ValidationError(result.reason);
|
|
}
|
|
return successMessage(res, 'User deleted');
|
|
}, 'auth-admin-users-delete'));
|
|
|
|
// ── /admin/allowlist ──────────────────────────────────────────────────
|
|
|
|
router.get('/admin/allowlist', _requireAdmin, asyncHandler(async (_req, res) => {
|
|
const emails = await userStore.listAllowlist();
|
|
return ok(res, { emails });
|
|
}, 'auth-admin-allowlist'));
|
|
|
|
// ── /admin/invites ────────────────────────────────────────────────────
|
|
|
|
router.get('/admin/invites', _requireAdmin, asyncHandler(async (_req, res) => {
|
|
const invites = await inviteStore.listOutstanding();
|
|
return ok(res, { invites });
|
|
}, 'auth-admin-invites-list'));
|
|
|
|
router.post('/admin/invites', _requireAdmin, asyncHandler(async (req, res) => {
|
|
const { email, role, ttlHours, sendEmail } = req.body || {};
|
|
if (!email) throw new ValidationError('email is required', 'email');
|
|
const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168)
|
|
? ttlHours * 60 * 60 * 1000
|
|
: inviteStore.DEFAULT_TTL_MS;
|
|
const invitedBy = (req.user && req.user.email) || 'admin';
|
|
const issued = await inviteStore.issue({
|
|
email,
|
|
role: (role && userStore.VALID_ROLES.has(role)) ? role : 'operator',
|
|
ttlMs,
|
|
invitedBy,
|
|
});
|
|
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
|
|
|
let deliveredVia = 'none';
|
|
let maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
|
if (sendEmail !== false) {
|
|
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
|
|
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
|
|
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
|
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
|
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
|
try {
|
|
const smtpConfig = req.app.locals && req.app.locals.emailConfig;
|
|
if (smtpConfig && emailSender.isConfigured(smtpConfig)) {
|
|
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
|
deliveredVia = 'email';
|
|
} else {
|
|
// Dev fallback — log the raw link so operators can grab it.
|
|
log.warn && log.warn('auth-invite-dev',
|
|
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
|
|
' role=' + issued.role + ' url=' + acceptUrl);
|
|
deliveredVia = 'dev-console';
|
|
}
|
|
} catch (sendErr) {
|
|
log.warn && log.warn('auth-invite-send',
|
|
'invite send failed: ' + (sendErr.message || String(sendErr)));
|
|
deliveredVia = 'failed';
|
|
}
|
|
} else {
|
|
deliveredVia = 'manual';
|
|
}
|
|
|
|
return ok(res, {
|
|
id: issued.id,
|
|
email: issued.email,
|
|
role: issued.role,
|
|
expiresAt: issued.expiresAt,
|
|
// The raw token is returned ONCE so the admin UI can show/copy the
|
|
// link. It is also embedded in the email when sendEmail !== false.
|
|
acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
|
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
|
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
|
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) +
|
|
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
|
|
deliveredVia,
|
|
maskedEmail,
|
|
});
|
|
}, 'auth-admin-invites-create'));
|
|
|
|
router.delete('/admin/invites/:id', _requireAdmin, asyncHandler(async (req, res) => {
|
|
const result = await inviteStore.revoke(req.params.id);
|
|
if (!result.ok) throw new NotFoundError('Invite not found');
|
|
return successMessage(res, 'Invite revoked');
|
|
}, 'auth-admin-invites-revoke'));
|
|
|
|
// ── /invites (public) ──────────────────────────────────────────────────
|
|
|
|
// PUBLIC: peek at an invite without consuming it.
|
|
router.get('/invites/:token', asyncHandler(async (req, res) => {
|
|
const peeked = await inviteStore.peek(req.params.token);
|
|
if (!peeked) {
|
|
// Same response as "not found" — don't leak token state.
|
|
return ok(res, { valid: false });
|
|
}
|
|
return ok(res, {
|
|
valid: true,
|
|
email: peeked.email,
|
|
role: peeked.role,
|
|
expiresAt: peeked.expiresAt,
|
|
});
|
|
}, 'auth-invites-peek'));
|
|
|
|
// PUBLIC: accept an invite token. Creates the user, sets the session.
|
|
router.post('/invites/:token/accept', asyncHandler(async (req, res) => {
|
|
const result = await inviteStore.accept(req.params.token, {
|
|
acceptedBy: req.user ? req.user.email : null,
|
|
});
|
|
if (!result.ok) {
|
|
throw new ValidationError('Invitation is ' + result.reason.replace('_', ' '), 'token');
|
|
}
|
|
|
|
// Authorize the email + create the user record.
|
|
const invite = result.invite;
|
|
const userResult = await userStore.login({
|
|
email: invite.email,
|
|
ip: req.ip || '',
|
|
displayName: invite.email.split('@')[0],
|
|
createdBy: 'invite:' + invite.id,
|
|
});
|
|
if (!userResult.ok) {
|
|
throw new ValidationError('Could not create user from invite: ' + userResult.reason);
|
|
}
|
|
|
|
// Create session (same shape as email verify path).
|
|
if (session) {
|
|
session.create(req, '24h');
|
|
session.setCookie(res, '24h');
|
|
}
|
|
if (req.app.locals && req.app.locals.renewCSRFToken) {
|
|
req.app.locals.renewCSRFToken(res, req.secure || req.protocol === 'https');
|
|
}
|
|
|
|
// Attach user to request for audit log.
|
|
req.user = {
|
|
id: userResult.user.id,
|
|
email: userResult.user.email,
|
|
role: userResult.user.role,
|
|
isAdmin: userResult.user.role === 'admin',
|
|
isBootstrap: false,
|
|
viaProvider: 'invite',
|
|
};
|
|
|
|
log.info && log.info('auth', 'invite accepted, user created', {
|
|
userId: userResult.user.id,
|
|
email: userResult.user.email,
|
|
role: userResult.user.role,
|
|
inviteId: invite.id,
|
|
});
|
|
|
|
return ok(res, {
|
|
message: 'Invitation accepted',
|
|
user: {
|
|
id: userResult.user.id,
|
|
email: userResult.user.email,
|
|
role: userResult.user.role,
|
|
},
|
|
csrfToken: res.locals && res.locals.csrfToken,
|
|
});
|
|
}, 'auth-invites-accept'));
|
|
|
|
return router;
|
|
}; |