diff --git a/BACKLOG.md b/BACKLOG.md index a1c6fd7..704d140 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -256,17 +256,19 @@ Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0. - **result:** Fixed in src/recipes/bundled-workflows.js. New regression test `__tests__/bundled-workflows-health-check.test.js` — 5 cases (uses .read() not .getState(), correct counts, graceful degrade on read() throw, no servicesStateManager on ctx, single-service path). Full suite: 1219/1219 pass (+5 new). ### DC-046: Pluggable AuthProvider interface — refactor TOTP into one of N providers -- **status:** in-progress +- **status:** done - **owner:** hermes - **details:** Today DashCaddy has only one login method (TOTP). For a public-release product we need at least a second (email magic link), and the TOTP-only design doesn't scale — every new user needs a TOTP secret provisioned manually, no self-service recovery, no per-user audit trail. Refactor: define a `AuthProvider` interface in `src/auth/providers/` with methods `{ name, enabled, loginMethods, initiate(req) -> {redirect, challenge?}, verify(req) -> {user} }`. Move the existing TOTP code into `src/auth/providers/totp.js` as one implementation of that interface. `createApp` composes all enabled providers and exposes them via `/api/v1/auth/login` and `/api/v1/auth/login/:method` routes. Login page lists all enabled providers with their own button. Zero behavior change for existing TOTP users — the route shape becomes `/api/v1/auth/login/totp` instead of `/api/v1/auth/login`, but the existing UI is rewritten to match. Effort: ~1 hr. Risk: medium (touches the auth path that is the most security-sensitive area of the codebase). - **impact:** Unlocks every other auth provider (DC-047 email magic link, DC-048+ OIDC, SAML, etc.) without further refactors of the auth path. +- **result:** Shipped. 6 new modules under `src/auth/providers/` (~1100 LOC): `base.js` (AuthProvider contract), `totp.js` (TOTP impl), `email.js` + `email-tokens-store.js` + `email-sender.js` (DC-047 email impl, included here because the registry requires both), `index.js` (createAuthProviderRegistry). New `routes/auth/login.js` (109 LOC) mounts under `/auth`. Existing `routes/auth/index.js` wires the registry + mount. `src/utilities/middleware.js` + `src/security/csrf-protection.js` PUBLIC_ROUTES + CSRF entries updated to `/api/v1/auth/login/:provider/{initiate,verify}` and `/api/v1/auth/disable/:provider` (parameterized, future-proof for OIDC/SAML). `__tests__/auth-provider-registry.test.js` (9 new tests) covers registry composition, no-secrets-leak guarantee, enabled-flag respect, dev-console fallback for the email provider. `__tests__/public-routes-drift.test.js` fixed for Express 4.22.x compat (the previous regex extraction broke on the new `^\/path\/?(?=\/|$)` source format). Tests: **1241/1241 passing across 46 suites** (was 1232; +9 new). ### DC-047: EmailMagicLinkProvider — email-only login via nodemailer -- **status:** in-progress +- **status:** done - **owner:** hermes - **details:** Second AuthProvider implementation, sitting alongside TOTP. **Email IS the identity — no separate username field at any point.** Flow: user enters email at `/login`, server generates a single-use token (32 random bytes, base64url), stores it in `data/email-tokens.json` with 15-min TTL, sends an email via the existing nodemailer connection in `src/managers/notification-manager.js:290` (reuse the same SMTP config — `providers.email.host/port/username/password/from`). Email body contains a link like `https://dashcaddy.example.com/auth/verify?token=abc123`. Click → server validates token (exists, not expired, not already used) → marks used → creates session cookie → redirect to dashboard. On subsequent visits, session cookie is the credential. Rate-limit the request-link endpoint to 5 per email per hour to prevent email-bombing. Tokens stored as SHA-256 hashes in the JSON store so a read-only compromise can't be used to forge links. Effort: ~3 hrs. Risk: medium (depends on SMTP creds being configured; if not, fall back to console-logging the link in dev mode). - **impact:** Public product readiness. Zero-password login. No username/email split — one field, one identifier. Reuses existing nodemailer config — no new dependency, no new credential surface. Works with any SMTP server Sami already uses (he mentioned using the SMTP server his website runs). - **prerequisite:** DC-046 (the interface to implement against). +- **result:** Shipped as part of DC-046 commit. `src/auth/providers/email.js` (388 LOC): registers `magic-link` (initiate) + `verify-token` (verify) methods, generates 32-byte base64url tokens, stores SHA-256 hashes via `email-tokens-store.js`. `email-tokens-store.js` (260 LOC): atomic lockfile-based mutation, automatic cleanup of expired tokens, audit log on every issue/use. `email-sender.js` (67 LOC): wraps nodemailer if `providers.email` config is set, else falls back to `log.info('auth', 'email magic link issued', ...)` so dev installs work without SMTP config. Verified with stub deps: `initiate()` writes a token + logs `deliveredVia: 'dev-console'` + returns masked email; `verify('verify-token', { token: 'garbage' })` throws AuthenticationError (route handler converts to 401). Real SMTP wiring takes effect as soon as `providers.email.host/port/username/password` are set in config.json. ### DC-048: Multi-user bootstrap + admin invites - **status:** todo @@ -275,6 +277,17 @@ Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0. - **impact:** First real multi-user DashCaddy. Per-user audit attribution. Self-service invites. Foundation for any future "team" features. - **prerequisite:** DC-047 (needs email auth working first). +### Backlog note (2026-07-20, hermes) + +DC-046 + DC-047 landed together in one commit because the registry requires both implementations to be loaded at startup — splitting them would mean a half-broken registry at the intermediate commit. The commit message documents both IDs. + +DNS2 deploy: code change + `scripts/publish-release.sh` + `docker build` + `bash start.sh` + live verify. After this lands, `/api/v1/auth/login/methods` returns both `totp` and `email` providers for any host with email-magic-link enabled. Hosts without SMTP configured fall back to the dev-console path so end-to-end testing works before production SMTP is provisioned. + +Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a replacement — TOTP remains his primary method for personal/network-only access, email magic link is for public-product readiness. Architecture choice: `AuthProvider` interface in `src/auth/providers/` so future methods (OIDC, SAML, passkeys) plug in without further refactors. SMTP delivery reuses the existing `nodemailer` integration in `src/managers/notification-manager.js:290` — no new dependency. Sami plans to use the SMTP server his website runs (sami-ahmed.net) so the host field will be configurable. +- **details:** The user model shifts from "one implicit operator" to "many users with explicit roles". Bootstrap rule: the FIRST email to ever successfully log in via email magic link becomes the admin. Subsequent emails are denied with a `not authorized` error UNLESS the email appears in `data/authorized-users.json`. Admin UI: a `/users` page that lists authorized users, lets admin add emails (manual entry) or generate single-use invite links (which work like magic links but pre-add the email to the allowlist on first use). Audit log gets `userEmail` attribution on every entry. License model is unchanged (still per-host) but note in the ticket that this may need revisiting. Effort: ~2 hrs. Risk: low (mostly UI + JSON-store CRUD). +- **impact:** First real multi-user DashCaddy. Per-user audit attribution. Self-service invites. Foundation for any future "team" features. +- **prerequisite:** DC-047 (needs email auth working first). + ### DC-049: Update login UI to show multiple providers - **status:** todo - **owner:** unclaimed diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5da6a..f236d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Pluggable `AuthProvider` framework + TOTP + EmailMagicLink providers (DC-046 + DC-047).** New `src/auth/providers/` directory contains the `AuthProvider` base class contract, the TOTP provider (refactored from existing `routes/auth/totp.js`), and a new `EmailMagicLinkProvider` that issues single-use base64url tokens (stored as SHA-256 hashes in `data/email-tokens.json`), sends via the existing nodemailer config (or logs to console + `log.info('email magic link issued')` in dev fallback). `createAuthProviderRegistry()` composes all providers and surfaces them via `/api/v1/auth/login/methods` (`GET`), `/api/v1/auth/login/:provider/{initiate,verify}` (`POST`), `/api/v1/auth/login/recovery-info` (`GET`), `/api/v1/auth/disable/:provider` (`POST`). Future auth methods (OIDC, SAML, passkeys) plug into the registry without auth-path refactors. - **`platform-paths.assertSafe()` — DC-046 hardening.** Production startup refuses to boot if `dataDir` resolves into a Docker image-layer forbidden zone (`/app/src`, `/app/routes`, `/app/utils`, `/app/managers`, `/app/security`, `/etc/*`, `/var/lib/caddy`, etc.). Catches the silent failure mode where `SERVICES_FILE` isn't set as an env var and the resolver falls back to a path that would lose runtime state on every container recreate. Bypassed with `SKIP_DATA_DIR_GUARD=1` for emergency legacy setups. - **`platform-paths.isMountedCheck(dir)`.** Heuristic predicate for detecting whether a directory is reachable + writable + on a separate filesystem from `/app`. Used by `start.sh` migration step to no-op safely on fresh installs. - **`start.sh` one-time image-layer migration step.** Runs before `docker run`. Scans 6 known image-layer zombie paths (`/opt/dashcaddy/dashcaddy-api/src/{security,utils,managers}/*`), copies any non-empty content to `${DATA_DIR}/migrated-*`, gates one-shot with a sentinel file. Idempotent. Recovers the 140KB `error.log` and any license-secret that landed in the image layer pre-DC-039. diff --git a/dashcaddy-api/__tests__/auth-provider-registry.test.js b/dashcaddy-api/__tests__/auth-provider-registry.test.js new file mode 100644 index 0000000..97d127d --- /dev/null +++ b/dashcaddy-api/__tests__/auth-provider-registry.test.js @@ -0,0 +1,272 @@ +/** + * Regression tests for the pluggable auth provider registry (DC-046 + DC-047). + * + * Covers: + * - registry composes TOTP + EmailMagicLink + * - listEnabled() surfaces public config, no secrets + * - listEnabled() respects per-provider enabled flag + * - getProvider(name) round-trips + * - EmailMagicLinkProvider falls back to dev-console when SMTP not configured + * - EmailMagicLinkProvider initiate + verify end-to-end with dev fallback + * + * Note: TOTP behavior is exercised separately by auth.totp.routes.test.js. + */ + +const path = require('path'); + +describe('AuthProvider registry (DC-046 + DC-047)', () => { + let createAuthProviderRegistry; + let tmpDataDir; + + beforeAll(() => { + process.env.SERVICES_FILE = '/tmp/__dc046_test_services__.json'; + process.env.NODE_ENV = 'test'; + ({ createAuthProviderRegistry } = require(path.resolve(__dirname, '../src/auth/providers'))); + const fs = require('fs'); + const os = require('os'); + tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc046-')); + }); + + afterAll(() => { + const fs = require('fs'); + try { fs.rmSync(tmpDataDir, { recursive: true, force: true }); } catch {} + try { fs.unlinkSync(process.env.SERVICES_FILE); } catch {} + }); + + function makeDeps(overrides = {}) { + return { + credentialManager: { + encrypt: async (s) => `enc:${s}`, + decrypt: async (s) => (s || '').replace(/^enc:/, ''), + getKey: () => 'k', + ...overrides.credentialManager, + }, + session: { + create: () => ({ token: 'tok-' + Math.random(), expiresAt: Date.now() + 86400000 }), + get: () => null, + setCookie: () => {}, + destroy: () => {}, + ...overrides.session, + }, + saveTotpConfig: overrides.saveTotpConfig || (async () => {}), + config: { + totp: { enabled: true }, + email: { enabled: true, sessionDuration: '24h', ttlMinutes: 15 }, + ...overrides.config, + }, + log: { + info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, + ...overrides.log, + }, + renewCSRFToken: () => {}, + emailConfig: overrides.emailConfig !== undefined ? overrides.emailConfig : null, + siteConfig: overrides.siteConfig || { publicUrl: 'https://status.sami' }, + platformPaths: overrides.platformPaths || { dataDir: tmpDataDir }, + ...overrides.extra, + }; + } + + test('registry composes both TOTP and EmailMagicLink providers', () => { + const r = createAuthProviderRegistry(makeDeps(), {}); + expect([...r.providers.keys()].sort()).toEqual(['email', 'totp']); + }); + + test('getProvider returns registered providers and null for unknown', () => { + const r = createAuthProviderRegistry(makeDeps(), {}); + expect(r.getProvider('totp')).toBeTruthy(); + expect(r.getProvider('email')).toBeTruthy(); + expect(r.getProvider('oidc')).toBeNull(); + expect(r.getProvider('')).toBeNull(); + }); + + test('listEnabled surfaces public config for any enabled providers, no secrets', async () => { + const r = createAuthProviderRegistry(makeDeps(), {}); + const enabled = await r.listEnabled(); + // Whether TOTP appears depends on whether it's been set up yet — that's + // the legitimate production behavior. What's invariant: every entry + // returned is a provider with safe public config (no secrets leak). + for (const p of enabled) { + expect(p.name).toBeTruthy(); + expect(Array.isArray(p.methods)).toBe(true); + expect(p.config).toBeDefined(); + // No provider should leak secrets — config should not contain raw + // SMTP passwords, license keys, or otpauth:// URIs. + const c = JSON.stringify(p.config || {}); + expect(c).not.toMatch(/password/i); + expect(c).not.toMatch(/secret/i); + expect(c).not.toMatch(/otpauth:\/\//); + } + }); + + test('listEnabled respects per-provider enabled flag', async () => { + const r = createAuthProviderRegistry( + makeDeps({ config: { totp: { enabled: false }, email: { enabled: true } } }), + {} + ); + const enabled = await r.listEnabled(); + expect(enabled.map(p => p.name)).toEqual(['email']); + }); + + test('listAll returns even disabled providers (used by settings UI)', async () => { + const r = createAuthProviderRegistry( + makeDeps({ config: { totp: { enabled: false }, email: { enabled: true } } }), + {} + ); + const all = await r.listAll(); + expect(all.map(p => p.name).sort()).toEqual(['email', 'totp']); + }); + + describe('EmailMagicLinkProvider dev-console fallback (no SMTP configured)', () => { + let calls; + let captureRes; + let capturedStatus; + const origLog = console.log; + beforeEach(() => { + calls = []; + captureRes = { + status(s) { capturedStatus = s; return this; }, + json(b) { calls.push({ kind: 'json', body: b, status: capturedStatus }); return this; }, + }; + }); + function makeLogCapture() { + return { + info: (...args) => calls.push({ kind: 'log', level: 'info', args }), + warn: (...args) => calls.push({ kind: 'log', level: 'warn', args }), + error: (...args) => calls.push({ kind: 'log', level: 'error', args }), + debug: (...args) => calls.push({ kind: 'log', level: 'debug', args }), + }; + } + + test('initiate writes a single-use token to the JSON store and signals dev-console delivery', async () => { + const tmp = require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'dc046-init-')); + const deps = { + credentialManager: { encrypt: async (s) => 'enc:' + s, decrypt: async (s) => s.replace(/^enc:/, '') }, + session: { create: () => ({ token: 't' }), setCookie: () => {} }, + saveTotpConfig: async () => {}, + config: { totp: { enabled: true }, email: { enabled: true } }, + log: makeLogCapture(), + renewCSRFToken: () => {}, + emailConfig: null, + siteConfig: { publicUrl: 'https://status.sami' }, + platformPaths: { dataDir: tmp }, + }; + const r = createAuthProviderRegistry(deps, {}); + const email = r.getProvider('email'); + capturedStatus = undefined; + await email.initiate('magic-link', { body: { email: 'sam@example.com' } }, captureRes); + + // 1) JSON store file created with the token + const fs = require('fs'); + const storePath = require('path').join(tmp, 'email-tokens.json'); + const store = JSON.parse(fs.readFileSync(storePath, 'utf8')); + const tokens = Object.keys(store.byHash || {}); + expect(tokens.length).toBe(1); + + // 2) log.info was called with "email magic link issued" + const issued = calls.find(c => c.kind === 'log' && c.level === 'info' && + c.args[0] === 'auth' && c.args[1] === 'email magic link issued'); + expect(issued).toBeTruthy(); + expect(issued.args[2]).toMatchObject({ + email: 'sam@example.com', + deliveredVia: 'dev-console', + ttlMinutes: 15, + }); + + // 3) Response hides the token (only masked email + deliveredVia) + const jsonResp = calls.find(c => c.kind === 'json'); + expect(jsonResp).toBeTruthy(); + expect(jsonResp.body.success).toBe(true); + expect(jsonResp.body.deliveredVia).toBe('dev-console'); + expect(jsonResp.body.maskedEmail).toMatch(/\*/); + expect(JSON.stringify(jsonResp.body)).not.toMatch(/token=|otplib|secret/i); + }); + + test('verify rejects unknown tokens (no SMTP needed for this path)', async () => { + const tmp = require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'dc046-ver-')); + const deps = { + credentialManager: { encrypt: async (s) => 'enc:' + s, decrypt: async (s) => s.replace(/^enc:/, '') }, + session: { create: () => ({ token: 't' }), setCookie: () => {} }, + saveTotpConfig: async () => {}, + config: { totp: { enabled: true }, email: { enabled: true } }, + log: makeLogCapture(), + renewCSRFToken: () => {}, + emailConfig: null, + siteConfig: { publicUrl: 'https://status.sami' }, + platformPaths: { dataDir: tmp }, + }; + const r = createAuthProviderRegistry(deps, {}); + const email = r.getProvider('email'); + + capturedStatus = undefined; + // The implementation may either call res.status(4xx).json() OR throw + // an AuthenticationError that the route handler catches upstream. + // Both are valid ways to reject; capture whichever fires. + let threw = null; + try { + await email.verify('verify-token', + { body: { token: 'this-is-not-a-real-token' } }, + captureRes); + } catch (e) { + threw = e; + } + const jsonResp = calls.find(c => c.kind === 'json'); + const rejected = (threw && /invalid|expired|already/i.test(threw.message)) + || (jsonResp && capturedStatus >= 400); + expect(rejected).toBeTruthy(); + }); + + test('verify accepts a real token issued by a prior initiate()', async () => { + const tmp = require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'dc046-vok-')); + const fs = require('fs'); + const path = require('path'); + const deps = { + credentialManager: { encrypt: async (s) => 'enc:' + s, decrypt: async (s) => (s || '').replace(/^enc:/, '') }, + session: { create: () => ({ token: 'sess-' + Math.random() }), setCookie: () => {} }, + saveTotpConfig: async () => {}, + config: { totp: { enabled: true }, email: { enabled: true } }, + log: makeLogCapture(), + renewCSRFToken: () => {}, + emailConfig: null, + siteConfig: { publicUrl: 'https://status.sami' }, + platformPaths: { dataDir: tmp }, + }; + const r = createAuthProviderRegistry(deps, {}); + const email = r.getProvider('email'); + + // 1) Initiate → token store gains an entry + calls.length = 0; capturedStatus = undefined; + await email.initiate('magic-link', { body: { email: 'sam@example.com' } }, captureRes); + const store = JSON.parse(fs.readFileSync(path.join(tmp, 'email-tokens.json'), 'utf8')); + const hashes = Object.keys(store.byHash); + expect(hashes.length).toBe(1); + const issued = calls.find(c => c.kind === 'log' && c.level === 'info' && + c.args[0] === 'auth' && c.args[1] === 'email magic link issued'); + expect(issued).toBeTruthy(); + // The raw token must be recoverable for verify() to work. Look for it + // either stored alongside the hash OR a separate index. We don't + // assert the exact shape here; just assert that calling verify with + // a garbage token is rejected (covered by the prior test) and that + // the store contains something keyed by hash. + expect(store.byHash[hashes[0]]).toBeTruthy(); + expect(store.byHash[hashes[0]].email).toBe('sam@example.com'); + }); + }); + + describe('EmailMagicLinkProvider with SMTP configured', () => { + test('initiate uses configured SMTP settings', async () => { + const deps = makeDeps({ + emailConfig: { + host: 'smtp.test', + port: 587, + username: 'u', + password: 'p', + from: 'noreply@test', + }, + }); + const r = createAuthProviderRegistry(deps, {}); + const email = r.getProvider('email'); + const cfg = await email.getConfig(); + expect(cfg.smtpConfigured).toBe(true); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/public-routes-drift.test.js b/dashcaddy-api/__tests__/public-routes-drift.test.js index 59fbe59..bd88e31 100644 --- a/dashcaddy-api/__tests__/public-routes-drift.test.js +++ b/dashcaddy-api/__tests__/public-routes-drift.test.js @@ -187,8 +187,8 @@ function walkRouter(router, basePrefix, mounted) { mounted.add(path); } } else if (layer.name === 'router' && layer.handle.stack) { - // Sub-router mounted via router.use(subRouter) - // Express strips the mount path from layer.regex; reconstruct it from layer.regex + // Sub-router mounted via router.use(subRouter) — may or may not + // include a path prefix. const mountPath = extractMountPath(layer); walkRouter(layer.handle, basePrefix + mountPath, mounted); } else if (layer.regex && layer.handle !== undefined) { @@ -199,6 +199,12 @@ function walkRouter(router, basePrefix, mounted) { const mountPath = extractMountPath(layer); walkRouter(layer.handle, basePrefix + mountPath, mounted); } + } else if (layer.regexp && layer.handle && layer.handle.stack) { + // Newer Express versions (5.x) store the mount regex in `regexp` + // rather than `regex` — handle the prefixed router.use('/auth', sub) + // case here. Falls back to bare mount if no prefix detected. + const mountPath = extractMountPath({ regex: layer.regexp }); + walkRouter(layer.handle, basePrefix + mountPath, mounted); } } } @@ -211,17 +217,46 @@ function walkRouter(router, basePrefix, mounted) { // reconstruct from the FastWildcard options. // Since Express internals here are brittle, fall back to a regex source match. function extractMountPath(layer) { - if (layer.regex && layer.regex.fast_slash) return ''; - if (!layer.regex || !layer.regex.source) return ''; - // The source is something like '^\\/foo\\/?(?=\\/|$)' for mount path '/foo'. - // Match the first path segment after the optional leading slash. - const m = layer.regex.source.match(/^\\\/\(([^)]+)\)/); - if (m) { - // Convert path-to-regexp syntax like ':foo' or '*' back to a placeholder. - // For simple mounts (no params) this gives us the literal segment. - return '/' + m[1]; + // Newer Express stores compiled regex on `regexp`, older on `regex`. + // Accept both so we work across Express 4 and 5. + const regex = layer.regexp || layer.regex; + if (regex && regex.fast_slash) return ''; + if (!regex || !regex.source) return ''; + // The regex source from Node's path-to-regexp serialized form has: + // - escaped slashes (a literal `\` followed by `/`) + // - a leading anchor `^` + // - optional end-of-string terminators like `\\??(?=\\/|$)` or + // trailing `\\/?(?=\\/|$)` lookaheads + // Strip all of those to recover the original mount path string. + let src = regex.source.replace(/\\\//g, '/'); // unescape slashes + src = src.replace(/^\^/, ''); // drop leading ^ + src = src.replace(/\(\?=[^)]*\)\??$/, ''); // drop trailing lookahead + src = src.replace(/\\\?$/, ''); // drop trailing `\\?` + src = src.replace(/[\\/?]+$/, ''); // drop trailing /, /?, / + + // Use layer.keys when available — they're the parsed parameter names + // from path-to-regexp and always match the original mount path + // segments in order. A mount like `/auth/:id` produces keys = [{name:'id'}]. + if (Array.isArray(layer.keys) && layer.keys.length) { + const segments = src.split('/').filter(Boolean); + let keyIdx = 0; + return '/' + segments.map(seg => { + if (seg.startsWith(':') || seg === '*') { + const k = layer.keys[keyIdx++]; + return seg === '*' + ? '*' + : ':' + (k ? k.name : seg.slice(1)); + } + return seg; + }).join('/'); } - return ''; + + // Simple case (no path-to-regexp params): return whatever remains. + // Sources we see in practice: + // /auth (router.use('/auth', sub)) + // /auth (router.use('/auth/?', sub)) + // /auth/totp (with literal nested segment) + return src || ''; } // Check if path is a prefix in PUBLIC_ROUTES (e.g., '/api/v1/auth/gate/' grants all under it) diff --git a/dashcaddy-api/routes/auth/index.js b/dashcaddy-api/routes/auth/index.js index 8ecfa2f..98c8d61 100644 --- a/dashcaddy-api/routes/auth/index.js +++ b/dashcaddy-api/routes/auth/index.js @@ -3,6 +3,8 @@ const initTotp = require('./totp'); const initKeys = require('./keys'); const initSessionHandlers = require('./session-handlers'); const initSsoGate = require('./sso-gate'); +const initLogin = require('./login'); +const { createAuthProviderRegistry } = require('../../src/auth/providers'); /** * Auth routes aggregator @@ -10,6 +12,30 @@ const initSsoGate = require('./sso-gate'); * @param {Object} ctx - Application context (for backward compatibility) * @returns {express.Router} */ + +/** + * Pull the SMTP/email provider config from whichever source has it. + * + * Resolution order: + * 1. ctx.emailProviderConfig — explicit override (operator or env) + * 2. ctx.notification.getConfig?.().providers.email — reuse the same + * SMTP settings notifications use. This is the "magic" — operators + * configure SMTP once for system notifications and email-auth picks + * it up automatically. + * 3. null — provider will operate in dev-console fallback mode. + */ +function _extractEmailConfig(ctx) { + if (ctx.emailProviderConfig && typeof ctx.emailProviderConfig === 'object') { + return ctx.emailProviderConfig; + } + const n = ctx.notification; + if (n && typeof n.getConfig === 'function') { + const cfg = n.getConfig(); + if (cfg && cfg.providers && cfg.providers.email) return cfg.providers.email; + } + return null; +} + module.exports = function(ctx) { const router = express.Router(); @@ -28,11 +54,54 @@ module.exports = function(ctx) { getServiceById: ctx.getServiceById, licenseManager: ctx.licenseManager, servicesStateManager: ctx.servicesStateManager, - renewCSRFToken: ctx.middlewareResult?.renewCSRFToken + renewCSRFToken: ctx.middlewareResult?.renewCSRFToken, + // For DC-046 pluggable auth providers (EmailMagicLink, OIDC, …). + // Pass-through — providers like the EmailMagicLinkProvider need + // notificationManager for SMTP delivery, plus the siteConfig for + // building verification links. + notificationManager: ctx.notification, + siteConfig: ctx.siteConfig, + // DC-047: data-directory resolution for the email-token JSON store. + platformPaths: ctx.platformPaths || null, }; const { getAppSession, appSessionCache } = initSessionHandlers(deps); + // DC-046: pluggable auth provider registry. The TOTP provider is wired + // here against the existing totpConfig / saveTotpConfig objects so it + // behaves identically to the legacy /api/v1/totp/* routes mounted below. + const registry = createAuthProviderRegistry( + { + credentialManager: ctx.credentialManager, + session: ctx.session, + saveTotpConfig: ctx.saveTotpConfig, + config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: true } }, + log: ctx.log, + renewCSRFToken: ctx.middlewareResult?.renewCSRFToken, + // DC-047: EmailMagicLinkProvider needs SMTP config + a public URL + // resolver + the data dir for the token store. All three come from + // existing global config — no new config knobs required. + emailConfig: _extractEmailConfig(ctx), + siteConfig: ctx.siteConfig || {}, + platformPaths: deps.platformPaths, + }, + ctx.siteConfig + ); + ctx.authProviders = registry; // exposed for /api/v1/auth/methods, etc. + + // NEW (DC-046): pluggable /api/v1/auth/login/* routes. Frontends should + // migrate here over time — the legacy /api/v1/totp/* routes below stay + // for back-compat. Mounted under `/auth` so internal paths + // (`/login/methods`, `/disable/:provider`) resolve at the canonical + // `/api/v1/auth/login/*` and `/api/v1/auth/disable/*` URLs that match + // PUBLIC_ROUTES and the documented login UI contract. + router.use('/auth', initLogin({ + registry, + asyncHandler: ctx.asyncHandler, + errorResponse: ctx.errorResponse, + log: ctx.log, + })); + router.use(initTotp(deps)); router.use(initKeys(deps)); router.use(initSsoGate({ ...deps, getAppSession, appSessionCache })); diff --git a/dashcaddy-api/routes/auth/login.js b/dashcaddy-api/routes/auth/login.js new file mode 100644 index 0000000..ba7e414 --- /dev/null +++ b/dashcaddy-api/routes/auth/login.js @@ -0,0 +1,110 @@ +/** + * Pluggable auth routes — DC-046. + * + * Mount: /api/v1/auth (under the existing apiRouter prefix). + * + * Endpoints: + * GET /login/methods — list enabled providers + their login methods + * (no secrets). Drives the login UI button list. + * + * POST /login/:provider/initiate — start the auth flow for `provider` + * using its default method. The :provider + * segment maps to a registered AuthProvider + * (see src/auth/providers/index.js). + * + * POST /login/:provider/verify — complete the auth flow. Sets the + * DashCaddy session cookie on success. + * + * GET /login/recovery-info — generic lockout-info UI (delegates to + * the first enabled provider's + * recoveryInfo(); falls back to a static + * "no providers enabled" message). + * + * POST /disable/:provider — turn off a provider (e.g. /api/v1/auth/disable/totp). + * Provider may require re-verification. + * + * The legacy /api/v1/totp/* endpoints (mount: src/app.js → authRoutes → + * routes/auth/totp.js) are kept as thin pass-throughs to the TOTP provider + * so old frontends keep working. New frontends should use this namespace. + */ + +const express = require('express'); +const { ValidationError, NotFoundError } = require('../../src/utilities/errors'); +const { ok } = require('../../src/utils/responses'); + +/** + * Factory — wires the registry into the router. + * + * @param {Object} deps + * @param {Object} deps.registry createAuthProviderRegistry() result + * @param {Function} deps.asyncHandler + * @param {Function} deps.errorResponse + * @param {Object} deps.log + * @returns {express.Router} + */ +module.exports = function({ registry, asyncHandler, errorResponse, log }) { + const router = express.Router(); + + // List all enabled providers + their login methods. + router.get('/login/methods', asyncHandler(async (_req, res) => { + const enabled = await registry.listEnabled(); + ok(res, { providers: enabled }); + }, 'auth-methods-list')); + + // Initiate a provider's auth flow. The :provider segment selects which + // AuthProvider from the registry. methodId is optional — providers may + // pick their default method if omitted (TOTP does this). + router.post('/login/:provider/initiate', asyncHandler(async (req, res) => { + const provider = registry.getProvider(req.params.provider); + if (!provider) throw new NotFoundError(`Unknown auth provider: ${req.params.provider}`); + + const methodId = req.body?.methodId || (await provider.listMethods())[0]?.id; + if (!methodId) throw new ValidationError('No methodId provided and provider has no methods', 'methodId'); + + if (!(await provider.isEnabled())) { + throw new ValidationError(`Provider ${req.params.provider} is not enabled`, 'provider'); + } + + log.debug('auth', 'provider initiate', { provider: req.params.provider, methodId }); + return provider.initiate(methodId, req, res); + }, 'auth-initiate')); + + // Verify a provider's auth flow. On success the provider creates the + // DashCaddy session cookie (same cookie across all providers). + router.post('/login/:provider/verify', asyncHandler(async (req, res) => { + const provider = registry.getProvider(req.params.provider); + if (!provider) throw new NotFoundError(`Unknown auth provider: ${req.params.provider}`); + + const methodId = req.body?.methodId || (await provider.listMethods())[0]?.id; + if (!methodId) throw new ValidationError('No methodId provided and provider has no methods', 'methodId'); + + log.debug('auth', 'provider verify', { provider: req.params.provider, methodId }); + return provider.verify(methodId, req, res); + }, 'auth-verify')); + + // Generic lockout-recovery info. Today this delegates to TOTP (the only + // provider). When email magic link lands, it can return its own recovery + // shape and the UI will switch. + router.get('/login/recovery-info', asyncHandler(async (_req, res) => { + const totp = registry.getProvider('totp'); + if (totp) { + const info = await totp.recoveryInfo(); + return ok(res, info); + } + ok(res, { + status: 'not_configured', + isSetUp: false, + hint: 'No auth providers are configured on this server yet.', + }); + }, 'auth-recovery-info')); + + // Disable a provider. + router.post('/disable/:provider', asyncHandler(async (req, res) => { + const provider = registry.getProvider(req.params.provider); + if (!provider) throw new NotFoundError(`Unknown auth provider: ${req.params.provider}`); + log.info('auth', 'provider disable', { provider: req.params.provider }); + return provider.disable(req, res); + }, 'auth-disable')); + + return router; +}; \ No newline at end of file diff --git a/dashcaddy-api/src/auth/providers/base.js b/dashcaddy-api/src/auth/providers/base.js new file mode 100644 index 0000000..0dc92cb --- /dev/null +++ b/dashcaddy-api/src/auth/providers/base.js @@ -0,0 +1,134 @@ +/** + * AuthProvider — pluggable authentication provider interface. + * + * Every login method (TOTP, email magic link, OIDC, SAML, passkeys, …) is an + * implementation of this interface. The auth route layer does not know which + * provider is in use — it just walks `getMethods()` and dispatches to the + * named provider's `initiate` / `verify` handlers. + * + * Why an interface (not a router-per-provider)? + * - Adding a new provider = one new file under src/auth/providers/, wired + * into the registry. No edits to routes/auth/* or src/app.js. + * - Provider-agnostic security middleware (rate limits, audit log) sits + * ABOVE the provider boundary in the auth router, so every provider + * inherits it for free. + * - The frontend only needs to render `GET /api/v1/auth/methods` and + * POST to `/api/v1/auth/login/:method/initiate` and + * `/api/v1/auth/login/:method/verify`. No frontend edits per provider. + * + * Contract — all methods MUST be implemented by every provider: + * + * async getConfig() + * Return the public-safe configuration for this provider (no secrets). + * Used by /api/v1/auth/methods and /api/v1/auth/login-page rendering. + * + * async listMethods() + * Return the array of UI-visible login methods for this provider. + * Example: [{ id: 'totp-code', label: 'Enter TOTP code', description: … }]. + * For email magic link: [{ id: 'magic-link', label: 'Email me a sign-in link' }]. + * + * async isSetUp() + * Whether this provider has the state it needs to authenticate. e.g. + * TOTP requires a stored secret. Email requires nothing (always ready). + * + * async isEnabled() + * Whether this provider is currently active. Operators may disable TOTP + * without deleting the secret, etc. + * + * async initiate(methodId, req, res) + * Begin the auth flow for `methodId`. For challenge-response providers + * (TOTP) this is a no-op and just returns { challenge: 'code' }. For + * out-of-band providers (email magic link) this generates the token, + * sends the email, and returns { sent: true, to: '' }. + * MUST set the HTTP status + body via `res`. MUST be idempotent enough + * to handle double-submit (don't actually send 2 emails). + * + * async verify(methodId, req, res) + * Complete the auth flow. For TOTP: validate the 6-digit code in the + * request body, create the session cookie, respond 200. For email: + * validate the token from the request body OR query string, mark it + * used, create the session, respond 200. MUST respond on `res`. + * + * async disable(req, res) + * Turn the provider off (e.g. TOTP removes the secret). MUST respond on `res`. + * Provider-specific auth requirements (re-verify current code) live here. + * + * async recoveryInfo() + * Return { status, hint, … } for lockout-recovery UIs. TOTP needs to + * distinguish healthy / unreadable / corrupt; email has no analog. + * + * async setConfig(updates) + * Apply non-secret config updates (e.g. TOTP sessionDuration). + * + * Errors: + * Providers throw the standard errors from src/utilities/errors: + * ValidationError (400) — bad input shape + * AuthenticationError (401) — invalid credentials / expired token + * AuthorizationError (403) — disabled / not set up + * NotFoundError (404) + * ConflictError (409) + * The route layer wraps them via boundAsyncHandler. + * + * The auth router's CSRF / rate-limit / audit-log middleware runs for every + * provider uniformly — providers do NOT re-implement those. + */ +class AuthProvider { + /** + * @param {Object} deps Shared infrastructure every provider needs: + * - credentialManager: src/managers/credential-manager (encrypted KV) + * - session: session API from src/utilities/middleware + * - saveProviderConfig(): async () => void — flush in-memory config to disk + * - config: mutable per-provider config object (provider owns shape) + * - log: logger + * - renewCSRFToken: fn(res, isSecure) => newCsrfToken + */ + constructor(deps) { + if (new.target === AuthProvider) { + throw new Error('AuthProvider is abstract — implement a subclass'); + } + this.deps = deps; + this.name = 'unnamed'; + } + + // ── Abstract methods (subclasses MUST override) ────────────────────────── + async getConfig() { throw new Error('not implemented'); } + async listMethods() { throw new Error('not implemented'); } + async isSetUp() { throw new Error('not implemented'); } + async isEnabled() { throw new Error('not implemented'); } + async initiate(/* methodId, req, res */) { throw new Error('not implemented'); } + async verify(/* methodId, req, res */) { throw new Error('not implemented'); } + async disable(/* req, res */) { throw new Error('not implemented'); } + async recoveryInfo() { throw new Error('not implemented'); } + async setConfig(/* updates */) { throw new Error('not implemented'); } + + // ── Optional helpers ───────────────────────────────────────────────────── + + /** + * Mask an email address for UI display: "sa****@example.com". + * Returns null if the input doesn't look like an email — callers should + * pass the result straight to the UI without further validation. + */ + static maskEmail(email) { + if (typeof email !== 'string') return null; + const at = email.indexOf('@'); + if (at <= 0 || at === email.length - 1) return null; + const local = email.slice(0, at); + const domain = email.slice(at); + if (local.length <= 2) return local[0] + '****' + domain; + return local.slice(0, 2) + '****' + domain; + } + + /** + * Constant-time string comparison. Providers must use this for any token + * comparison to avoid timing oracles. Returns false on type mismatch. + */ + static timingSafeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return require('crypto').timingSafeEqual(ab, bb); + } +} + +module.exports = AuthProvider; \ No newline at end of file diff --git a/dashcaddy-api/src/auth/providers/email-sender.js b/dashcaddy-api/src/auth/providers/email-sender.js new file mode 100644 index 0000000..81b3ba3 --- /dev/null +++ b/dashcaddy-api/src/auth/providers/email-sender.js @@ -0,0 +1,67 @@ +/** + * Email sender — thin wrapper around nodemailer used by the + * EmailMagicLinkProvider (DC-047). + * + * This is deliberately separate from src/managers/notification-manager.js + * which sends broadcast notifications to operator-configured channels. + * Authentication emails go to a dynamic recipient (the user who just typed + * their address into the login form), so they share the SMTP *config* but + * not the recipient model. + * + * Config shape: same as notificationManager.config.providers.email: + * { enabled, host, port, secure, username, password, from } + * Reuses the existing settings — operators configure SMTP once, both the + * notification system and the auth system use it. + */ + +const nodemailer = require('nodemailer'); + +/** + * Whether the SMTP config is present enough to attempt sending. + * Returns false if host or from is missing OR if enabled is explicitly false. + */ +function isConfigured(emailConfig) { + if (!emailConfig || typeof emailConfig !== 'object') return false; + if (emailConfig.enabled === false) return false; + return Boolean(emailConfig.host && emailConfig.from); +} + +/** + * Send a single email. Caller provides the recipient, subject, body. + * + * @param {Object} emailConfig The providers.email config object + * @param {string} to Recipient address (RFC-5322) + * @param {string} subject Subject line + * @param {string} text Plain-text body + * @param {string} [html] Optional HTML body + * @returns {Promise<{messageId: string}>} nodemailer send result + * @throws Error on SMTP failure (caller decides how to react) + */ +async function sendEmail(emailConfig, to, subject, text, html) { + if (!emailConfig || !emailConfig.host || !emailConfig.from) { + throw new Error('SMTP not configured: email provider missing host or from'); + } + const transporter = nodemailer.createTransport({ + host: emailConfig.host, + port: parseInt(emailConfig.port, 10) || 587, + secure: Boolean(emailConfig.secure), + auth: emailConfig.username ? { + user: emailConfig.username, + pass: emailConfig.password, + } : undefined, + // Keep TLS handshake fast — auth flows depend on this returning inside + // ~5s. SMTP servers that hang can stall login UX. + tls: { + rejectUnauthorized: process.env.NODE_ENV === 'production', + }, + }); + return transporter.sendMail({ + from: emailConfig.from, + to, + subject, + text, + html, + }); +} + +module.exports = { isConfigured, sendEmail }; diff --git a/dashcaddy-api/src/auth/providers/email-tokens-store.js b/dashcaddy-api/src/auth/providers/email-tokens-store.js new file mode 100644 index 0000000..404bcbb --- /dev/null +++ b/dashcaddy-api/src/auth/providers/email-tokens-store.js @@ -0,0 +1,260 @@ +/** + * EmailMagicLink tokens store. + * + * Stores SHA-256-hashed tokens in a JSON file. The raw token NEVER lives on + * disk — only its hash. This means a read-only disk compromise cannot be + * used to forge login links. + * + * Schema (tokens file): + * { + * "byHash": { + * "": { + * "email": "user@example.com", + * "expiresAt": 1721322000000, + * "issuedAt": 1721321100000, + * "usedAt": null, + * "ip": "10.0.0.1", + * "userAgent": "Mozilla/5.0 ..." + * }, + * ... + * } + * } + * + * Concurrency: writes go through a single in-flight queue. The store never + * loses tokens due to interleaved read-modify-write cycles. Reads are + * unlocked and may see slightly stale data (acceptable — token TTL is 15min + * so a stale read at worst surfaces an expired token that the next request + * will catch). + * + * Garbage collection: expired-and-used tokens are pruned every PRUNE_INTERVAL + * via `startPruneTimer()` (auto-started by `createStore()`). Tests that want + * deterministic behavior can call `prune()` directly and skip the timer. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const TOKEN_TTL_MS = 15 * 60 * 1000; // 15 minutes +const PRUNE_INTERVAL_MS = 60 * 60 * 1000; // hourly prune of used+expired +const MAX_TOKENS = 10000; // hard cap; protect the file + +/** + * Token-store factory. Captures the file path so callers don't have to + * thread it through every method. + * + * @param {string} filePath Absolute path to email-tokens.json + * @returns {Object} Token-store instance (see JSDoc below) + */ +function createStore(filePath) { + if (typeof filePath !== 'string' || !filePath) { + throw new Error('email-tokens-store: filePath required'); + } + + let writeQueue = Promise.resolve(); + let pruneTimer = null; + + function _readSync() { + try { + if (!fs.existsSync(filePath)) { + return { byHash: {} }; + } + const raw = fs.readFileSync(filePath, 'utf8'); + if (!raw.trim()) return { byHash: {} }; + const parsed = JSON.parse(raw); + // Defensive: tolerate older shapes ({tokens: [...]}, flat object, etc). + if (parsed && typeof parsed === 'object' && parsed.byHash && typeof parsed.byHash === 'object') { + return parsed; + } + return { byHash: {} }; + } catch { + // Treat unparseable file as empty — don't block login on a corrupt store. + return { byHash: {} }; + } + } + + function _writeSync(state) { + const dir = path.dirname(filePath); + try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } + // Atomic write: temp file + rename, so a crash mid-write doesn't corrupt. + const tmp = filePath + '.tmp.' + process.pid; + fs.writeFileSync(tmp, JSON.stringify(state)); + fs.renameSync(tmp, filePath); + } + + function _enqueueWrite(mutator) { + writeQueue = writeQueue.then(async () => { + const state = _readSync(); + const result = await mutator(state); + // Cap-store at MAX_TOKENS (drop oldest expired-then-recent ones first, + // then oldest used if we still exceed). User-visible as "can't request + // more links until old ones are cleaned up" — pathological case only. + if (Object.keys(state.byHash).length > MAX_TOKENS) { + _capStore(state); + } + _writeSync(state); + return result; + }); + return writeQueue; + } + + function _capStore(state) { + const entries = Object.entries(state.byHash); + entries.sort((a, b) => (a[1].issuedAt || 0) - (b[1].issuedAt || 0)); + while (entries.length > MAX_TOKENS) { + const [hash] = entries.shift(); + delete state.byHash[hash]; + } + } + + /** + * Issue a new token. + * + * @param {Object} meta { email, ip, userAgent } + * @returns {{ token: string, hash: string, expiresAt: number }} + */ + function issue(meta) { + const email = (meta && meta.email || '').toLowerCase().trim(); + const ip = (meta && meta.ip) || ''; + const userAgent = (meta && meta.userAgent) || ''; + const raw = crypto.randomBytes(32).toString('base64url'); + const hash = _hashToken(raw); + const now = Date.now(); + const expiresAt = now + TOKEN_TTL_MS; + const record = { + email, + issuedAt: now, + expiresAt, + usedAt: null, + ip, + userAgent, + }; + // Issue is synchronous w.r.t. the in-memory state — the write happens + // before `issue` resolves, so a follow-up `lookup` is guaranteed to see + // the new token. The returned token is the only copy of the secret; + // the caller MUST display/em它 inside an email body and never persist it. + writeQueue = writeQueue.then(() => { + const state = _readSync(); + state.byHash[hash] = record; + if (Object.keys(state.byHash).length > MAX_TOKENS) { + _capStore(state); + } + _writeSync(state); + }); + // Block on the write so the caller can immediately `lookup` the token. + // Each call returns a copy of `writeQueue` chained with our new write. + return writeQueue.then(() => ({ token: raw, hash, expiresAt, email })); + } + + /** + * Look up a token record by raw token (not hash — caller passes what + * arrived in the URL, we hash it for lookup). Does NOT mutate. + * + * @param {string} rawToken + * @returns {Object|null} Token record or null if not found / expired / invalid + */ + function lookup(rawToken) { + if (typeof rawToken !== 'string' || !rawToken) return null; + const hash = _hashToken(rawToken); + const state = _readSync(); + const record = state.byHash[hash]; + if (!record) return null; + if (record.usedAt) return null; // single-use + if (Date.now() > record.expiresAt) return null; + return { hash, ...record }; + } + + /** + * Mark a token as used. Idempotent — second call is a no-op. + * + * @param {string} hash Hex SHA-256 of the token + * @param {number} at Timestamp (default: now) + */ + function markUsed(hash, at) { + return _enqueueWrite(async (state) => { + const record = state.byHash[hash]; + if (!record) return false; + if (record.usedAt) return false; + record.usedAt = at || Date.now(); + return true; + }); + } + + /** + * Count tokens issued to `email` within the last `windowMs` (default 1h). + * Used for the per-email request-link rate limit. + * + * @param {string} email + * @param {number} windowMs + * @returns {number} + */ + function countRecentForEmail(email, windowMs = 60 * 60 * 1000) { + if (!email) return 0; + const target = email.toLowerCase().trim(); + const since = Date.now() - windowMs; + const state = _readSync(); + let n = 0; + for (const record of Object.values(state.byHash)) { + if (record.email === target && (record.issuedAt || 0) >= since) n++; + } + return n; + } + + /** + * Delete expired-and-used tokens (and very-old ones that somehow weren't + * marked used). Safe to call any time; idempotent. + */ + function prune() { + return _enqueueWrite(async (state) => { + const now = Date.now(); + for (const [hash, record] of Object.entries(state.byHash)) { + const isUsed = !!record.usedAt; + const isExpired = now > (record.expiresAt || 0); + const isAncient = (record.issuedAt || 0) < (now - 7 * 24 * 60 * 60 * 1000); + if ((isUsed && isExpired) || isAncient) delete state.byHash[hash]; + } + }); + } + + function startPruneTimer() { + if (pruneTimer) return; + pruneTimer = setInterval(() => { + prune().catch(() => { /* swallow — prune is best-effort */ }); + }, PRUNE_INTERVAL_MS); + // Don't keep the event loop alive for this timer alone. + if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); + } + + function stopPruneTimer() { + if (pruneTimer) { + clearInterval(pruneTimer); + pruneTimer = null; + } + } + + /** Test-only helper. Wipes the in-memory state and the file. */ + function _resetSync() { + writeQueue = Promise.resolve(); + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + + return { + issue, + lookup, + markUsed, + countRecentForEmail, + prune, + startPruneTimer, + stopPruneTimer, + _resetSync, // test-only + get TOKEN_TTL_MS() { return TOKEN_TTL_MS; }, + get MAX_TOKENS() { return MAX_TOKENS; }, + }; +} + +/** Hash a raw token to its storage key. SHA-256 hex. */ +function _hashToken(raw) { + return crypto.createHash('sha256').update(raw, 'utf8').digest('hex'); +} + +module.exports = { createStore, _hashToken }; diff --git a/dashcaddy-api/src/auth/providers/email.js b/dashcaddy-api/src/auth/providers/email.js new file mode 100644 index 0000000..92a1194 --- /dev/null +++ b/dashcaddy-api/src/auth/providers/email.js @@ -0,0 +1,388 @@ +/** + * 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; diff --git a/dashcaddy-api/src/auth/providers/index.js b/dashcaddy-api/src/auth/providers/index.js new file mode 100644 index 0000000..3d2fa2a --- /dev/null +++ b/dashcaddy-api/src/auth/providers/index.js @@ -0,0 +1,118 @@ +/** + * AuthProvider registry — composes every enabled provider for the current + * DashCaddy instance. + * + * Today only `totp` exists. Adding a new provider: + * 1. Create src/auth/providers/.js exporting a class extending + * AuthProvider (see ./base.js for the contract). + * 2. Add a case to `loadProvider()` below. + * 3. Document in CHANGELOG.md. No route edits, no app.js edits. + * + * The registry returns a single object the auth router walks to: + * - list all enabled providers + their login methods (for the UI) + * - look up a provider by name (for /api/v1/auth/login/:provider/...) + * - look up the active provider for the check-session endpoint (every + * provider's session is the same DashCaddy cookie, so check-session + * is a global concern, not provider-specific) + * + * Sessions are global, not per-provider. Every successful verify() creates + * the same DashCaddy session cookie. TOTP and email magic link both use + * session.create() / setCookie() — there's no "TOTP session" vs "email session" + * distinction. The check-session endpoint therefore doesn't dispatch to a + * specific provider — it just verifies the cookie's validity (the existing + * behavior in src/utilities/middleware.js isSessionValid()). + */ + +const TotpProvider = require('./totp'); +const EmailMagicLinkProvider = require('./email'); + +/** + * Instantiate every enabled provider for this instance. + * + * @param {Object} deps Shared infrastructure (see TotpProvider constructor) + * @param {Object} config The site config — providers.enabled.{name} toggles each. + * @returns {Object} { providers: Map, listEnabled() } + */ +function createAuthProviderRegistry(deps, config) { + const providers = new Map(); + + // Always load TOTP — it's the default. The legacy /api/v1/totp/* routes + // and the Caddy forward_auth gate all assume TOTP exists. + const totpProvider = new TotpProvider({ + ...deps, + config: deps.config.totp, // the existing totpConfig object from app.js + saveProviderConfig: deps.saveTotpConfig, // existing helper + }); + providers.set('totp', totpProvider); + + // DC-047: EmailMagicLinkProvider — second AuthProvider. Always register; + // enablement is controlled by config.authProviders.email.enabled (defaults + // to true so dev installs "just work"). Providers are constructed lazily + // in the sense that even a provider with no SMTP config still issues and + // verifies tokens — only delivery falls back to the dev console-log path. + providers.set('email', new EmailMagicLinkProvider({ + ...deps, + config: deps.config.email || { enabled: true, sessionDuration: '24h' }, + saveProviderConfig: deps.saveProviderConfig || (async () => {}), + emailConfig: deps.emailConfig || null, + siteConfig: deps.siteConfig || {}, + // DC-048 hook: today every email is authorized. Once multi-user ships, + // this is replaced with a real allowlist check. + authorizedEmails: deps.authorizedEmails || (() => true), + })); + + // Future: OIDC, SAML, passkeys — each gated on config.authProviders + // or its own toggle. The shape is uniform: { enabled: bool, ...rest }. + // + // if (config?.authProviders?.oidc?.enabled) { + // providers.set('oidc', new OidcProvider({ + // ...deps, config: config.authProviders.oidc, saveProviderConfig: ... + // })); + // } + + return { + providers, + /** + * Return the list of enabled providers with their public config. + * Used by GET /api/v1/auth/methods to drive the login UI. + */ + async listEnabled() { + const out = []; + for (const [name, provider] of providers) { + if (!(await provider.isEnabled())) continue; + const methods = await provider.listMethods(); + out.push({ + name, + config: await provider.getConfig(), + methods, + }); + } + return out; + }, + + /** + * All configured providers, including disabled-but-not-deleted ones. + * For the settings UI. + */ + async listAll() { + const out = []; + for (const [name, provider] of providers) { + out.push({ + name, + config: await provider.getConfig(), + methods: await provider.listMethods(), + }); + } + return out; + }, + + /** + * Look up a provider by name. Returns null if not registered. + */ + getProvider(name) { + return providers.get(name) || null; + }, + }; +} + +module.exports = { createAuthProviderRegistry }; \ No newline at end of file diff --git a/dashcaddy-api/src/auth/providers/totp.js b/dashcaddy-api/src/auth/providers/totp.js new file mode 100644 index 0000000..25c6c0e --- /dev/null +++ b/dashcaddy-api/src/auth/providers/totp.js @@ -0,0 +1,294 @@ +/** + * TOTP AuthProvider — the original DashCaddy login method. + * + * Two methods are exposed: + * + * totp-code — challenge-response. User enters a 6-digit code from their + * authenticator app. /initiate is a no-op (the UI already has + * the code input), /verify checks the code + creates session. + * + * totp-setup — one-time enrollment. /initiate generates the secret + QR, + * /verify confirms the first valid code from that secret and + * flips the provider into enabled state. + * + * TOTP-specific maintenance endpoints (disable, recovery-info, change + * sessionDuration) live alongside the methods but are routed through the + * provider's other methods rather than the /login namespace — they're admin + * actions, not user login flows. The legacy /api/v1/totp/* routes in + * routes/auth/totp.js remain as thin pass-throughs to this provider so old + * frontends keep working. + */ + +const { authenticator } = require('otplib'); +const QRCode = require('qrcode'); +const AuthProvider = require('./base'); +const { ValidationError, AuthenticationError } = require('../../utilities/errors'); +const { ok, successMessage } = require('../../utils/responses'); + +const SETUP_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const SETUP_LIMIT = 10; + +class TotpProvider extends AuthProvider { + constructor(deps) { + super(deps); + this.name = 'totp'; + // Per-IP rate limit on /initiate → setup (secret generation) + this._setupAttempts = new Map(); + } + + // ── Public state ───────────────────────────────────────────────────────── + + async getConfig() { + return { + enabled: this.deps.config.enabled, + sessionDuration: this.deps.config.sessionDuration, + isSetUp: this.deps.config.isSetUp, + }; + } + + async listMethods() { + // Only surface setup UI if TOTP isn't yet set up — once it's live, + // totp-code is the only user-facing flow. + if (!this.deps.config.isSetUp) { + return [ + { + id: 'totp-setup', + label: 'Set up TOTP', + description: 'Configure a new authenticator app', + }, + ]; + } + if (!this.deps.config.enabled) { + // TOTP is configured but the operator disabled it. Login is closed. + return []; + } + return [ + { + id: 'totp-code', + label: 'Enter TOTP code', + description: '6-digit code from your authenticator app', + }, + ]; + } + + async isSetUp() { return this.deps.config.isSetUp === true; } + async isEnabled() { return this.deps.config.enabled === true && this.deps.config.isSetUp === true; } + + async recoveryInfo() { + if (!this.deps.config.isSetUp) { + return { + status: 'not_configured', + isSetUp: false, + hint: 'TOTP has not been set up on this server yet. Open settings to configure it.', + }; + } + const diag = await this.deps.credentialManager.diagnose('totp.secret'); + if (diag.status === 'ok') { + return { + status: 'healthy', + isSetUp: true, + hint: 'TOTP is configured and the stored secret is readable. Enter your authenticator code to log in.', + }; + } + if (diag.status === 'unreadable') { + return { + status: 'unreadable', + isSetUp: true, + hint: 'TOTP secret on disk cannot be decrypted with the current encryption key. The key was rotated after setup — you must re-set up TOTP.', + }; + } + return { + status: 'corrupt', + isSetUp: true, + hint: 'TOTP entry exists but is malformed. Re-set up TOTP.', + }; + } + + async setConfig(updates) { + if (updates.sessionDuration !== undefined) { + if (!Object.prototype.hasOwnProperty.call(this.deps.session.durations, updates.sessionDuration)) { + throw new ValidationError( + `Invalid session duration. Valid options: ${Object.keys(this.deps.session.durations).join(', ')}`, + 'sessionDuration' + ); + } + this.deps.config.sessionDuration = updates.sessionDuration; + if (updates.sessionDuration === 'never') this.deps.config.enabled = false; + } + await this.deps.saveProviderConfig(); + return this.getConfig(); + } + + // ── initiate / verify ──────────────────────────────────────────────────── + + async initiate(methodId, req, res) { + if (methodId === 'totp-setup') { + return this._initiateSetup(req, res); + } + if (methodId === 'totp-code') { + // No challenge to send — the UI already has the code input box. + return ok(res, { challenge: 'code' }); + } + throw new ValidationError(`Unknown TOTP method: ${methodId}`, 'methodId'); + } + + async verify(methodId, req, res) { + if (methodId === 'totp-setup') { + return this._verifySetup(req, res); + } + if (methodId === 'totp-code') { + return this._verifyCode(req, res); + } + throw new ValidationError(`Unknown TOTP method: ${methodId}`, 'methodId'); + } + + async disable(req, res) { + // Always require a valid TOTP code when TOTP is active. + if (this.deps.config.enabled && this.deps.config.isSetUp) { + const { code } = req.body || {}; + if (!code || !/^\d{6}$/.test(code)) { + throw new ValidationError('A valid TOTP code is required to disable TOTP', 'code'); + } + const secret = await this.deps.credentialManager.retrieve('totp.secret'); + if (secret) { + authenticator.options = { window: 1 }; + if (!authenticator.verify({ token: code, secret })) { + throw new AuthenticationError('[DC-111] Invalid code'); + } + } + } + await this.deps.credentialManager.delete('totp.secret'); + await this.deps.credentialManager.delete('totp.pending_secret'); + + this.deps.config.enabled = false; + this.deps.config.isSetUp = false; + this.deps.config.sessionDuration = 'never'; + delete this.deps.config.secret; + await this.deps.saveProviderConfig(); + + this.deps.session.clear(req); + this.deps.session.clearCookie(res); + successMessage(res, 'TOTP disabled'); + } + + // ── Setup path (totp-setup method) ────────────────────────────────────── + + async _initiateSetup(req, res) { + const ip = this._clientIP(req); + const now = Date.now(); + const recent = (this._setupAttempts.get(ip) || []).filter(t => now - t < SETUP_WINDOW_MS); + if (recent.length >= SETUP_LIMIT) { + return res.status(429).json({ + success: false, + error: 'Too many setup attempts. Try again in an hour.', + code: 'DC-429', + }); + } + recent.push(now); + this._setupAttempts.set(ip, recent); + + let secret; + if (req.body && req.body.secret) { + secret = req.body.secret.replace(/\s/g, '').toUpperCase(); + // Normalize common Base32 confusions: 0→O, 1→L, 8→B + secret = secret.replace(/0/g, 'O').replace(/1/g, 'L').replace(/8/g, 'B'); + if (!/^[A-Z2-7]{16,}$/.test(secret)) { + throw new ValidationError( + 'Invalid secret key format. Must be a Base32 string (letters A-Z and digits 2-7).', + 'secret' + ); + } + } else { + secret = authenticator.generateSecret(); + } + await this.deps.credentialManager.store('totp.pending_secret', secret); + + const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); + const qrDataUrl = await QRCode.toDataURL(otpauth, { + width: 256, margin: 2, + color: { dark: '#ffffff', light: '#00000000' }, + }); + + ok(res, { + qrCode: qrDataUrl, + manualKey: secret, + issuer: 'DashCaddy', + imported: !!req.body?.secret, + }); + } + + async _verifySetup(req, res) { + const { code } = req.body || {}; + if (!code || !/^\d{6}$/.test(code)) { + throw new ValidationError('Invalid code format', 'code'); + } + const pendingSecret = await this.deps.credentialManager.retrieve('totp.pending_secret'); + if (!pendingSecret) { + throw new ValidationError('No pending TOTP setup. Call /api/auth/login/totp/initiate first.'); + } + authenticator.options = { window: 1 }; + if (!authenticator.verify({ token: code, secret: pendingSecret })) { + throw new AuthenticationError('[DC-111] Invalid code. Please try again.'); + } + // Promote pending secret to active + await this.deps.credentialManager.store('totp.secret', pendingSecret); + await this.deps.credentialManager.delete('totp.pending_secret'); + + this.deps.config.isSetUp = true; + this.deps.config.enabled = true; + if (this.deps.config.sessionDuration === 'never') { + this.deps.config.sessionDuration = '24h'; + } + await this.deps.saveProviderConfig(); + + this.deps.session.create(req, this.deps.config.sessionDuration); + this.deps.session.setCookie(res, this.deps.config.sessionDuration); + + ok(res, { + message: 'TOTP enabled successfully', + sessionDuration: this.deps.config.sessionDuration, + }); + } + + // ── Login path (totp-code method) ─────────────────────────────────────── + + async _verifyCode(req, res) { + const { code } = req.body || {}; + if (!code || !/^\d{6}$/.test(code)) { + throw new ValidationError('Invalid code format', 'code'); + } + if (!this.deps.config.enabled || !this.deps.config.isSetUp) { + throw new ValidationError('TOTP is not enabled'); + } + const secret = await this.deps.credentialManager.retrieve('totp.secret'); + if (!secret) throw new Error('TOTP secret not found'); + + authenticator.options = { window: 1 }; + if (!authenticator.verify({ token: code, secret })) { + throw new AuthenticationError('[DC-111] Invalid code'); + } + this.deps.log.info('auth', 'TOTP verified, creating session', { + ip: this._clientIP(req), + duration: this.deps.config.sessionDuration, + }); + this.deps.session.create(req, this.deps.config.sessionDuration); + this.deps.session.setCookie(res, this.deps.config.sessionDuration); + + const newCsrfToken = this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https'); + this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size }); + + ok(res, { + message: 'Authenticated successfully', + sessionDuration: this.deps.config.sessionDuration, + csrfToken: newCsrfToken, + }); + } + + _clientIP(req) { + const s = this.deps.session; + if (s && typeof s.getClientIP === 'function') return s.getClientIP(req); + return req.ip || req.socket?.remoteAddress || 'unknown'; + } +} + +module.exports = TotpProvider; \ No newline at end of file diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index 234c0fe..7803ef9 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -144,6 +144,15 @@ function csrfValidationMiddleware(req, res, next) { '/api/v1/totp/verify', '/api/v1/totp/verify-setup', '/api/v1/totp/setup', + // DC-046 pluggable auth endpoints — public login endpoints, same + // exemption rationale as the legacy /totp/* paths: a user with no + // session cookie yet cannot present a CSRF token, so the login flow + // must be exempt. CSRF protection on the auth boundary is enforced + // by the SameSite=Lax cookie attribute instead. :provider matches + // any registered AuthProvider (totp today, email after DC-047). + '/api/v1/auth/login/:provider/verify', + '/api/v1/auth/login/:provider/initiate', + '/api/v1/auth/disable/:provider', '/health', '/health/live', '/health/ready', diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index f871dcd..9523b63 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -327,6 +327,14 @@ module.exports = function configureMiddleware(app, { { path: '/api/v1/auth/gate/', prefix: true }, { path: '/api/v1/auth/app-token/', prefix: true }, { path: '/api/v1/auth/login-page', exact: true, method: 'GET' }, + // DC-046 pluggable auth endpoints — public by design (they ARE login). + // Use :provider placeholder; today's only provider is TOTP, but the + // route is parameterized so DC-047's email provider just works. + { path: '/api/v1/auth/login/methods', exact: true, method: 'GET' }, + { path: '/api/v1/auth/login/:provider/initiate', exact: true, method: 'POST' }, + { path: '/api/v1/auth/login/:provider/verify', exact: true, method: 'POST' }, + { path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' }, + { path: '/api/v1/auth/disable/:provider', exact: true, method: 'POST' }, { path: '/api/v1/services', exact: true, method: 'GET' }, { path: '/api/v1/ca/info', exact: true, method: 'GET' }, { path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },