Root cause for "invalid credentials" on correct password was a corrupt btree index (idx_users_email) causing user lookups to miss existing rows. Fixed by REINDEX DATABASE. Keeping a typed catch around passwords.verify() so any future verify throw is logged cleanly instead of bubbling as 500.
511 lines
28 KiB
JavaScript
511 lines
28 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const bcrypt = require('bcryptjs');
|
|
const passwords = require('../utils/passwords');
|
|
const jwt = require('jsonwebtoken');
|
|
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');
|
|
|
|
// 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
|
|
function setAuthCookie(res, token) {
|
|
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
|
res.cookie('ped_auth', token, {
|
|
httpOnly: true,
|
|
secure: !!isProduction, // HTTPS only in production
|
|
sameSite: 'lax', // Allows normal navigation, blocks CSRF from other origins
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
|
|
path: '/'
|
|
});
|
|
}
|
|
|
|
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']);
|
|
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 = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' });
|
|
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 */ }
|
|
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) {
|
|
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(){});
|
|
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(){});
|
|
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 verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
|
|
if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' });
|
|
}
|
|
|
|
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
|
|
|
|
// 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);
|
|
|
|
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
|
|
router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
|
try {
|
|
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 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' });
|
|
await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]);
|
|
res.json({ success: true });
|
|
} 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 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
|
|
router.post('/forgot-password', async (req, res) => {
|
|
try {
|
|
// Cloudflare Turnstile verification
|
|
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 user = await db.get('SELECT id, name FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
|
|
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
|
|
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(req.body.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)}`
|
|
));
|
|
res.json({ success: true, message: 'If account exists, reset email sent' });
|
|
} 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 });
|
|
});
|
|
|
|
// 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]);
|
|
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]);
|
|
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, created_at FROM users WHERE id = ?',
|
|
[req.user.id]
|
|
);
|
|
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;
|