697 lines
37 KiB
JavaScript
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, '&').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 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;
|