/** * 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;