Files
dashcaddy/dashcaddy-api/src/auth/providers/email.js
T
Krystie 503de258b8 [grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13.
These files were modified during the Aug 12 sprint but never committed.
2026-08-12 17:34:10 -07:00

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;