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
115 lines
4.4 KiB
JavaScript
115 lines
4.4 KiB
JavaScript
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) {
|
|
console.error('[FATAL] JWT_SECRET is not set. Refusing to start in production.');
|
|
process.exit(1);
|
|
}
|
|
console.warn('[WARN] JWT_SECRET not set — using development fallback. DO NOT use in production.');
|
|
}
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
|
|
|
async function authMiddleware(req, res, next) {
|
|
var token = null;
|
|
|
|
var authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7) || null;
|
|
}
|
|
if (!token && req.cookies && req.cookies.ped_auth) {
|
|
token = req.cookies.ped_auth;
|
|
}
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
|
|
try {
|
|
var decoded = jwt.verify(token, JWT_SECRET);
|
|
var user = await db.get('SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?', [decoded.userId]);
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'User not found' });
|
|
}
|
|
|
|
if (user.disabled) {
|
|
return res.status(403).json({ error: 'Account disabled. Contact administrator.' });
|
|
}
|
|
|
|
req.user = user;
|
|
|
|
// 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]);
|
|
if (!session) {
|
|
// Pre-migration user? If they have no sessions at all, allow through
|
|
var sessCount = await db.get('SELECT COUNT(*) as count FROM user_sessions WHERE user_id = ?', [user.id]);
|
|
if (parseInt(sessCount.count) > 0) {
|
|
return res.status(401).json({ error: 'Session revoked' });
|
|
}
|
|
} else {
|
|
req.sessionId = session.id;
|
|
var now = Date.now();
|
|
var lastAct = new Date(session.last_activity).getTime();
|
|
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) {
|
|
// Fail open — table may not exist yet during migration
|
|
}
|
|
|
|
next();
|
|
} catch (err) {
|
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
}
|
|
}
|
|
|
|
// Admin-only middleware — use after authMiddleware
|
|
async function adminMiddleware(req, res, next) {
|
|
if (!req.user || req.user.role !== 'admin') {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Moderator or Admin middleware — for Learning Hub CMS
|
|
async function moderatorMiddleware(req, res, next) {
|
|
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'moderator')) {
|
|
return res.status(403).json({ error: 'Moderator or admin access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
module.exports = { authMiddleware, adminMiddleware, moderatorMiddleware, JWT_SECRET };
|