pediatric-ai-scribe-v3/src/routes/auth.js
Daniel 39c1663334 feat: invite-only registration
registration_enabled was a single switch: open to anyone, or closed to
everyone. This adds the setting an operator actually wants in between — open
to people you invited.

A code is single-use, expires (7 days by default, 90 maximum), and can be
revoked or deleted. It is stored hashed with only its last four characters
kept, because an invite grants account creation and a database dump should
not hand someone a working one. The code is readable exactly once, in the
response that creates it.

The claim is a single conditional UPDATE carrying every condition, so two
registrations racing the same code cannot both succeed. It happens after the
account exists, so a code is never spent on a failed registration — and if
the race is lost, the just-created account is removed rather than left behind
as a free registration. The rejection never says which of the four reasons
applied; distinguishing them would tell someone probing codes which guesses
were closer.

Codes avoid I, L, O and U so they survive being read aloud or copied off a
screen, and matching ignores case and separators.

The sign-up field appears only when the server says a code is required. The
admin card creates, lists, revokes and deletes, and carries the toggle.

Verified against the live database: create, claim, second claim refused,
unknown code refused, revoking a used code refused, delete. 684 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 23:12:07 +02:00

714 lines
38 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 invites = require('../utils/registrationInvites');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
var logger = require('../utils/logger');
const { requireLocalAuth, isSSOOnly } = require('../utils/policy');
// 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', requireLocalAuth, 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, inviteCode } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
// Invite-only sits between "open" and "closed": anyone with a code, nobody
// without one. Checked before the work of hashing a password, and claimed
// atomically once the account exists.
var inviteRequired = await invites.inviteOnly();
if (inviteRequired && !String(inviteCode || '').trim()) {
return res.status(400).json({ error: 'An invitation code is required to register.' });
}
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;
// Claimed only now, so a code is never spent on a registration that failed.
// The claim is a single conditional UPDATE, so two people racing the same
// code cannot both win — the loser's account is removed again rather than
// left behind as a free registration.
if (inviteRequired) {
var claimedInvite = await invites.claim(inviteCode, userId);
if (!claimedInvite) {
await db.run('DELETE FROM users WHERE id = ?', [userId]);
return res.status(400).json({ error: 'That invitation code is not valid. It may have expired, been revoked, or already been used.' });
}
}
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();
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'])]);
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', requireLocalAuth, async (req, res) => {
try {
var { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// No Turnstile on login. The widget could not reliably complete a
// challenge inside the Capacitor WebView, which locked mobile users out.
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
// Registration and password reset — the endpoints that actually attract
// bots — are still gated.
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();
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'])]);
// 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, requireLocalAuth, 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, requireLocalAuth, 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, requireLocalAuth, 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', requireLocalAuth, 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', requireLocalAuth, 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, requireLocalAuth, 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 && !await isSSOOnly();
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' && !await isSSOOnly(),
inviteOnly: await invites.inviteOnly()
});
} catch (err) { res.json({ registrationEnabled: false }); }
});
// 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;