const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { getDb } = require('../db/database'); const COOKIE_NAME = 'bt_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); if (!valid) return null; 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(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) }; } /** * Create a session for a user who has already been authenticated externally * (e.g. via OIDC). Does not verify credentials — the caller is responsible * for authentication before calling this. */ 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; 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(sessionId, user.id, expiresAt); return { sessionId, user: publicUser(user) }; } function logout(sessionId) { if (!sessionId) return; getDb().prepare('DELETE FROM sessions WHERE id = ?').run(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(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 in a transaction db.prepare('BEGIN').run(); try { db.prepare('DELETE FROM sessions WHERE id = ?').run(oldSessionId); db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)') .run(newSessionId, userId, expiresAt); db.prepare('COMMIT').run(); return newSessionId; } catch (err) { db.prepare('ROLLBACK').run(); throw err; } } 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 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(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, }; } // Prune expired sessions — called by daily worker function pruneExpiredSessions() { getDb().prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); } module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId };