BillTracker/services/authService.js

395 lines
14 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database');
2026-05-15 22:45:38 -05:00
const { buildDeviceFingerprint } = require('./loginFingerprint');
const { encryptSecret, decryptSecret } = require('./encryptionService');
2026-05-03 19:51:57 -05:00
// 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');
}
2026-05-03 19:51:57 -05:00
const COOKIE_NAME = 'bt_session';
const SINGLE_COOKIE_NAME = 'bt_single_session';
2026-05-03 19:51:57 -05:00
const SESSION_DAYS = 7;
// Pre-computed hash used to equalize login timing when the account is unknown,
// inactive, or OIDC-only. Without it those paths skip bcrypt.compare and
// respond measurably faster, letting an attacker enumerate valid usernames.
// Cost factor 12 matches real password hashes. Computed once at module load.
const TIMING_EQUALIZATION_HASH = bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 12);
2026-05-03 19:51:57 -05:00
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);
// Unknown, inactive, or OIDC-only account: still burn a bcrypt comparison so
// the response time is indistinguishable from a wrong password (no timing
// oracle for username enumeration).
if (!user || user.active === 0 || (user.auth_provider && user.auth_provider !== 'local')) {
await bcrypt.compare(password, TIMING_EQUALIZATION_HASH);
2026-05-03 19:51:57 -05:00
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 };
2026-05-03 19:51:57 -05:00
// 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);
}
2026-05-03 19:51:57 -05:00
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);
2026-05-03 19:51:57 -05:00
// 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;
2026-05-04 23:34:24 -05:00
if (user.active === 0) return null;
2026-05-03 19:51:57 -05:00
// 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);
}
2026-05-03 19:51:57 -05:00
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);
2026-05-03 19:51:57 -05:00
return { sessionId, user: publicUser(user) };
}
function logout(sessionId) {
if (!sessionId) return;
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId));
2026-05-03 19:51:57 -05:00
}
2026-05-09 13:03:36 -05:00
/**
* 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));
2026-05-09 13:03:36 -05:00
if (!existingSession || existingSession.user_id !== userId) {
return null;
}
2026-05-09 13:03:36 -05:00
// 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;
2026-05-09 13:03:36 -05:00
}
2026-05-03 19:51:57 -05:00
function getSessionUser(sessionId) {
if (!sessionId) return null;
const row = getDb().prepare(`
2026-05-04 23:34:24 -05:00
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login,
2026-05-14 21:00:07 -05:00
u.active, u.is_default_admin, u.last_seen_version
2026-05-03 19:51:57 -05:00
FROM sessions s
JOIN users u ON u.id = s.user_id
2026-05-04 23:34:24 -05:00
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
`).get(hashSession(sessionId));
2026-05-03 19:51:57 -05:00
return row || null;
}
async function hashPassword(password) {
return bcrypt.hash(password, 12);
}
function publicUser(u) {
const displayName = u.display_name || null;
2026-05-03 19:51:57 -05:00
return {
id: u.id,
username: u.username,
display_name: displayName,
displayName,
name: displayName || u.username,
2026-05-03 19:51:57 -05:00
role: u.role,
2026-05-04 23:34:24 -05:00
active: u.active !== 0,
is_default_admin: !!u.is_default_admin,
2026-05-03 19:51:57 -05:00
must_change_password: !!u.must_change_password,
first_login: !!u.first_login,
2026-05-14 21:00:07 -05:00
last_seen_version: u.last_seen_version || null,
webauthn_enabled: !!u.webauthn_enabled,
2026-05-03 19:51:57 -05:00
};
}
2026-05-15 01:36:56 -05:00
/**
* 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.
2026-05-15 01:36:56 -05:00
*/
function recordLogin(userId, ipAddress, userAgent, sessionId) {
2026-05-15 01:36:56 -05:00
const db = getDb();
2026-05-15 22:45:38 -05:00
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;
2026-05-15 01:36:56 -05:00
db.transaction(() => {
const result = db.prepare(`
2026-05-15 22:45:38 -05:00
INSERT INTO user_login_history (
user_id, logged_in_at, ip_address, user_agent,
browser, os, device_type, device_fingerprint, session_fingerprint, success
2026-05-15 22:45:38 -05:00
)
VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, 1)
2026-05-15 22:45:38 -05:00
`).run(
userId,
encIp,
encUa,
2026-05-15 22:45:38 -05:00
device.browser,
device.os,
device.device_type,
device.device_fingerprint,
sessionFingerprint,
2026-05-15 22:45:38 -05:00
);
insertedId = result.lastInsertRowid;
2026-05-15 01:36:56 -05:00
// Keep only the 10 most recent rows for this user
2026-05-15 01:36:56 -05:00
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
2026-05-15 01:36:56 -05:00
)
`).run(userId, userId);
})();
// Background tasks: geolocation + new device push alert
if (insertedId) {
setImmediate(() => {
// Geolocation — only when the user has opted in, and skip private/loopback IPs
const loginUser = getDb().prepare('SELECT geolocation_enabled FROM users WHERE id = ?').get(userId);
if (ipAddress && loginUser?.geolocation_enabled) {
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`, { signal: AbortSignal.timeout(5000) })
.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 */ }
2026-05-15 01:36:56 -05:00
}
2026-05-03 19:51:57 -05:00
// 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;
2026-05-03 19:51:57 -05:00
}
/**
* 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 };