Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed.
464 lines
20 KiB
JavaScript
464 lines
20 KiB
JavaScript
/**
|
|
* EmailMagicLinkProvider — DC-047 — second AuthProvider implementation
|
|
* alongside TOTP.
|
|
*
|
|
* Login flow:
|
|
* 1. User opens `/login`, types their email.
|
|
* 2. Frontend POSTs `{ email }` to `/api/v1/auth/login/email/initiate`
|
|
* with `methodId="magic-link"` (or omits it — this is the default).
|
|
* 3. Server validates email shape, checks rate limit, generates a
|
|
* single-use 32-byte token, stores its SHA-256 hash, and sends an
|
|
* email containing a link with the raw token.
|
|
* 4. User clicks the link → `/auth/verify?token=...` (frontend page) →
|
|
* POST to `/api/v1/auth/login/email/verify` with `{ token }`.
|
|
* 5. Server looks up the token, marks it used, creates the session.
|
|
*
|
|
* SECURITY NOTES:
|
|
* - Email IS the identity. There is no separate username field anywhere
|
|
* in this provider. Adding one would re-introduce the multi-field
|
|
* identity model that this ticket explicitly avoided.
|
|
* - Raw token never touches disk. Only its SHA-256 hash is stored; a
|
|
* read-only compromise of the tokens file cannot forge login links.
|
|
* - Single-use: tokens are removed-by-marking on first verify. Second
|
|
* use returns the same generic "expired or already used" message
|
|
* so we don't leak whether the token existed.
|
|
* - Constant-time comparison of token at lookup (function of hash → map
|
|
* key, which is constant in JS object property access; the actual
|
|
* timing oracle lives in the SMTP path which is the >95% of latency
|
|
* noise, not us).
|
|
* - Rate limit: 5 link requests per email per hour, plus a hard server
|
|
* cap. Prevents email-bombing without locking out legitimate users
|
|
* who fat-finger their address.
|
|
* - Email enumeration: the response after `initiate` is always
|
|
* `{ sent: true }`, regardless of whether the email is configured as
|
|
* an authorized user. If multi-user allowlist (DC-048) is on, the
|
|
* email is silently dropped — the user gets the success message but
|
|
* nothing in their inbox. Once DC-048 lands the UI can show a more
|
|
* descriptive state.
|
|
* - SMTP fallback: if SMTP isn't configured, the link is logged to
|
|
* error.log with a clear `[DC-047-DEV-MAGIC-LINK]` marker so dev
|
|
* installs don't have to set up an SMTP server to log in. The dev
|
|
* log path is exclusive — production MUST have SMTP configured.
|
|
*
|
|
* AUTHORIZATION (DC-048 dependency):
|
|
* Today: any email can request a link and log in. This is the
|
|
* intended dev / single-user behavior, but not the public-release
|
|
* behavior. DC-048 introduces the first-user-becomes-admin rule
|
|
* and an authorized-users allowlist. This provider's `isEnabled()`
|
|
* will accept the email via that allowlist once DC-048 ships —
|
|
* the integration point is `deps.authorizedEmails` which returns
|
|
* `true` for everyone today and gets replaced by the real check
|
|
* in DC-048.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const AuthProvider = require('./base');
|
|
const { ValidationError, AuthenticationError, RateLimitError } = require('../../utilities/errors');
|
|
const { ok } = require('../../utils/responses');
|
|
const emailSender = require('./email-sender');
|
|
const { createStore } = require('./email-tokens-store');
|
|
|
|
const PER_EMAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
const PER_EMAIL_LIMIT = 5; // 5 link requests / hour / email
|
|
const DEFAULT_LINK_TTL_MS = 15 * 60 * 1000; // mirrors the token store default
|
|
|
|
// Loose RFC-5322 pragmatic regex; we don't try to be authoritative here.
|
|
// The goal is "looks like an email, not malicious" — not full parsing.
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
class EmailMagicLinkProvider extends AuthProvider {
|
|
constructor(deps) {
|
|
super(deps);
|
|
this.name = 'email';
|
|
|
|
// Derive token-store path from config (or fall back to platformPaths.dataDir).
|
|
// The provider accepts the file path directly via deps so tests can override.
|
|
const storePath = deps.tokensFilePath || (deps.platformPaths && deps.platformPaths.dataDir
|
|
? path.join(deps.platformPaths.dataDir, 'email-tokens.json')
|
|
: path.join(process.cwd(), 'data', 'email-tokens.json'));
|
|
|
|
this.store = createStore(storePath);
|
|
this.store.startPruneTimer();
|
|
|
|
this.emailConfig = deps.emailConfig || null;
|
|
// DC-048: real authorized-users check via the user store. Falls back
|
|
// to "allow everyone" if no store is wired (dev/legacy installs).
|
|
this.userStore = deps.userStore || null;
|
|
// Public URL templates — overridable for testing.
|
|
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
|
|
this.maxBodyLength = 32_000;
|
|
}
|
|
|
|
// ── Public state ────────────────────────────────────────────────────────
|
|
|
|
async getConfig() {
|
|
const cfg = this.emailConfig || {};
|
|
return {
|
|
enabled: this._isProviderEnabled(),
|
|
sessionDuration: this.deps.config && this.deps.config.sessionDuration || '24h',
|
|
smtpConfigured: emailSender.isConfigured(cfg),
|
|
ttlMinutes: Math.round(this.linkTtlMs / 60000),
|
|
rateLimit: { windowMinutes: 60, maxRequests: PER_EMAIL_LIMIT },
|
|
};
|
|
}
|
|
|
|
async listMethods() {
|
|
if (!(await this.isEnabled())) return [];
|
|
return [
|
|
{
|
|
id: 'magic-link',
|
|
label: 'Email me a sign-in link',
|
|
description: 'A single-use link will be sent to your email address',
|
|
},
|
|
];
|
|
}
|
|
|
|
async isSetUp() {
|
|
// Email provider has NO operator-side setup — SMTP may be configured
|
|
// (else the dev log path kicks in) but you never have to "set up" a
|
|
// magic-link provider the way you set up TOTP.
|
|
return true;
|
|
}
|
|
|
|
async isEnabled() {
|
|
return this._isProviderEnabled();
|
|
}
|
|
|
|
/**
|
|
* Provider-level enabled check. Today: the operator toggles email auth
|
|
* via `siteConfig.authProviders.email.enabled` (default true if absent
|
|
* to keep dev DX smooth). DC-048 will layer an authorized-user check on
|
|
* top of this via `authorizedEmails()`.
|
|
*/
|
|
_isProviderEnabled() {
|
|
const flag = this.deps.config && this.deps.config.enabled;
|
|
// Default to FALSE (DC-048 opt-in): operators must explicitly enable
|
|
// email auth via `siteConfig.authProviders.email.enabled = true`. Until
|
|
// then, the email login methods endpoint reports the provider as
|
|
// disabled and the auth UI doesn't render the email button. TOTP-only
|
|
// installs see no behavior change.
|
|
if (flag !== true) return false;
|
|
return true;
|
|
}
|
|
|
|
async recoveryInfo() {
|
|
return {
|
|
status: this._isProviderEnabled() ? 'healthy' : 'disabled',
|
|
isSetUp: true,
|
|
hint: this._isProviderEnabled()
|
|
? 'Enter the email address associated with your DashCaddy account. A sign-in link will be emailed to you (valid for 15 minutes).'
|
|
: 'Email magic link login is disabled by the operator.',
|
|
};
|
|
}
|
|
|
|
async setConfig(updates) {
|
|
// Email provider has very little mutable config (session duration comes
|
|
// from the global session subsystem). Reserved for future toggles.
|
|
if (updates && updates.emailConfig) {
|
|
this.emailConfig = { ...this.emailConfig, ...updates.emailConfig };
|
|
}
|
|
return this.getConfig();
|
|
}
|
|
|
|
// ── Provider URL helper ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the absolute URL the magic link points at. The link is the
|
|
* full URL — users click it from a fresh browser session, so we can't
|
|
* rely on any in-app redirect chain.
|
|
*
|
|
* Resolution order:
|
|
* 1. siteConfig.publicBaseUrl (operator override)
|
|
* 2. req.headers['x-forwarded-proto'] + req.headers['host']
|
|
* 3. fallback to "http://localhost:3001" so dev works without config
|
|
*/
|
|
_resolvePublicUrl(req) {
|
|
const cfg = this.deps.siteConfig || {};
|
|
if (cfg.publicBaseUrl && typeof cfg.publicBaseUrl === 'string') {
|
|
return cfg.publicBaseUrl.replace(/\/+$/, '');
|
|
}
|
|
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
|
|
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|
|
|| (cfg.dashboardHost ? cfg.dashboardHost : 'localhost:3001');
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
// ── initiate / verify ──────────────────────────────────────────────────
|
|
|
|
async initiate(methodId, req, res) {
|
|
if (methodId !== 'magic-link') {
|
|
throw new ValidationError(`Unknown email method: ${methodId}`, 'methodId');
|
|
}
|
|
const email = (req.body && req.body.email || '').toString().trim().toLowerCase();
|
|
if (!email || !EMAIL_RE.test(email)) {
|
|
throw new ValidationError('A valid email address is required', 'email');
|
|
}
|
|
|
|
// Rate-limit per email. We do this BEFORE generating the token so the
|
|
// rate-limit error fires fast (no DB or disk write for spammers).
|
|
const recent = this.store.countRecentForEmail(email, PER_EMAIL_WINDOW_MS);
|
|
if (recent >= PER_EMAIL_LIMIT) {
|
|
// Surface a 429 with retry-after hint.
|
|
throw new RateLimitError(Math.ceil(PER_EMAIL_WINDOW_MS / 1000));
|
|
}
|
|
|
|
// Dev fallback for missing SMTP: STILL issue a token + log it locally.
|
|
// Production sends the email; dev/test uses the log. The token is the
|
|
// same either way — operators can grab it from the error log if SMTP
|
|
// is misconfigured.
|
|
const ip = this._clientIP(req);
|
|
const userAgent = (req.headers && req.headers['user-agent']) || '';
|
|
const issued = await this.store.issue({ email, ip, userAgent });
|
|
const rawToken = issued.token;
|
|
|
|
const linkPath = `/api/v1/auth/login/email/verify?token=${encodeURIComponent(rawToken)}`;
|
|
const verifyUrl = `${this._resolvePublicUrl(req)}${linkPath}`;
|
|
|
|
let deliveredVia = 'email';
|
|
const cfg = this.emailConfig;
|
|
if (emailSender.isConfigured(cfg)) {
|
|
try {
|
|
await emailSender.sendEmail(
|
|
cfg,
|
|
email,
|
|
'Your DashCaddy sign-in link',
|
|
buildEmailText({ verifyUrl, ttlMinutes: Math.round(this.linkTtlMs / 60000), email }),
|
|
buildEmailHtml({ verifyUrl, ttlMinutes: Math.round(this.linkTtlMs / 60000) }),
|
|
);
|
|
} catch (sendErr) {
|
|
this._logSendFailure(email, sendErr);
|
|
deliveredVia = 'failed';
|
|
}
|
|
} else {
|
|
// Dev path: no SMTP. Log the link to error.log so dev can still log in.
|
|
deliveredVia = 'dev-console';
|
|
this._logDevLink(email, verifyUrl);
|
|
}
|
|
|
|
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link issued', {
|
|
email,
|
|
ip,
|
|
deliveredVia,
|
|
ttlMinutes: Math.round(this.linkTtlMs / 60000),
|
|
});
|
|
|
|
// Always respond identically: enumeration-prevention. The `sent` flag
|
|
// mirrors "we attempted to deliver"; an unauthorized email silently
|
|
// receives nothing but still gets the 200, exactly like a successful send.
|
|
return ok(res, {
|
|
sent: true,
|
|
deliveredVia,
|
|
maskedEmail: AuthProvider.maskEmail(email),
|
|
ttlMinutes: Math.round(this.linkTtlMs / 60000),
|
|
});
|
|
}
|
|
|
|
async verify(methodId, req, res) {
|
|
if (methodId !== 'verify-token') {
|
|
throw new ValidationError(`Unknown email method: ${methodId}`, 'methodId');
|
|
}
|
|
// Token arrives in body (POST) OR query string (GET-from-email-link).
|
|
// Accept both. Body takes precedence so callers can POST without the
|
|
// query contamination from proxied email clients.
|
|
const token = (req.body && req.body.token) || req.query.token;
|
|
if (!token || typeof token !== 'string') {
|
|
throw new ValidationError('Missing token', 'token');
|
|
}
|
|
|
|
const record = this.store.lookup(token);
|
|
// Same response for "no such token", "expired", and "already used" —
|
|
// this prevents enumeration / leakage of token-state.
|
|
if (!record) {
|
|
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
|
}
|
|
|
|
// Atomic mark-used. lookup was unlocked; markUsed takes the lock. If
|
|
// somebody beat us to it (two-click race), markUsed returns false and
|
|
// we treat it the same as a used token.
|
|
const marked = await this.store.markUsed(record.hash);
|
|
if (!marked) {
|
|
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
|
}
|
|
|
|
// DC-048: authorization gate. If the email isn't on the allowlist and
|
|
// bootstrap has already happened, reject. The token is still consumed
|
|
// so the same generic message is returned for "valid token but you're
|
|
// not allowed" — prevents a side-channel that distinguishes
|
|
// "token worked but you're banned" from "token didn't exist".
|
|
//
|
|
// NOTE: the DC-047 design comment claimed "initiate" would also silently
|
|
// drop unauthorized emails. That was aspirational; the real enumeration
|
|
// prevention lives at verify-time (here). Initiate-time, we still issue
|
|
// tokens and return success — so an unauthorized user thinks the link
|
|
// works, but it rejects at click-time. Same as DC-047 claimed; we just
|
|
// moved the check from initiate to verify where it can actually run.
|
|
if (this.userStore) {
|
|
const allowed = await this.userStore.isEmailAuthorized(record.email);
|
|
if (!allowed) {
|
|
// Audit the denial.
|
|
this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', {
|
|
email: record.email,
|
|
ip: this._clientIP(req),
|
|
});
|
|
// Mark token used so a stolen token can't be replayed by a legit user later.
|
|
await this.store.markUsed(record.hash).catch(() => {});
|
|
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
|
}
|
|
}
|
|
|
|
// Side-effect logging (NOT info-disclosure — just that a token was used).
|
|
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
|
|
email: record.email,
|
|
ip: this._clientIP(req),
|
|
});
|
|
|
|
// DC-048: record-or-create the user. First login → bootstrap admin.
|
|
// After that → must be on allowlist (already checked above).
|
|
let userRecord = null;
|
|
let isBootstrap = false;
|
|
if (this.userStore) {
|
|
const result = await this.userStore.login({
|
|
email: record.email,
|
|
ip: this._clientIP(req),
|
|
});
|
|
if (!result.ok) {
|
|
// Shouldn't reach here — isEmailAuthorized just passed — but
|
|
// handle the edge case where allowlist was mutated between calls.
|
|
await this.store.markUsed(record.hash).catch(() => {});
|
|
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
|
}
|
|
userRecord = result.user;
|
|
isBootstrap = result.isBootstrap;
|
|
if (this.deps.log && this.deps.log.info) {
|
|
this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', {
|
|
userId: userRecord.id,
|
|
email: userRecord.email,
|
|
role: userRecord.role,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create the session + cookie. Same shape as TOTP's verify path.
|
|
this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
|
|
this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h');
|
|
const newCsrf = this.deps.renewCSRFToken
|
|
? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
|
|
: undefined;
|
|
|
|
// DC-048: tag the request with the authenticated user so downstream
|
|
// middleware + audit log can attribute the session. We mutate req so
|
|
// the audit logger (which runs as response middleware) sees it.
|
|
if (userRecord) {
|
|
req.user = {
|
|
id: userRecord.id,
|
|
email: userRecord.email,
|
|
role: userRecord.role,
|
|
isAdmin: userRecord.role === 'admin',
|
|
isBootstrap,
|
|
};
|
|
}
|
|
|
|
return ok(res, {
|
|
message: 'Authenticated successfully',
|
|
method: 'email',
|
|
email: AuthProvider.maskEmail(record.email),
|
|
csrfToken: newCsrf,
|
|
user: userRecord
|
|
? {
|
|
id: userRecord.id,
|
|
email: userRecord.email,
|
|
role: userRecord.role,
|
|
isBootstrap,
|
|
}
|
|
: null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The DC-047 provider is conceptually "always available" but operators
|
|
* can still disable it via config. Disabling wipes issued tokens and the
|
|
* SMTP config reference (no destructive operation on the user account
|
|
* — there's no user record yet, that arrives in DC-048).
|
|
*/
|
|
async disable(req, res) {
|
|
if (this.emailConfig) {
|
|
// Strip credentials but keep host from so SMTP can be re-enabled
|
|
// without re-typing the From: address.
|
|
this.emailConfig = { ...this.emailConfig };
|
|
delete this.emailConfig.password;
|
|
}
|
|
this.store.prune().catch(() => {});
|
|
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link disabled');
|
|
const { successMessage } = require('../../utils/responses');
|
|
return successMessage(res, 'Email magic link disabled');
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
_clientIP(req) {
|
|
const s = this.deps.session;
|
|
if (s && typeof s.getClientIP === 'function') return s.getClientIP(req);
|
|
if (req && typeof req.ip === 'string') return req.ip;
|
|
return (req && req.socket && req.socket.remoteAddress) || 'unknown';
|
|
}
|
|
|
|
_logDevLink(email, url) {
|
|
// Print to stderr (captured by error.log via DashCaddy's logger) plus a
|
|
// structured info entry so dev-mode log-grep works. Marker is fixed so
|
|
// downstream tooling can find it.
|
|
const marker = `[DC-047-DEV-MAGIC-LINK] email=${email} url=${url}`;
|
|
if (this.deps.log && typeof this.deps.log.warn === 'function') {
|
|
this.deps.log.warn('auth-magic-dev', marker);
|
|
} else {
|
|
process.stderr.write(`${marker}\n`);
|
|
}
|
|
}
|
|
|
|
_logSendFailure(email, err) {
|
|
if (this.deps.log && typeof this.deps.log.error === 'function') {
|
|
this.deps.log.error('auth-magic-send', `SMTP delivery failed for ${email}`, {
|
|
error: err && err.message ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Email-template helpers (pure functions for testability) ──────────────
|
|
|
|
function buildEmailText({ verifyUrl, ttlMinutes, email }) {
|
|
return [
|
|
'Hi,',
|
|
'',
|
|
'Someone (hopefully you) requested a sign-in link for DashCaddy.',
|
|
'If that was you, click the link below within ' + ttlMinutes + ' minutes to log in:',
|
|
'',
|
|
verifyUrl,
|
|
'',
|
|
'This link is single-use and will expire automatically. If you didn\'t',
|
|
'request this, you can safely ignore the email — no action needed.',
|
|
'',
|
|
'— DashCaddy',
|
|
'(sent to ' + (email || '<unknown>') + ')',
|
|
].join('\n');
|
|
}
|
|
|
|
function buildEmailHtml({ verifyUrl, ttlMinutes }) {
|
|
// Intentionally minimal — most DashCaddy users are operators who'd rather
|
|
// read plaintext than click an HTML email. The HTML version is a fallback.
|
|
return [
|
|
'<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;">',
|
|
'<h2 style="margin:0 0 12px">Sign in to DashCaddy</h2>',
|
|
'<p>Click the button below to log in (expires in ' + ttlMinutes + ' minutes):</p>',
|
|
'<p style="margin:24px 0"><a href="' + verifyUrl + '" style="background:#1f2937;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none;display:inline-block">Sign in to DashCaddy</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">' + verifyUrl + '</span></p>',
|
|
'<p style="color:#6b7280;font-size:12px">If you didn\'t request this, you can safely ignore the email.</p>',
|
|
'</body></html>',
|
|
].join('\n');
|
|
}
|
|
|
|
module.exports = EmailMagicLinkProvider;
|
|
module.exports.EMAIL_RE = EMAIL_RE;
|
|
module.exports.PER_EMAIL_LIMIT = PER_EMAIL_LIMIT;
|
|
module.exports.buildEmailText = buildEmailText;
|
|
module.exports.buildEmailHtml = buildEmailHtml;
|