pediatric-ai-scribe-v3/src/routes/auth.js

697 lines
37 KiB
JavaScript

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const passwords = require('../utils/passwords');
const jwt = require('jsonwebtoken');
const { isMobileClient } = require('../utils/platform');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const crypto = require('crypto');
const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
var logger = require('../utils/logger');
// Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent)
async function checkPwnedPassword(password) {
try {
var sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
var prefix = sha1.substring(0, 5);
var suffix = sha1.substring(5);
var resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, {
headers: { 'User-Agent': 'PediatricAIScribe-PasswordCheck' }
});
if (!resp.ok) return 0;
var text = await resp.text();
var match = text.split('\n').find(function(line) { return line.startsWith(suffix); });
return match ? parseInt(match.split(':')[1]) : 0;
} catch (e) { return 0; } // fail open — don't block registration if HIBP is down
}
// Public endpoint — check password before form submission
router.post('/check-password', async (req, res) => {
var { password } = req.body;
if (!password) return res.json({ breached: false });
var count = await checkPwnedPassword(password);
res.json({ breached: count > 0, count: count });
});
// ── Cookie helper ─────────────────────────────────────────────
// Sets the JWT as a secure, httpOnly cookie so JS cannot read it.
// maxAge is a safety net — real timeout is 24h sliding via middleware.
function setAuthCookie(res, token) {
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
res.cookie('ped_auth', token, {
httpOnly: true,
secure: !!isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days absolute; middleware enforces 24h sliding idle
path: '/'
});
}
// Issue a JWT whose absolute expiry matches the client platform:
// web → 30 days (sliding 24h idle handled in middleware)
// mobile → 365 days (persistent, token sits in Keychain / Keystore)
function signAuthToken(userId, req) {
var expiresIn = isMobileClient(req) ? '365d' : '30d';
return jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: expiresIn });
}
function clearAuthCookie(res) {
res.clearCookie('ped_auth', { path: '/' });
}
// ============================================================
// HTML HELPERS
// ============================================================
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function safeAppUrl() {
return escHtml((process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''));
}
// ============================================================
// EMAIL TEMPLATES — markdown-style, Resend/Linear aesthetic
// Plain, spacious, no decorative chrome. Reads like a document.
// ============================================================
function emailWrapper(body) {
var siteName = process.env.SITE_NAME || 'Pediatric AI Scribe';
var F = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>${escHtml(siteName)}</title>
<style>
body,table,td,p,a{font-family:${F};}
@media only screen and (max-width:600px){
.outer{padding:24px 16px !important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#ffffff;-webkit-text-size-adjust:100%;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="outer" align="center" style="padding:48px 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;">
<!-- Wordmark -->
<tr><td style="padding-bottom:32px;border-bottom:1px solid #e5e7eb;">
<p style="margin:0;font-size:14px;font-weight:600;color:#111827;letter-spacing:-0.1px;">${escHtml(siteName)}</p>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px 0;">
${body}
</td></tr>
<!-- Footer -->
<tr><td style="padding-top:24px;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.7;">
${escHtml(siteName)} &mdash; AI-powered clinical documentation<br>
If you didn&rsquo;t request this, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function btnHtml(href, label) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
<tr><td style="border-radius:5px;background:#111827;">
<a href="${escHtml(href)}" target="_blank"
style="display:inline-block;color:#ffffff;padding:11px 22px;border-radius:5px;
text-decoration:none;font-weight:500;font-size:14px;line-height:1;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">${escHtml(label)}</a>
</td></tr>
</table>`;
}
function linkFallback(url) {
return `<p style="margin:16px 0 4px;font-size:13px;color:#6b7280;line-height:1.5;">If the button doesn&rsquo;t work, copy this link:</p>
<p style="margin:0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;
font-size:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;
word-break:break-all;color:#374151;line-height:1.6;">${escHtml(url)}</p>`;
}
// Email helper — DB settings override env vars
async function getSmtpTransport() {
var nodemailer = require('nodemailer');
var host = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
if (!host) return null;
var port = parseInt(await db.getSetting('smtp.port').catch(function() { return null; }) || process.env.SMTP_PORT || '587', 10);
var user = await db.getSetting('smtp.user').catch(function() { return null; }) || process.env.SMTP_USER || '';
var pass = await db.getSetting('smtp.pass').catch(function() { return null; }) || process.env.SMTP_PASS || '';
var from = await db.getSetting('smtp.from').catch(function() { return null; }) || process.env.SMTP_FROM || user;
var secure = (await db.getSetting('smtp.secure').catch(function() { return null; }) || process.env.SMTP_SECURE || 'false') === 'true';
return { transport: nodemailer.createTransport({ host: host, port: port, secure: secure, auth: user ? { user: user, pass: pass } : undefined }), from: from };
}
async function sendEmail(to, subject, html) {
try {
var smtp = await getSmtpTransport();
if (!smtp) {
console.log('[Email] SMTP not configured. Would send to:', to);
return false;
}
await smtp.transport.sendMail({ from: smtp.from, to: to, subject: subject, html: html });
return true;
} catch (err) {
console.error('[Email] Failed:', err.message);
return false;
}
}
// ============================================================
// REGISTER (checks if registration is enabled)
// ============================================================
router.post('/register', async (req, res) => {
try {
var regEnabled = await db.getSetting('registration_enabled');
if (regEnabled === 'false') {
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
}
var { email, password, name, turnstileToken } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification challenge' });
var turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var turnstileData = await turnstileRes.json();
if (!turnstileData.success) {
console.error('[Auth] Turnstile verification failed:', turnstileData['error-codes']);
return res.status(400).json({ error: 'Bot verification failed. Please try again.' });
}
}
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) return res.status(400).json({ error: 'Email already registered' });
var hash = await passwords.hash(password);
var verifyToken = crypto.randomBytes(32).toString('hex');
var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
var result = await db.run(
'INSERT INTO users (email, password, name, role, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, ?, false)',
[email.toLowerCase(), hash, name, role, verifyToken, verifyExpires]
);
var userId = result.lastInsertRowid;
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
var verifySubject = await db.getSetting('email.verify.subject') || 'Verify your Pediatric AI Scribe account';
var verifyBody = await db.getSetting('email.verify.body') || 'Great to have you on Pediatric AI Scribe. Please verify your email address by clicking the button below.';
await sendEmail(email, verifySubject, emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Welcome aboard, ${escHtml(name)}!</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(verifyBody).replace(/\n/g, '<br>')}</p>
${btnHtml(verifyUrl, 'Verify My Email')}
${linkFallback(verifyUrl)}
<p style="color:#9ca3af;font-size:11px;margin:16px 0 0;">This link expires in 24 hours.</p>`
));
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' });
notifyNewRegistration(email, name);
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
if (!smtpHost) {
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
var token = signAuthToken(userId, req);
var regSessionId = generateSessionId();
try {
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
[regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
} catch (e) { /* table may not exist yet */ }
setAuthCookie(res, token);
return res.json({
success: true, token: token, sessionId: regSessionId,
user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true },
message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).'
});
}
res.json({ success: true, needsVerification: true, message: 'Check your email for verification link.' + (role === 'admin' ? ' You are the first user and have admin privileges.' : '') });
} catch (err) {
console.error('[Auth] Register error:', err.message);
res.status(500).json({ error: 'Registration failed. Please try again.' });
}
});
// ============================================================
// VERIFY EMAIL
// ============================================================
router.get('/verify-email', async (req, res) => {
try {
var token = req.query.token;
if (!token) return res.status(400).send('Missing token');
var user = await db.get('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?', [token, Date.now()]);
if (!user) {
return res.send('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Link Expired</title></head><body style="margin:0;padding:40px 24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif;background:#fafafa;text-align:center;"><p style="font-size:15px;font-weight:600;color:#111827;margin:0 0 8px;">Link expired or invalid</p><p style="color:#6b7280;font-size:14px;margin:0 0 24px;">This verification link has expired. Request a new one from the app.</p><a href="' + safeAppUrl() + '" style="display:inline-block;background:#111827;color:#fff;padding:10px 22px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">Go to app</a></body></html>');
}
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
res.send('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Email Verified</title></head><body style="margin:0;padding:40px 24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif;background:#fafafa;text-align:center;"><p style="font-size:15px;font-weight:600;color:#111827;margin:0 0 8px;">Email verified</p><p style="color:#6b7280;font-size:14px;margin:0 0 24px;">Welcome, ' + escHtml(user.name) + '. You\'re all set.</p><a href="' + safeAppUrl() + '" style="display:inline-block;background:#111827;color:#fff;padding:10px 22px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">Open app</a></body></html>');
} catch (err) { console.error('[Auth] Verify error:', err.message); res.status(500).send('Verification failed. Please try again.'); }
});
// RESEND VERIFICATION
router.post('/resend-verification', async (req, res) => {
try {
var user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
if (!user) return res.json({ success: true, message: 'If account exists, verification email sent' });
if (user.email_verified) return res.json({ success: true, message: 'Already verified.' });
var verifyToken = crypto.randomBytes(32).toString('hex');
await db.run('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?', [verifyToken, Date.now() + 86400000, user.id]);
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
await sendEmail(req.body.email, 'Verify your email — Pediatric AI Scribe', emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Verify your email</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">Here's a fresh verification link for your account. Click below to confirm your email and get started.</p>
${btnHtml(verifyUrl, 'Verify My Email')}
${linkFallback(verifyUrl)}
<p style="color:#9ca3af;font-size:11px;margin:16px 0 0;">This link expires in 24 hours.</p>`
));
res.json({ success: true, message: 'Verification email sent' });
} catch (err) { console.error('[Auth] Resend error:', err.message); res.status(500).json({ error: 'Failed to send email' }); }
});
// ============================================================
// LOGIN (checks disabled status)
// ============================================================
router.post('/login', async (req, res) => {
try {
var { email, password, totpCode, turnstileToken } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
// Enumeration-resistant: always run bcrypt to keep timing constant, and return
// the same generic message for unknown-user, wrong-password, disabled, unverified.
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
if (!user) {
// Server-side only — no email in the message, so operators can watch
// lookup-miss rates in Grafana without leaking which addresses exist.
console.warn('[Auth] login: user not found (ip=' + req.ip + ')');
await bcrypt.compare(password, DUMMY_HASH).catch(function(){});
return res.status(401).json({ error: 'Invalid credentials' });
}
var valid;
try {
valid = await passwords.verify(password, user.password);
} catch (verifyErr) {
console.error('[Auth] passwords.verify threw:', verifyErr.message);
return res.status(500).json({ error: 'Request failed' });
}
if (!valid) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
logger.access(user.id, 'login_failed', req, false);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Transparent migration: bcrypt → argon2id on next successful login.
passwords.maybeRehash(password, user.password).then(function(newHash) {
if (newHash) db.run('UPDATE users SET password = ? WHERE id = ?', [newHash, user.id]).catch(function(){});
}).catch(function(){});
if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
logger.access(user.id, 'login_blocked', req, false);
return res.status(401).json({ error: 'Invalid credentials' });
}
if (!user.email_verified) {
return res.status(403).json({ error: 'Email not verified', needsVerification: true });
}
if (user.totp_enabled) {
if (!totpCode) return res.json({ requires2FA: true });
var totpInput = String(totpCode).trim();
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpInput, window: 1 });
if (!verified) {
// Backup-code fallback. Backup codes are 10-char alphanumeric and
// each hash is bcrypt — consumed on use.
var consumed = await tryConsumeBackupCode(user.id, totpInput);
if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){});
logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' });
}
}
var token = signAuthToken(user.id, req);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
logger.access(user.id, 'login', req, true);
// Create session record
var sessionId = generateSessionId();
try {
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
[sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
} catch (e) { /* table may not exist yet */ }
// Notify user of new login (fire-and-forget)
notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip);
setAuthCookie(res, token);
res.json({
success: true, token: token, sessionId: sessionId,
user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
});
} catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); }
});
// ── 2FA backup codes helpers ──────────────────────────────
// 10 single-use codes, 10 chars each (alphanumeric, readable). Stored as
// JSON array of bcrypt hashes in users.totp_backup_codes.
function generateBackupCodes(n) {
n = n || 10;
var alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
var out = [];
for (var i = 0; i < n; i++) {
var code = '';
var buf = crypto.randomBytes(10);
for (var j = 0; j < 10; j++) code += alphabet[buf[j] % alphabet.length];
// Render as XXXXX-XXXXX for readability
out.push(code.slice(0, 5) + '-' + code.slice(5));
}
return out;
}
async function hashBackupCodes(codes) {
var hashes = [];
for (var i = 0; i < codes.length; i++) {
hashes.push(await bcrypt.hash(codes[i].replace('-', '').toUpperCase(), 10));
}
return hashes;
}
async function tryConsumeBackupCode(userId, submitted) {
if (!submitted) return false;
var normalized = String(submitted).replace(/[-\s]/g, '').toUpperCase();
if (!/^[A-Z0-9]{10}$/.test(normalized)) return false; // wrong format — skip
// Atomic read-modify-write: wrap the SELECT + UPDATE in a transaction
// with SELECT ... FOR UPDATE to serialize concurrent consume attempts.
// Without this, two parallel logins using the same code could both
// pass the bcrypt compare and both succeed. The row lock funnels them
// one at a time — second attempt sees the already-consumed array.
var client = await db.pool.connect();
try {
await client.query('BEGIN');
var rowRes = await client.query(
'SELECT totp_backup_codes FROM users WHERE id = $1 FOR UPDATE',
[userId]
);
var row = rowRes.rows[0];
if (!row || !row.totp_backup_codes) { await client.query('ROLLBACK'); return false; }
var hashes;
try { hashes = JSON.parse(row.totp_backup_codes); } catch (e) { await client.query('ROLLBACK'); return false; }
if (!Array.isArray(hashes)) { await client.query('ROLLBACK'); return false; }
for (var i = 0; i < hashes.length; i++) {
if (await bcrypt.compare(normalized, hashes[i])) {
hashes.splice(i, 1);
await client.query(
'UPDATE users SET totp_backup_codes = $1 WHERE id = $2',
[JSON.stringify(hashes), userId]
);
await client.query('COMMIT');
return true;
}
}
await client.query('ROLLBACK');
return false;
} catch (e) {
try { await client.query('ROLLBACK'); } catch (_) {}
console.error('[Auth] backup-code consume error:', e.message);
return false;
} finally {
client.release();
}
}
// Generate (or regenerate) backup codes. Requires current password to authorise.
router.post('/2fa/backup-codes', authMiddleware, async (req, res) => {
try {
var { password } = req.body;
if (!password) return res.status(400).json({ error: 'Current password required' });
var user = await db.get('SELECT password, totp_enabled FROM users WHERE id = ?', [req.user.id]);
if (!user || !user.totp_enabled) return res.status(400).json({ error: '2FA is not enabled' });
var valid = await passwords.verify(password, user.password);
if (!valid) return res.status(401).json({ error: 'Wrong password' });
var codes = generateBackupCodes(10);
var hashes = await hashBackupCodes(codes);
await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, '2fa_backup_codes_regenerated', req.ip]).catch(function(){});
logger.audit(req.user.id, '2fa_backup_codes_regenerated', 'Backup codes regenerated', req, { category: 'auth' });
// Return plaintext codes — this is the ONLY time they are visible.
res.json({ success: true, codes: codes, message: 'Save these somewhere safe — they cannot be shown again.' });
} catch (err) { console.error('[Auth] 2FA backup codes error:', err.message); res.status(500).json({ error: 'Request failed' }); }
});
// How many backup codes remain (for UI badge).
router.get('/2fa/backup-codes/count', authMiddleware, async (req, res) => {
try {
var row = await db.get('SELECT totp_backup_codes FROM users WHERE id = ?', [req.user.id]);
var n = 0;
if (row && row.totp_backup_codes) {
try { var arr = JSON.parse(row.totp_backup_codes); if (Array.isArray(arr)) n = arr.length; } catch(e){}
}
res.json({ success: true, remaining: n });
} catch (err) { res.status(500).json({ error: 'Request failed' }); }
});
// 2FA
router.post('/setup-2fa', authMiddleware, async (req, res) => {
try {
// SSO-only accounts never go through the local password flow, so TOTP
// would sit dormant. Reject up front with a clear message.
var row = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
if (!row || !hasLocalPassword(row.password)) {
return res.status(400).json({ error: 'This account uses SSO. Two-factor authentication is managed by your identity provider.' });
}
var secret = speakeasy.generateSecret({ name: 'PedScribe (' + req.user.email + ')', issuer: 'Pediatric AI Scribe' });
await db.run('UPDATE users SET totp_secret = ? WHERE id = ?', [secret.base32, req.user.id]);
var qrUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ success: true, secret: secret.base32, qrCode: qrUrl });
} catch (err) { console.error('[Auth] 2FA setup error:', err.message); res.status(500).json({ error: '2FA setup failed' }); }
});
router.post('/verify-2fa', authMiddleware, async (req, res) => {
try {
var user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
if (!verified) return res.status(400).json({ error: 'Invalid code' });
// First time enabling — generate backup codes and show them once.
var wasFirstEnable = !user.totp_enabled;
var backupCodes = null;
if (wasFirstEnable) {
backupCodes = generateBackupCodes(10);
var hashes = await hashBackupCodes(backupCodes);
await db.run('UPDATE users SET totp_enabled = true, totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]);
} else {
await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]);
}
res.json({ success: true, backupCodes: backupCodes });
} catch (err) { console.error('[Auth] 2FA verify error:', err.message); res.status(500).json({ error: '2FA verification failed' }); }
});
router.post('/disable-2fa', authMiddleware, async (req, res) => {
try {
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
var valid = await passwords.verify(req.body.password, user.password);
if (!valid) return res.status(401).json({ error: 'Wrong password' });
await db.run('UPDATE users SET totp_enabled = false, totp_secret = NULL, totp_backup_codes = NULL WHERE id = ?', [req.user.id]);
res.json({ success: true });
} catch (err) { console.error('[Auth] 2FA disable error:', err.message); res.status(500).json({ error: '2FA disable failed' }); }
});
// Password reset
//
// Timing-safe: response is sent after Turnstile verification, BEFORE the DB
// lookup and email send. Hit and miss therefore take the same wall-clock
// time (no SMTP RTT on hit, no extra latency on miss), closing the
// user-enumeration oracle that previously let an attacker distinguish
// registered emails by response time.
router.post('/forgot-password', async (req, res) => {
try {
// Cloudflare Turnstile verification (runs for every request regardless
// of whether the account exists, so timing is equal)
if (process.env.TURNSTILE_SECRET_KEY) {
var turnstileToken = req.body.turnstileToken;
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var email = String(req.body.email || '').toLowerCase();
// Return the identical response immediately — do not await anything
// account-specific below this point.
res.json({ success: true, message: 'If account exists, reset email sent' });
// Fire-and-forget: DB lookup + token + email happen after the response
// is sent, so their latency cannot be measured by the caller.
async function sendResetEmailInBackground() {
try {
var user = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (!user) return;
var token = crypto.randomBytes(32).toString('hex');
await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]);
var resetUrl = safeAppUrl() + '/reset-password?token=' + token;
var resetSubject = await db.getSetting('email.reset.subject') || 'Reset your password — Pediatric AI Scribe';
var resetBody = await db.getSetting('email.reset.body') || 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed.';
await sendEmail(email, resetSubject, emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Password reset request</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(resetBody).replace(/\n/g, '<br>')}</p>
${btnHtml(resetUrl, 'Reset My Password')}
${linkFallback(resetUrl)}`
));
} catch (bgErr) {
console.error('[Auth] forgot-password background send failed:', bgErr.message);
}
}
sendResetEmailInBackground();
} catch (err) { console.error('[Auth] Forgot password error:', err.message); res.status(500).json({ error: 'Password reset request failed' }); }
});
router.post('/reset-password', async (req, res) => {
try {
var { token, newPassword } = req.body;
if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' });
var user = await db.get('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?', [token, Date.now()]);
if (!user) return res.status(400).json({ error: 'Invalid or expired token' });
var pwnedCount = await checkPwnedPassword(newPassword);
var hash = await passwords.hash(newPassword);
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
// Force logout — destroy all sessions for this user
try { await db.run('DELETE FROM user_sessions WHERE user_id = ?', [user.id]); } catch (e) { /* best effort */ }
var resp = { success: true };
if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider changing it to something unique.';
res.json(resp);
} catch (err) { console.error('[Auth] Reset password error:', err.message); res.status(500).json({ error: 'Password reset failed' }); }
});
// Logout — destroy session and clear cookie
router.post('/logout', async function(req, res) {
try {
var token = null;
var authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) token = authHeader.substring(7) || null;
if (!token && req.cookies && req.cookies.ped_auth) token = req.cookies.ped_auth;
if (token) {
await db.run('DELETE FROM user_sessions WHERE token_hash = ?', [hashToken(token)]);
}
} catch (e) { /* best effort */ }
clearAuthCookie(res);
res.json({ success: true });
});
// Returns true if the user has a real password hash and can change it.
// SSO-auto-created users have a random hex blob in `password` — changing
// it is meaningless because they never authenticate locally.
function hasLocalPassword(hash) {
return !!(hash && (/^\$2[aby]\$/.test(hash) || hash.indexOf('$argon2') === 0));
}
// Change password (requires current password)
router.post('/change-password', authMiddleware, async (req, res) => {
try {
var { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' });
if (newPassword.length < 8) return res.status(400).json({ error: 'New password must be 8+ characters' });
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
if (!hasLocalPassword(user.password)) {
return res.status(400).json({ error: 'This account uses SSO. Password changes are managed by your identity provider.' });
}
var valid = await passwords.verify(currentPassword, user.password);
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
var pwnedCount = await checkPwnedPassword(newPassword);
var hash = await passwords.hash(newPassword);
await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, req.user.id]);
// Destroy all OTHER sessions (keep current one)
if (req.sessionId) {
try { await db.run('DELETE FROM user_sessions WHERE user_id = ? AND id != ?', [req.user.id, req.sessionId]); } catch (e) { /* best effort */ }
}
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]);
logger.audit(req.user.id, 'password_changed', 'Password changed', req, { category: 'auth' });
notifyPasswordChanged(req.user.id);
var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' };
if (pwnedCount > 0) resp.passwordWarning = 'This password has appeared in ' + pwnedCount.toLocaleString() + ' data breaches. Consider choosing a different one.';
res.json(resp);
} catch (err) { console.error('[Auth] Change password error:', err.message); res.status(500).json({ error: 'Password change failed' }); }
});
// Get current user
router.get('/me', authMiddleware, async (req, res) => {
try {
var user = await db.get(
'SELECT id, email, name, role, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, oidc_sub, password, created_at FROM users WHERE id = ?',
[req.user.id]
);
// canLocalAuth: true if the user has a real password hash (can log in
// locally, so password change / 2FA are meaningful). SSO-auto-created
// users have a random hex blob in `password` which can't be verified
// — hide those UIs for them.
var canLocalAuth = !!(user && user.password && (/^\$2[aby]\$/.test(user.password) || user.password.indexOf('$argon2') === 0));
// Don't leak the password hash in the response.
if (user) delete user.password;
if (user) user.canLocalAuth = canLocalAuth;
res.json({ user: user });
} catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); }
});
// Check if registration is enabled (public endpoint)
router.get('/registration-status', async (req, res) => {
try {
var enabled = await db.getSetting('registration_enabled');
res.json({ registrationEnabled: enabled !== 'false' });
} catch (err) { res.json({ registrationEnabled: true }); }
});
// Expose helpers for adminConfig test-email and email template loading
module.exports = router;
module.exports.__sendEmail = sendEmail;
module.exports.__emailWrapper = emailWrapper;
module.exports.__btnHtml = btnHtml;