Two independent PHI-leak hardenings folded together:
1. forgot-password timing oracle
The hit path previously did SELECT + token gen + UPDATE + SMTP send
before responding; the miss path returned after the SELECT. An
attacker could distinguish registered emails by response latency
(SMTP RTT is hundreds of ms). Response is now sent immediately after
Turnstile, with the DB and email work fired-and-forgotten in a
background async block. Hit and miss take identical wall-clock time.
Also hardened req.body.email to tolerate missing/non-string input
instead of throwing 500.
2. logger.file redaction
logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
without going through redact(). Current callers are metadata-only and
safe, but any future caller writing logger.error('boom', req.body)
would silently drop PHI to disk. Route both message and optional data
through redact() — same helper the audit path already uses. Benign
startup messages pass through unchanged; SSN/phone/email/DOB patterns
are tokenised, long note-body-shaped text is truncated.
696 lines
37 KiB
JavaScript
696 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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)} — AI-powered clinical documentation<br>
|
|
If you didn’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’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 () {
|
|
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);
|
|
}
|
|
})();
|
|
} 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;
|