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.
294 lines
10 KiB
JavaScript
294 lines
10 KiB
JavaScript
/**
|
|
* 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; |