/** * 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 will replace this with the real authorized-users check. this.authorizedEmails = deps.authorizedEmails || (() => true); // 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 TRUE: dev installs should "just work". Operators who want // to disable email login set `siteConfig.authProviders.email.enabled = false` // — the same config knob the TOTP provider uses. if (flag === false) 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'); } // 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), }); // 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; return ok(res, { message: 'Authenticated successfully', method: 'email', email: AuthProvider.maskEmail(record.email), csrfToken: newCsrf, }); } /** * 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 { // eslint-disable-next-line no-console console.warn(marker); } } _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 || '') + ')', ].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 [ '', '

Sign in to DashCaddy

', '

Click the button below to log in (expires in ' + ttlMinutes + ' minutes):

', '

Sign in to DashCaddy

', '

If the button doesn\'t work, paste this link into your browser:
' + verifyUrl + '

', '

If you didn\'t request this, you can safely ignore the email.

', '', ].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;