484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
import type { Db } from '../types/db';
|
|
import type { Req } from '../types/http';
|
|
|
|
const crypto = require('crypto');
|
|
const { log } = require('../utils/logger.cts');
|
|
const bcrypt = require('bcryptjs');
|
|
const { getDb } = require('../db/database.cts');
|
|
const { buildDeviceFingerprint } = require('./loginFingerprint.cts');
|
|
const { encryptSecret, decryptSecret } = require('./encryptionService.cts');
|
|
|
|
// Store SHA-256(token) in the DB; the raw token stays only in the cookie.
|
|
function hashSession(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
const COOKIE_NAME = 'bt_session';
|
|
const SINGLE_COOKIE_NAME = 'bt_single_session';
|
|
const SESSION_DAYS = 7;
|
|
|
|
// Pre-computed hash used to equalize login timing when the account is unknown,
|
|
// inactive, or OIDC-only. Cost factor 12 matches real password hashes.
|
|
const TIMING_EQUALIZATION_HASH = bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 12);
|
|
|
|
function envFlag(name: string): boolean | null {
|
|
const value = process.env[name];
|
|
if (value === undefined) return null;
|
|
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
|
|
}
|
|
|
|
function requestLooksHttps(req?: Req): boolean {
|
|
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?: 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: any, password: any) {
|
|
const db: 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.
|
|
if (!user || user.active === 0 || (user.auth_provider && user.auth_provider !== 'local')) {
|
|
await bcrypt.compare(password, TIMING_EQUALIZATION_HASH);
|
|
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.
|
|
if (!valid) return { error: 'bad_password', userId: user.id };
|
|
|
|
// TOTP is enabled — issue a short-lived challenge instead of a session
|
|
if (user.totp_enabled) {
|
|
const { createChallenge } = require('./totpService.cts');
|
|
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.cts');
|
|
try {
|
|
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 };
|
|
} catch (err: any) {
|
|
// ONLY the stale-flag case (enabled but no usable credentials) may fall
|
|
// through to password-only login — a hard failure there would lock the
|
|
// account out entirely. Any other error (DB/crypto hiccup) rethrows:
|
|
// silently downgrading 2FA to password-only on a transient fault would
|
|
// let an attacker with the password ride out the second factor.
|
|
if (err.message !== 'No registered WebAuthn credentials') throw err;
|
|
log.warn(
|
|
`[auth] WebAuthn enabled for user ${user.id} but no usable credentials; proceeding with password login`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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: any) {
|
|
log.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: any) {
|
|
const db: 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: any) {
|
|
log.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: any): void {
|
|
if (!sessionId) return;
|
|
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId));
|
|
}
|
|
|
|
/**
|
|
* Regenerate session ID for security (e.g., on privilege escalation).
|
|
*/
|
|
function rotateSessionId(oldSessionId: any, userId: any): string | null {
|
|
if (!oldSessionId || !userId) return null;
|
|
|
|
const db: 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: any): any {
|
|
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: string): Promise<string> {
|
|
return bcrypt.hash(password, 12);
|
|
}
|
|
|
|
function publicUser(u: any) {
|
|
const displayName = u.display_name || null;
|
|
return {
|
|
id: u.id,
|
|
username: u.username,
|
|
display_name: displayName,
|
|
displayName,
|
|
name: displayName || u.username,
|
|
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.
|
|
*/
|
|
function recordLogin(userId: any, ipAddress: any, userAgent: any, sessionId: any): void {
|
|
const db: 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: any[];
|
|
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: any) => r.device_fingerprint);
|
|
} catch {
|
|
priorFingerprints = [];
|
|
}
|
|
|
|
let insertedId: any;
|
|
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 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: Response) => r.json())
|
|
.then((data: any) => {
|
|
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.cts')._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).
|
|
*/
|
|
function recordFailedLogin(userId: any, ipAddress: any, userAgent: any): void {
|
|
if (!userId) return;
|
|
try {
|
|
const db: 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();
|
|
log.info(`[cleanup] Purged ${result.changes} expired sessions`);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Invalidate all sessions for a user except for a specific session ID.
|
|
*/
|
|
function invalidateOtherSessions(userId: any, keepSessionId: any) {
|
|
if (!userId) return { changes: 0 };
|
|
|
|
const db: Db = getDb();
|
|
let result: any;
|
|
|
|
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,
|
|
};
|