130 lines
3.9 KiB
JavaScript
130 lines
3.9 KiB
JavaScript
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;
|
|
|
|
// 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;
|
|
|
|
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);
|
|
}
|
|
|
|
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
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.id = ? AND s.expires_at > datetime('now')
|
|
`).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,
|
|
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 };
|