Domain=.sami cookies are silently rejected by real browsers - .sami is an unregistered custom TLD, so browsers treat sami itself as the effective public suffix and refuse to set a cookie scoped to it (the same rule that stops a site from setting a supercookie for all of .com). Confirmed via curl verbose (cookie dropped, domain must not set cookies for sami) and via the Firefox console on the actual device (Cookie rejected for invalid domain) for the same cookie. The session cookie set on status.sami after TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami no matter how the cookie itself was built - prior fixes tonight left this mechanism untouched, which is why the loop persisted. Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The status.sami frontend appends it to the redirect URL when bouncing the user back to a gated service. That services login page exchanges the token via the new public GET /api/v1/auth/sso-exchange for a host-only session cookie (no Domain attribute - always accepted). isSessionValid only checks the cookies HMAC signature, never its Domain, so the host-only cookie validates identically to the cross-domain one on every existing check with zero changes to that logic.
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
const express = require('express');
|
|
const { ValidationError, AuthenticationError } = require('../../src/utilities/errors');
|
|
const { ok, successMessage } = require('../../src/utils/responses');
|
|
|
|
/**
|
|
* Auth TOTP routes factory
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {Object} deps.authManager - Auth manager
|
|
* @param {Object} deps.credentialManager - Credential manager
|
|
* @param {Object} deps.totpConfig - TOTP configuration
|
|
* @param {Function} deps.saveTotpConfig - Save TOTP config helper
|
|
* @param {Object} deps.session - Session context
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @param {Function} deps.errorResponse - Error response helper
|
|
* @param {Object} deps.log - Logger instance
|
|
* @returns {express.Router}
|
|
*/
|
|
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
|
|
const router = express.Router();
|
|
|
|
// Ctx shim for backward compatibility
|
|
const ctx = {
|
|
credentialManager,
|
|
totpConfig,
|
|
saveTotpConfig,
|
|
session
|
|
};
|
|
|
|
// Get current TOTP config (public route)
|
|
router.get('/totp/config', asyncHandler(async (req, res) => {
|
|
ok(res, {
|
|
config: {
|
|
enabled: ctx.totpConfig.enabled,
|
|
sessionDuration: ctx.totpConfig.sessionDuration,
|
|
isSetUp: ctx.totpConfig.isSetUp
|
|
}
|
|
});
|
|
}, 'totp-config-get'));
|
|
|
|
// Recovery diagnostic.
|
|
//
|
|
// Returns information a locked-out user needs to choose a recovery path:
|
|
// - whether TOTP is configured at all (isSetUp)
|
|
// - whether the stored secret is readable by the current encryption key
|
|
// - a human-readable hint matching the situation
|
|
//
|
|
// Status values:
|
|
// 'not_configured' — no TOTP setup yet, user should set it up
|
|
// 'healthy' — secret present and decryptable, normal login
|
|
// 'unreadable' — secret on disk but can't decrypt (key rotated)
|
|
// 'corrupt' — entry exists but value is malformed
|
|
//
|
|
// This route never returns the secret itself — only metadata about it.
|
|
// AUTH GATE: requires a valid session. Was previously public, which let
|
|
// unauthenticated attackers probe TOTP state on a target server.
|
|
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
|
if (!ctx.session.isValid(req)) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: '[DC-110] Authentication required',
|
|
code: 'DC-401'
|
|
});
|
|
}
|
|
if (!ctx.totpConfig.isSetUp) {
|
|
return res.json({
|
|
success: true,
|
|
status: 'not_configured',
|
|
isSetUp: false,
|
|
hint: 'TOTP has not been set up on this server yet. Open settings to configure it.'
|
|
});
|
|
}
|
|
|
|
const diag = await ctx.credentialManager.diagnose('totp.secret');
|
|
if (diag.status === 'ok') {
|
|
return res.json({
|
|
success: true,
|
|
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 res.json({
|
|
success: true,
|
|
status: 'unreadable',
|
|
isSetUp: true,
|
|
hint: 'Your stored TOTP secret is on disk but cannot be decrypted — this usually means the encryption key changed during an upgrade. ' +
|
|
'If you saved your Base32 secret when you first set up TOTP, paste it below to restore access. ' +
|
|
'Otherwise you will need SSH access to the server to recover or rotate the key.'
|
|
});
|
|
}
|
|
if (diag.status === 'missing') {
|
|
// Config says isSetUp:true but no secret in store — corrupted config state
|
|
return res.json({
|
|
success: true,
|
|
status: 'corrupt',
|
|
isSetUp: true,
|
|
hint: 'TOTP is marked as configured but the secret is missing. Set up TOTP again with a fresh secret.'
|
|
});
|
|
}
|
|
return res.json({
|
|
success: true,
|
|
status: 'corrupt',
|
|
isSetUp: true,
|
|
hint: 'TOTP storage is in an unexpected state. ' + (diag.error || '')
|
|
});
|
|
}, 'totp-recovery-info'));
|
|
|
|
// Rate limiter for /totp/setup — prevents QR endpoint abuse / secret enumeration.
|
|
// Per-IP sliding window. Defaults: 3 attempts per hour.
|
|
const _setupAttempts = router._setupAttempts || (router._setupAttempts = new Map());
|
|
const SETUP_LIMIT = 3;
|
|
const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|
|
|
// Generate new TOTP secret + QR code
|
|
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
|
const ip = (ctx.session.getClientIP ? ctx.session.getClientIP(req) : (req.ip || req.socket?.remoteAddress || 'unknown'));
|
|
const now = Date.now();
|
|
const recent = (_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);
|
|
_setupAttempts.set(ip, recent);
|
|
|
|
const { authenticator } = require('otplib');
|
|
const QRCode = require('qrcode');
|
|
|
|
// Accept user-provided secret or generate a new one
|
|
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 ctx.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 });
|
|
}, 'totp-setup'));
|
|
|
|
// Verify first code to confirm setup, then activate TOTP
|
|
router.post('/totp/verify-setup', asyncHandler(async (req, res) => {
|
|
const { authenticator } = require('otplib');
|
|
const { code } = req.body;
|
|
|
|
if (!code || !/^\d{6}$/.test(code)) {
|
|
throw new ValidationError('Invalid code format', 'code');
|
|
}
|
|
|
|
const pendingSecret = await ctx.credentialManager.retrieve('totp.pending_secret');
|
|
if (!pendingSecret) {
|
|
throw new ValidationError('No pending TOTP setup. Call /api/totp/setup 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 ctx.credentialManager.store('totp.secret', pendingSecret);
|
|
await ctx.credentialManager.delete('totp.pending_secret');
|
|
|
|
ctx.totpConfig.isSetUp = true;
|
|
ctx.totpConfig.enabled = true;
|
|
if (ctx.totpConfig.sessionDuration === 'never') {
|
|
ctx.totpConfig.sessionDuration = '24h';
|
|
}
|
|
await ctx.saveTotpConfig();
|
|
|
|
// Set session so user doesn't get locked out immediately
|
|
ctx.session.create(req, ctx.totpConfig.sessionDuration);
|
|
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
|
|
|
|
ok(res, { message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration });
|
|
}, 'totp-verify-setup'));
|
|
|
|
// Login: verify TOTP code and set session cookie
|
|
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
|
const { authenticator } = require('otplib');
|
|
const { code } = req.body;
|
|
|
|
if (!code || !/^\d{6}$/.test(code)) {
|
|
throw new ValidationError('Invalid code format', 'code');
|
|
}
|
|
|
|
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
|
throw new ValidationError('TOTP is not enabled');
|
|
}
|
|
|
|
const secret = await ctx.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');
|
|
}
|
|
|
|
log.info('auth', 'TOTP verified, creating session', { ip: ctx.session.getClientIP(req), duration: ctx.totpConfig.sessionDuration });
|
|
ctx.session.create(req, ctx.totpConfig.sessionDuration);
|
|
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
|
|
|
|
// Rotate CSRF token for the new session
|
|
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
|
|
|
|
// Cross-subdomain SSO handoff token (see middleware.js "Cross-subdomain
|
|
// SSO token handoff" for why): the Domain=.sami cookie set above is
|
|
// silently dropped by real browsers on any OTHER *.sami subdomain, so
|
|
// status.sami's login-page frontend appends this token to the redirect
|
|
// URL when bouncing the user back to a gated service. That service's
|
|
// login page exchanges it via /auth/sso-exchange for its own host-only
|
|
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
|
const ssoToken = ctx.session.createHandoffToken();
|
|
|
|
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
|
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
|
}, 'totp-verify'));
|
|
|
|
// Check session validity (used by Caddy forward_auth)
|
|
router.get('/totp/check-session', asyncHandler(async (req, res) => {
|
|
// Never cache session checks — stale cached 200s cause auth loops
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
|
|
// Bypass REMOVED for security: the previous code returned authenticated:true
|
|
// whenever totpConfig.enabled was false or sessionDuration was 'never'. That
|
|
// allowed anyone reaching the API to bypass auth entirely. The only safe
|
|
// behavior is to require a valid session OR to throw AuthenticationError.
|
|
// Operators wanting development convenience should enable TOTP locally or
|
|
// bind the service to 127.0.0.1 only.
|
|
if (!ctx.totpConfig.enabled) {
|
|
throw new AuthenticationError('[DC-110] TOTP protection required');
|
|
}
|
|
|
|
const valid = ctx.session.isValid(req);
|
|
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
|
|
if (valid) {
|
|
// Response contract: { success: true, authenticated: true } — login-page
|
|
// consumer in /api/v1/auth/login-page reads `if(!st.success||!st.authenticated)`
|
|
// and would otherwise redirect valid sessions to status.sami in a TOTP loop.
|
|
return ok(res, { authenticated: true });
|
|
}
|
|
|
|
throw new AuthenticationError('Session expired or invalid');
|
|
}, 'totp-check-session'));
|
|
|
|
// Disable TOTP
|
|
router.post('/totp/disable', asyncHandler(async (req, res) => {
|
|
const { code } = req.body;
|
|
|
|
// Always require a valid TOTP code when TOTP is active
|
|
if (ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
|
|
if (!code || !/^\d{6}$/.test(code)) {
|
|
throw new ValidationError('A valid TOTP code is required to disable TOTP', 'code');
|
|
}
|
|
const { authenticator } = require('otplib');
|
|
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
|
if (secret) {
|
|
authenticator.options = { window: 1 };
|
|
if (!authenticator.verify({ token: code, secret })) {
|
|
throw new AuthenticationError('[DC-111] Invalid code');
|
|
}
|
|
}
|
|
}
|
|
|
|
await ctx.credentialManager.delete('totp.secret');
|
|
await ctx.credentialManager.delete('totp.pending_secret');
|
|
|
|
ctx.totpConfig.enabled = false;
|
|
ctx.totpConfig.isSetUp = false;
|
|
ctx.totpConfig.sessionDuration = 'never';
|
|
delete ctx.totpConfig.secret; // Remove backup
|
|
await ctx.saveTotpConfig();
|
|
|
|
ctx.session.clear(req);
|
|
ctx.session.clearCookie(res);
|
|
successMessage(res, 'TOTP disabled');
|
|
}, 'totp-disable'));
|
|
|
|
// Update TOTP settings (session duration)
|
|
router.post('/totp/config', asyncHandler(async (req, res) => {
|
|
const { sessionDuration } = req.body;
|
|
|
|
if (sessionDuration && !Object.prototype.hasOwnProperty.call(ctx.session.durations, sessionDuration)) {
|
|
throw new ValidationError(`Invalid session duration. Valid options: ${Object.keys(ctx.session.durations).join(', ')}`, 'sessionDuration');
|
|
}
|
|
|
|
if (sessionDuration) {
|
|
ctx.totpConfig.sessionDuration = sessionDuration;
|
|
if (sessionDuration === 'never') {
|
|
ctx.totpConfig.enabled = false;
|
|
}
|
|
}
|
|
|
|
await ctx.saveTotpConfig();
|
|
ok(res, {
|
|
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
|
|
});
|
|
}, 'totp-config'));
|
|
|
|
return router;
|
|
};
|