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