diff --git a/public/js/auth.js b/public/js/auth.js
index 260b52a..6b9be3c 100644
--- a/public/js/auth.js
+++ b/public/js/auth.js
@@ -335,6 +335,9 @@ document.addEventListener('DOMContentLoaded', function() {
showToast('2FA enabled!', 'success');
var setup = document.getElementById('2fa-setup');
if (setup) setup.style.display = 'none';
+ if (data.backupCodes && data.backupCodes.length) {
+ showBackupCodesModal(data.backupCodes);
+ }
load2FAStatus();
} else {
showToast(data.error || 'Invalid code', 'error');
@@ -342,6 +345,31 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
+ // Regenerate backup codes
+ if (target.id === 'btn-regen-backup-codes' || target.closest('#btn-regen-backup-codes')) {
+ e.preventDefault();
+ showConfirm({
+ title: 'Regenerate backup codes?',
+ message: 'This will invalidate your existing backup codes and generate 10 new ones. Enter your current password.',
+ inputType: 'password',
+ inputPlaceholder: 'Current password',
+ confirmText: 'Regenerate',
+ required: true,
+ requiredMsg: 'Password is required'
+ }, function(pw) {
+ if (!pw) return;
+ fetch('/api/auth/2fa/backup-codes', {
+ method: 'POST', headers: getAuthHeaders(),
+ body: JSON.stringify({ password: pw })
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.success && data.codes) showBackupCodesModal(data.codes);
+ else showToast(data.error || 'Failed to regenerate codes', 'error');
+ });
+ });
+ }
+
// Disable 2FA
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
e.preventDefault();
@@ -705,10 +733,25 @@ document.addEventListener('DOMContentLoaded', function() {
if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; }
if (setupBtn) setupBtn.style.display = 'none';
if (disableBtn) disableBtn.style.display = 'inline-flex';
+ // Show backup-code row and remaining count
+ fetch('/api/auth/2fa/backup-codes/count', { credentials: 'same-origin', headers: getAuthHeaders() })
+ .then(function(r) { return r.json(); })
+ .then(function(bc) {
+ var el = document.getElementById('2fa-backup-info');
+ if (el && typeof bc.remaining === 'number') {
+ var color = bc.remaining <= 2 ? '#f97316' : 'var(--g500)';
+ el.innerHTML = '
'
+ + '' + bc.remaining + ' backup codes remaining. '
+ + ''
+ + '
';
+ }
+ }).catch(function(){});
} else {
if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
if (setupBtn) setupBtn.style.display = 'inline-flex';
if (disableBtn) disableBtn.style.display = 'none';
+ var bi = document.getElementById('2fa-backup-info');
+ if (bi) bi.innerHTML = '';
}
})
.catch(function() {
@@ -719,6 +762,63 @@ document.addEventListener('DOMContentLoaded', function() {
// Expose globally so app.js tabChanged handler can call it
window.load2FAStatus = load2FAStatus;
+ // Modal that shows backup codes exactly once. Uses textContent and a copy
+ // button — codes never land in innerHTML so a stray value can't produce XSS.
+ function showBackupCodesModal(codes) {
+ var existing = document.getElementById('backup-codes-modal');
+ if (existing) existing.remove();
+ var modal = document.createElement('div');
+ modal.id = 'backup-codes-modal';
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px;';
+
+ var box = document.createElement('div');
+ box.style.cssText = 'background:#fff;border-radius:12px;max-width:460px;width:100%;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);';
+
+ var title = document.createElement('div');
+ title.style.cssText = 'font-size:18px;font-weight:700;margin-bottom:4px;';
+ title.textContent = 'Save your backup codes';
+ var hint = document.createElement('div');
+ hint.style.cssText = 'font-size:13px;color:#6b7280;margin-bottom:16px;line-height:1.5;';
+ hint.textContent = 'Each code works once. Store them somewhere safe — you will NOT see them again. Use one in place of your 2FA code if you lose your authenticator.';
+
+ var list = document.createElement('pre');
+ list.style.cssText = 'background:#f3f4f6;padding:12px 14px;border-radius:8px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px;line-height:1.8;margin:0;white-space:pre-wrap;user-select:all;';
+ list.textContent = codes.join('\n');
+
+ var btnRow = document.createElement('div');
+ btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px;justify-content:flex-end;';
+
+ var copyBtn = document.createElement('button');
+ copyBtn.className = 'btn-sm btn-ghost';
+ copyBtn.textContent = 'Copy';
+ copyBtn.addEventListener('click', function() {
+ navigator.clipboard.writeText(codes.join('\n')).then(function() {
+ copyBtn.textContent = 'Copied ✓';
+ setTimeout(function() { copyBtn.textContent = 'Copy'; }, 1500);
+ }).catch(function() { showToast('Copy failed', 'error'); });
+ });
+
+ var downloadBtn = document.createElement('button');
+ downloadBtn.className = 'btn-sm btn-ghost';
+ downloadBtn.textContent = 'Download .txt';
+ downloadBtn.addEventListener('click', function() {
+ var blob = new Blob(['PedScribe 2FA backup codes\nGenerated: ' + new Date().toISOString() + '\n\n' + codes.join('\n') + '\n'], { type: 'text/plain' });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a'); a.href = url; a.download = 'pedscribe-backup-codes.txt'; a.click();
+ URL.revokeObjectURL(url);
+ });
+
+ var doneBtn = document.createElement('button');
+ doneBtn.className = 'btn-sm btn-primary';
+ doneBtn.textContent = "I've saved them";
+ doneBtn.addEventListener('click', function() { modal.remove(); });
+
+ btnRow.appendChild(copyBtn); btnRow.appendChild(downloadBtn); btnRow.appendChild(doneBtn);
+ box.appendChild(title); box.appendChild(hint); box.appendChild(list); box.appendChild(btnRow);
+ modal.appendChild(box);
+ document.body.appendChild(modal);
+ }
+
// Check registration status — link is hidden by default, shown only when enabled
fetch('/api/auth/registration-status')
.then(function(r) { return r.json(); })
diff --git a/src/db/database.js b/src/db/database.js
index 30849f1..a671a5e 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -280,6 +280,8 @@ async function initDatabase() {
// Add user preferences for STT model and TTS voice
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}
+ // 2FA backup codes — JSON array of bcrypt hashes; entries consumed on use
+ try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT DEFAULT NULL"); } catch(e) {}
// Add idempotency_key for duplicate prevention
try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {}
try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {}
diff --git a/src/middleware/auth.js b/src/middleware/auth.js
index da02ce9..7400814 100644
--- a/src/middleware/auth.js
+++ b/src/middleware/auth.js
@@ -1,6 +1,12 @@
const jwt = require('jsonwebtoken');
const db = require('../db/database');
const { hashToken } = require('../utils/sessions');
+const { isMobileClient } = require('../utils/platform');
+
+// Sliding idle timeout — web only. Mobile stays persistent.
+const WEB_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24h since last request
+const ACTIVITY_THROTTLE_MS = 5 * 60 * 1000; // don't UPDATE more than every 5 min
+const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // cookie hard expiry — session table drives actual session
if (!process.env.JWT_SECRET) {
if (process.env.NODE_ENV === 'production' || process.env.APP_URL) {
@@ -40,7 +46,7 @@ async function authMiddleware(req, res, next) {
req.user = user;
- // Session validation — allows revocation
+ // Session validation — allows revocation + sliding idle timeout
try {
var tHash = hashToken(token);
var session = await db.get('SELECT id, last_activity FROM user_sessions WHERE token_hash = ?', [tHash]);
@@ -52,10 +58,32 @@ async function authMiddleware(req, res, next) {
}
} else {
req.sessionId = session.id;
- // Throttled last_activity update — at most once per 5 minutes, fire-and-forget
+ var now = Date.now();
var lastAct = new Date(session.last_activity).getTime();
- if (Date.now() - lastAct > 5 * 60 * 1000) {
+ var idleMs = now - lastAct;
+ var mobile = isMobileClient(req);
+
+ // Sliding idle timeout — web only. Mobile stays persistent.
+ if (!mobile && idleMs > WEB_IDLE_TIMEOUT_MS) {
+ db.run('DELETE FROM user_sessions WHERE id = ?', [session.id]).catch(function(){});
+ res.clearCookie('ped_auth', { path: '/' });
+ return res.status(401).json({ error: 'Session expired due to inactivity', idleTimeout: true });
+ }
+
+ // Throttled last_activity update — at most every 5 min
+ if (idleMs > ACTIVITY_THROTTLE_MS) {
db.run('UPDATE user_sessions SET last_activity = NOW() WHERE id = ?', [session.id]).catch(function() {});
+ // Sliding cookie refresh (web only) — extend browser-side lifetime too
+ if (!mobile && req.cookies && req.cookies.ped_auth) {
+ var isProd = process.env.NODE_ENV === 'production' || !!process.env.APP_URL;
+ res.cookie('ped_auth', token, {
+ httpOnly: true,
+ secure: !!isProd,
+ sameSite: 'lax',
+ maxAge: COOKIE_MAX_AGE_MS,
+ path: '/'
+ });
+ }
}
}
} catch (sessErr) {
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 42ce2de..c71c858 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -3,6 +3,7 @@ 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');
@@ -36,18 +37,27 @@ router.post('/check-password', async (req, res) => {
});
// ── Cookie helper ─────────────────────────────────────────────
-// Sets the JWT as a secure, httpOnly cookie so JS cannot read it
+// 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, // HTTPS only in production
- sameSite: 'lax', // Allows normal navigation, blocks CSRF from other origins
- maxAge: 24 * 60 * 60 * 1000, // 24 hours in ms
+ 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: '/' });
}
@@ -224,7 +234,7 @@ router.post('/register', async (req, res) => {
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: '24h' });
+ 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 (?, ?, ?, ?, ?, ?)',
@@ -341,11 +351,19 @@ router.post('/login', async (req, res) => {
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 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(){});
+ }
}
- var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '24h' });
+ var token = signAuthToken(user.id, req);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
// Create session record
@@ -365,6 +383,82 @@ router.post('/login', async (req, res) => {
} 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
+ var row = await db.get('SELECT totp_backup_codes FROM users WHERE id = ?', [userId]);
+ if (!row || !row.totp_backup_codes) return false;
+ var hashes;
+ try { hashes = JSON.parse(row.totp_backup_codes); } catch (e) { return false; }
+ if (!Array.isArray(hashes)) return false;
+ for (var i = 0; i < hashes.length; i++) {
+ if (await bcrypt.compare(normalized, hashes[i])) {
+ // Consume — remove this hash from the array
+ hashes.splice(i, 1);
+ await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), userId]);
+ return true;
+ }
+ }
+ return false;
+}
+
+// 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(){});
+ // 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 {
@@ -377,11 +471,21 @@ router.post('/setup-2fa', authMiddleware, async (req, res) => {
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 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' });
- await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]);
- res.json({ success: true });
+
+ // 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' }); }
});
@@ -390,7 +494,7 @@ router.post('/disable-2fa', authMiddleware, async (req, res) => {
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]);
+ 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' }); }
});
diff --git a/src/routes/oidc.js b/src/routes/oidc.js
index 86f05ca..ae75126 100644
--- a/src/routes/oidc.js
+++ b/src/routes/oidc.js
@@ -12,6 +12,7 @@ var net = require('net');
var db = require('../db/database');
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
+var { isMobileClient } = require('../utils/platform');
// SSRF guard: reject issuer URLs that resolve to private / loopback / link-local IPs.
// Prevents an admin (or compromised admin account) from pointing OIDC at AWS metadata etc.
@@ -70,7 +71,7 @@ function setAuthCookie(res, token) {
httpOnly: true,
secure: !!isProduction,
sameSite: 'lax',
- maxAge: 24 * 60 * 60 * 1000, // 24 hours
+ maxAge: 30 * 24 * 60 * 60 * 1000, // 30d safety net; middleware enforces 24h sliding idle
path: '/'
});
}
@@ -211,7 +212,7 @@ router.get('/oidc/callback', async function(req, res) {
}
// Issue JWT
- var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '24h' });
+ var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: isMobileClient(req) ? '365d' : '30d' });
setAuthCookie(res, token);
// Create session record
diff --git a/src/utils/platform.js b/src/utils/platform.js
new file mode 100644
index 0000000..de74972
--- /dev/null
+++ b/src/utils/platform.js
@@ -0,0 +1,12 @@
+// Simple mobile-vs-web client detection based on the User-Agent
+// appended by Capacitor config (appendUserAgent: "PedScribe-Android",
+// similarly for iOS) and an optional X-Client header.
+function isMobileClient(req) {
+ if (!req) return false;
+ var xc = req.headers && req.headers['x-client'];
+ if (xc === 'mobile' || xc === 'ios' || xc === 'android') return true;
+ var ua = (req.headers && req.headers['user-agent']) || '';
+ return /PedScribe|Capacitor/i.test(ua);
+}
+
+module.exports = { isMobileClient: isMobileClient };