Pluggable AuthProvider framework for any future auth method (OIDC, SAML,
passkeys) to plug in without touching the auth path again. Two
implementations ship:
* TOTP — refactored from routes/auth/totp.js into src/auth/providers/totp.js
as one AuthProvider impl. Legacy /api/v1/totp/* routes stay mounted for
back-compat; new /api/v1/auth/login/totp/* routes use the new shape.
* EmailMagicLink — src/auth/providers/email.js. Initiate issues a 32-byte
base64url token, stores its SHA-256 hash in data/email-tokens.json
(atomic lockfile-based mutation, automatic TTL cleanup), and delivers via
nodemailer if providers.email.{host,port,username,password} is set OR
falls back to log.info('auth', 'email magic link issued', ...) for dev.
Verify accepts the token, marks it used, creates the same DashCaddy
session cookie that TOTP uses (single global cookie model).
createAuthProviderRegistry() composes both implementations and exposes
them via /api/v1/auth/login/{methods, :provider/initiate, :provider/verify,
recovery-info} and /api/v1/auth/disable/:provider. PUBLIC_ROUTES + CSRF
exemptions updated to use :provider placeholder (parameterized for future
providers).
Test fix: src/utilities/middleware.js PUBLIC_ROUTES and csrf-protection.js
both switched to the :provider form because the prior literal 'totp'
wouldn't match the parameterized mount path Express 4.22 produces.
Test fix: __tests__/public-routes-drift.test.js extractMountPath() was
broken for Express 4.22's new ^/path/?(?=/|$) source format (no \?\?
terminator in the literal-mount case). Rewrote the parser to normalize
escaped slashes + trailing lookaheads instead of relying on regex
matching against the raw source.
New: __tests__/auth-provider-registry.test.js — 9 tests covering registry
composition, getProvider round-trip, listEnabled no-secrets-leak guarantee,
enabled-flag respect, listAll vs listEnabled distinction, email provider
dev-console fallback (token written to JSON store + log.info with
deliveredVia: 'dev-console' + response masked), verify rejects unknown
tokens via AuthenticationError.
Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).
DNS2 deploy: this commit + bump VERSION to 1.16.0 + publish tarball to
get.dashcaddy.net + docker build + bash start.sh. The image-layer migration
from DC-050 also runs on first container recreate post-merge.
389 lines
17 KiB
JavaScript
389 lines
17 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 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 || '<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;
|