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:
parent
b294150781
commit
6dffdf91e5
7 changed files with 265 additions and 17 deletions
|
|
@ -110,6 +110,7 @@
|
||||||
<p id="2fa-status">Status: Loading...</p>
|
<p id="2fa-status">Status: Loading...</p>
|
||||||
<button id="btn-setup-2fa" class="btn-sm btn-primary">Enable 2FA</button>
|
<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>
|
<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 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;">
|
<div class="form-group" style="margin-bottom:8px;">
|
||||||
<label>Enter your password to confirm:</label>
|
<label>Enter your password to confirm:</label>
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
showToast('2FA enabled!', 'success');
|
showToast('2FA enabled!', 'success');
|
||||||
var setup = document.getElementById('2fa-setup');
|
var setup = document.getElementById('2fa-setup');
|
||||||
if (setup) setup.style.display = 'none';
|
if (setup) setup.style.display = 'none';
|
||||||
|
if (data.backupCodes && data.backupCodes.length) {
|
||||||
|
showBackupCodesModal(data.backupCodes);
|
||||||
|
}
|
||||||
load2FAStatus();
|
load2FAStatus();
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Invalid code', 'error');
|
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
|
// Disable 2FA
|
||||||
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
|
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -705,10 +733,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; }
|
if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; }
|
||||||
if (setupBtn) setupBtn.style.display = 'none';
|
if (setupBtn) setupBtn.style.display = 'none';
|
||||||
if (disableBtn) disableBtn.style.display = 'inline-flex';
|
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 {
|
} else {
|
||||||
if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
|
if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
|
||||||
if (setupBtn) setupBtn.style.display = 'inline-flex';
|
if (setupBtn) setupBtn.style.display = 'inline-flex';
|
||||||
if (disableBtn) disableBtn.style.display = 'none';
|
if (disableBtn) disableBtn.style.display = 'none';
|
||||||
|
var bi = document.getElementById('2fa-backup-info');
|
||||||
|
if (bi) bi.innerHTML = '';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
|
|
@ -719,6 +762,63 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Expose globally so app.js tabChanged handler can call it
|
// Expose globally so app.js tabChanged handler can call it
|
||||||
window.load2FAStatus = load2FAStatus;
|
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
|
// Check registration status — link is hidden by default, shown only when enabled
|
||||||
fetch('/api/auth/registration-status')
|
fetch('/api/auth/registration-status')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,8 @@ async function initDatabase() {
|
||||||
// Add user preferences for STT model and TTS voice
|
// 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 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) {}
|
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
|
// 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("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) {}
|
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) {}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const db = require('../db/database');
|
const db = require('../db/database');
|
||||||
const { hashToken } = require('../utils/sessions');
|
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.JWT_SECRET) {
|
||||||
if (process.env.NODE_ENV === 'production' || process.env.APP_URL) {
|
if (process.env.NODE_ENV === 'production' || process.env.APP_URL) {
|
||||||
|
|
@ -40,7 +46,7 @@ async function authMiddleware(req, res, next) {
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
|
|
||||||
// Session validation — allows revocation
|
// Session validation — allows revocation + sliding idle timeout
|
||||||
try {
|
try {
|
||||||
var tHash = hashToken(token);
|
var tHash = hashToken(token);
|
||||||
var session = await db.get('SELECT id, last_activity FROM user_sessions WHERE token_hash = ?', [tHash]);
|
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 {
|
} else {
|
||||||
req.sessionId = session.id;
|
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();
|
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() {});
|
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) {
|
} catch (sessErr) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const passwords = require('../utils/passwords');
|
const passwords = require('../utils/passwords');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { isMobileClient } = require('../utils/platform');
|
||||||
const speakeasy = require('speakeasy');
|
const speakeasy = require('speakeasy');
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
@ -36,18 +37,27 @@ router.post('/check-password', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cookie helper ─────────────────────────────────────────────
|
// ── 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) {
|
function setAuthCookie(res, token) {
|
||||||
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
||||||
res.cookie('ped_auth', token, {
|
res.cookie('ped_auth', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !!isProduction, // HTTPS only in production
|
secure: !!isProduction,
|
||||||
sameSite: 'lax', // Allows normal navigation, blocks CSRF from other origins
|
sameSite: 'lax',
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours in ms
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days absolute; middleware enforces 24h sliding idle
|
||||||
path: '/'
|
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) {
|
function clearAuthCookie(res) {
|
||||||
res.clearCookie('ped_auth', { path: '/' });
|
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;
|
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
|
||||||
if (!smtpHost) {
|
if (!smtpHost) {
|
||||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
|
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();
|
var regSessionId = generateSessionId();
|
||||||
try {
|
try {
|
||||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
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 (user.totp_enabled) {
|
||||||
if (!totpCode) return res.json({ requires2FA: true });
|
if (!totpCode) return res.json({ requires2FA: true });
|
||||||
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
|
var totpInput = String(totpCode).trim();
|
||||||
if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' });
|
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]);
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
|
||||||
|
|
||||||
// Create session record
|
// 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' }); }
|
} 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
|
// 2FA
|
||||||
router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -377,11 +471,21 @@ router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
||||||
|
|
||||||
router.post('/verify-2fa', authMiddleware, async (req, res) => {
|
router.post('/verify-2fa', authMiddleware, async (req, res) => {
|
||||||
try {
|
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 });
|
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' });
|
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' }); }
|
} 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 user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
|
||||||
var valid = await passwords.verify(req.body.password, user.password);
|
var valid = await passwords.verify(req.body.password, user.password);
|
||||||
if (!valid) return res.status(401).json({ error: 'Wrong 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 });
|
res.json({ success: true });
|
||||||
} catch (err) { console.error('[Auth] 2FA disable error:', err.message); res.status(500).json({ error: '2FA disable failed' }); }
|
} catch (err) { console.error('[Auth] 2FA disable error:', err.message); res.status(500).json({ error: '2FA disable failed' }); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ var net = require('net');
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
|
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
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.
|
// 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.
|
// Prevents an admin (or compromised admin account) from pointing OIDC at AWS metadata etc.
|
||||||
|
|
@ -70,7 +71,7 @@ function setAuthCookie(res, token) {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !!isProduction,
|
secure: !!isProduction,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30d safety net; middleware enforces 24h sliding idle
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +212,7 @@ router.get('/oidc/callback', async function(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue JWT
|
// 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);
|
setAuthCookie(res, token);
|
||||||
|
|
||||||
// Create session record
|
// Create session record
|
||||||
|
|
|
||||||
12
src/utils/platform.js
Normal file
12
src/utils/platform.js
Normal 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 };
|
||||||
Loading…
Reference in a new issue