Sliding 24h idle timeout (web) + persistent mobile + 2FA backup codes

Session model:
  Web     — 24h sliding idle timeout enforced server-side via
             user_sessions.last_activity. 30-day JWT + cookie are a
             safety net; middleware is the real clock. Cookie is
             re-set on active use so browsers match the sliding window.
  Mobile  — 365-day JWT, no idle timeout (stays persistent via Keychain
             / Keystore). Detected via User-Agent ("PedScribe" /
             "Capacitor") or X-Client: mobile header.

2FA backup codes:
  - 10 single-use codes generated when 2FA is first enabled
  - Stored as bcrypt hashes in new users.totp_backup_codes column
  - Consumed atomically on successful login fallback (when TOTP fails)
  - Regenerate endpoint (POST /api/auth/2fa/backup-codes) requires
    current password; invalidates prior codes
  - Count endpoint (GET /api/auth/2fa/backup-codes/count) powers a
    "N codes remaining" indicator on the 2FA settings card
  - Modal shows codes exactly once with Copy + Download .txt actions
  - Codes cleared when 2FA is disabled

New files:
  src/utils/platform.js — isMobileClient() helper

Schema migration (idempotent):
  ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT
This commit is contained in:
Daniel 2026-04-14 04:24:54 +02:00
parent b294150781
commit 6dffdf91e5
7 changed files with 265 additions and 17 deletions

View file

@ -110,6 +110,7 @@
<p id="2fa-status">Status: Loading...</p>
<button id="btn-setup-2fa" class="btn-sm btn-primary">Enable 2FA</button>
<button id="btn-disable-2fa" class="btn-sm btn-ghost hidden" style="color:var(--red);">Disable 2FA</button>
<div id="2fa-backup-info"></div>
<div id="2fa-disable-confirm" class="hidden" style="margin-top:10px;padding:12px;background:var(--g50);border-radius:8px;border:1px solid var(--g200);max-width:340px;">
<div class="form-group" style="margin-bottom:8px;">
<label>Enter your password to confirm:</label>

View file

@ -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 = '<div style="margin-top:12px;font-size:13px;color:' + color + ';">'
+ '<span>' + bc.remaining + ' backup codes remaining.</span> '
+ '<button id="btn-regen-backup-codes" class="btn-sm btn-ghost" style="margin-left:6px;">Regenerate</button>'
+ '</div>';
}
}).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(); })

View file

@ -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) {}

View file

@ -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) {

View file

@ -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' }); }
});

View file

@ -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

12
src/utils/platform.js Normal file
View file

@ -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 };