const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { getDb, getSetting } = require('../db/database'); const { buildDeviceFingerprint } = require('./loginFingerprint'); const { encryptSecret, decryptSecret } = require('./encryptionService'); // Store SHA-256(token) in the DB; the raw token stays only in the cookie. function hashSession(token) { return crypto.createHash('sha256').update(token).digest('hex'); } const COOKIE_NAME = 'bt_session'; const SINGLE_COOKIE_NAME = 'bt_single_session'; const SESSION_DAYS = 7; function envFlag(name) { const value = process.env[name]; if (value === undefined) return null; return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); } function requestLooksHttps(req) { if (!req) return false; if (req.secure) return true; const proto = req.get?.('x-forwarded-proto') || req.headers?.['x-forwarded-proto']; return String(proto || '').split(',').map(s => s.trim()).includes('https'); } /** * Build session-cookie options. * * COOKIE_SECURE=true/false is explicit. HTTPS=true keeps the old deployment knob. * Otherwise, mark cookies Secure only when the current request appears to be HTTPS. */ function cookieOpts(req) { const cookieSecure = envFlag('COOKIE_SECURE'); const httpsSecure = envFlag('HTTPS'); const secure = cookieSecure !== null ? cookieSecure : httpsSecure !== null ? httpsSecure : requestLooksHttps(req); return { httpOnly: true, sameSite: 'strict', secure, maxAge: SESSION_DAYS * 86400 * 1000, path: '/', }; } async function login(username, password) { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user) return null; if (user.active === 0) return null; // Reject OIDC-only accounts from local login if (user.auth_provider && user.auth_provider !== 'local') { return null; } const valid = await bcrypt.compare(password, user.password_hash); // Return userId so the route can log the failed attempt against the known account. // The external response is identical either way — no user enumeration. if (!valid) return { error: 'bad_password', userId: user.id }; // TOTP is enabled — don't create a session yet; issue a short-lived challenge instead if (user.totp_enabled) { const { createChallenge } = require('./totpService'); const challengeToken = createChallenge(getDb(), user.id); return { requires_totp: true, challenge_token: challengeToken }; } // WebAuthn is enabled — issue a WebAuthn authentication challenge instead if (user.webauthn_enabled) { const { createAuthenticationChallenge, createLoginChallenge } = require('./webauthnService'); const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id); const loginToken = createLoginChallenge(getDb(), user.id, challengeId); return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options }; } // Clean up expired sessions for this user before creating new session try { db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id); } catch (err) { console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message); } const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000) .toISOString().slice(0, 19).replace('T', ' '); db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)') .run(hashSession(sessionId), user.id, expiresAt); // Update last_login_at if column exists (added in v0.17 migration) try { db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id); } catch { /* column may not exist on older schemas */ } return { sessionId, user: publicUser(user) }; } async function createSession(userId) { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) return null; if (user.active === 0) return null; // Clean up expired sessions for this user before creating new session try { db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(userId); } catch (err) { console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message); } const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000) .toISOString().slice(0, 19).replace('T', ' '); db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)') .run(hashSession(sessionId), user.id, expiresAt); return { sessionId, user: publicUser(user) }; } function logout(sessionId) { if (!sessionId) return; getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId)); } /** * Regenerate session ID for security (e.g., on privilege escalation). * This invalidates the old session and creates a new one with the same user. */ function rotateSessionId(oldSessionId, userId) { if (!oldSessionId || !userId) return null; const db = getDb(); // Verify the old session belongs to the user and is valid const existingSession = db.prepare('SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')').get(hashSession(oldSessionId)); if (!existingSession || existingSession.user_id !== userId) { return null; } // Generate new session ID const newSessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000) .toISOString().slice(0, 19).replace('T', ' '); // Delete old session and create new one atomically try { db.transaction(() => { db.prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(oldSessionId)); db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)') .run(hashSession(newSessionId), userId, expiresAt); })(); } catch { return null; } return newSessionId; } function getSessionUser(sessionId) { if (!sessionId) return null; const row = getDb().prepare(` SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login, u.active, u.is_default_admin, u.last_seen_version FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1 `).get(hashSession(sessionId)); return row || null; } async function hashPassword(password) { return bcrypt.hash(password, 12); } function publicUser(u) { return { id: u.id, username: u.username, display_name: u.display_name || null, role: u.role, active: u.active !== 0, is_default_admin: !!u.is_default_admin, must_change_password: !!u.must_change_password, first_login: !!u.first_login, last_seen_version: u.last_seen_version || null, webauthn_enabled: !!u.webauthn_enabled, }; } /** * Records a successful login with encrypted IP/UA and prunes older entries * so each user keeps at most 10 login history rows. * Session fingerprint is stored for current-session detection. * New device alerts and geolocation are resolved asynchronously. */ function recordLogin(userId, ipAddress, userAgent, sessionId) { const db = getDb(); const device = buildDeviceFingerprint({ userAgent, ipAddress }); const encIp = ipAddress ? encryptSecret(ipAddress) : null; const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null; const sessionFingerprint = sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32) : null; // Collect prior fingerprints before insert to detect new devices let priorFingerprints; try { priorFingerprints = db.prepare(` SELECT device_fingerprint FROM user_login_history WHERE user_id = ? AND success = 1 ORDER BY logged_in_at DESC, id DESC LIMIT 10 `).all(userId).map(r => r.device_fingerprint); } catch { priorFingerprints = []; } let insertedId; db.transaction(() => { const result = db.prepare(` INSERT INTO user_login_history ( user_id, logged_in_at, ip_address, user_agent, browser, os, device_type, device_fingerprint, session_fingerprint, success ) VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, 1) `).run( userId, encIp, encUa, device.browser, device.os, device.device_type, device.device_fingerprint, sessionFingerprint, ); insertedId = result.lastInsertRowid; // Keep only the 10 most recent rows for this user db.prepare(` DELETE FROM user_login_history WHERE user_id = ? AND id NOT IN ( SELECT id FROM user_login_history WHERE user_id = ? ORDER BY logged_in_at DESC, id DESC LIMIT 10 ) `).run(userId, userId); })(); // Background tasks: geolocation + new device push alert if (insertedId) { setImmediate(() => { // Geolocation — only when admin has opted in, and skip private/loopback IPs if (ipAddress && getSetting('geolocation_enabled') === 'true') { const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(ipAddress); if (!isPrivate) { fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`) .then(r => r.json()) .then(data => { if (data.status !== 'success') return; const updDb = getDb(); updDb.prepare(` UPDATE user_login_history SET location_city=?, location_country=?, location_region=?, location_isp=? WHERE id=? `).run( encryptSecret(data.city || ''), encryptSecret(data.country || ''), encryptSecret(data.regionName || ''), encryptSecret(data.isp || ''), insertedId, ); }) .catch(() => {}); } } // New device alert via push notification const isNewDevice = device.device_fingerprint && !priorFingerprints.includes(device.device_fingerprint); if (isNewDevice) { try { const { sendPushToUser } = require('./notificationService')._push; const alertUser = getDb().prepare('SELECT * FROM users WHERE id = ?').get(userId); if (alertUser?.notify_push_enabled && alertUser?.push_channel) { const deviceDesc = [device.browser, device.os, device.device_type !== 'desktop' ? device.device_type : null] .filter(Boolean).join(' / '); const ipDesc = ipAddress || 'unknown IP'; sendPushToUser( alertUser, 'New device sign-in — Bill Tracker', `A sign-in was detected from a new device: ${deviceDesc} (${ipDesc}).`, 'today', ).catch(() => {}); } } catch { /* push is best-effort */ } } }); } } /** * Records a failed login attempt (wrong password for a known account). * Stored with success=0 so it shows in login history but doesn't count as * a real session. Only called when the username maps to a real user — * unknown usernames are not tracked to prevent user-enumeration side-channels. */ function recordFailedLogin(userId, ipAddress, userAgent) { if (!userId) return; try { const db = getDb(); const device = buildDeviceFingerprint({ userAgent, ipAddress }); const encIp = ipAddress ? encryptSecret(ipAddress) : null; const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null; db.transaction(() => { db.prepare(` INSERT INTO user_login_history ( user_id, logged_in_at, ip_address, user_agent, browser, os, device_type, device_fingerprint, success ) VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, 0) `).run( userId, encIp, encUa, device.browser, device.os, device.device_type, device.device_fingerprint, ); // Prune: keep 10 most recent (success or fail) per user db.prepare(` DELETE FROM user_login_history WHERE user_id = ? AND id NOT IN ( SELECT id FROM user_login_history WHERE user_id = ? ORDER BY logged_in_at DESC, id DESC LIMIT 10 ) `).run(userId, userId); })(); } catch { /* never fail a request because of history logging */ } } // Prune expired sessions — called by daily worker function pruneExpiredSessions() { const result = getDb().prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); console.log(`[cleanup] Purged ${result.changes} expired sessions`); return result; } /** * Invalidate all sessions for a user except for a specific session ID * @param {number} userId - User ID * @param {string} keepSessionId - Session ID to keep (typically the current session) * @returns {Object} Result object with changes count */ function invalidateOtherSessions(userId, keepSessionId) { if (!userId) return { changes: 0 }; const db = getDb(); let result; if (keepSessionId) { result = db.prepare( "DELETE FROM sessions WHERE user_id = ? AND id != ?" ).run(userId, hashSession(keepSessionId)); } else { result = db.prepare( "DELETE FROM sessions WHERE user_id = ?" ).run(userId); } return result; } module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin };