BillTracker/routes/auth.js

304 lines
12 KiB
JavaScript

const express = require('express');
const router = express.Router();
let _appVersion;
function getAppVersion() {
if (!_appVersion) {
try { _appVersion = require('../package.json').version; } catch { _appVersion = '0.0.0'; }
}
return _appVersion;
}
const { getDb, getSetting, setSetting } = require('../db/database');
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService');
const { decryptSecret } = require('../services/encryptionService');
const { getCsrfToken } = require('../middleware/csrf');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
const { getPublicOidcInfo } = require('../services/oidcService');
const { ValidationError, formatError } = require('../utils/apiError');
const { standardizeError } = require('../middleware/errorFormatter');
const { passwordLimiter } = require('../middleware/rateLimiter');
const { logAudit } = require('../services/auditService');
// ─────────────────────────────────────────
// PUBLIC AUTH ROUTES
// ─────────────────────────────────────────
// POST /api/auth/login
router.post('/login', (req, res, next) => {
// Exempt login from CSRF - no session exists yet to hijack
// CSRF validation happens on all other authenticated routes
req.csrfSkip = true;
next();
}, async (req, res) => {
// Respect admin-configured login method toggle
if (getSetting('local_login_enabled') === 'false') {
return res.status(403).json(standardizeError('Local username/password login is not enabled on this server.', 'FORBIDDEN'));
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json(standardizeError('Username and password are required', 'VALIDATION_ERROR', !username ? 'username' : 'password'));
}
try {
const result = await login(username, password);
if (!result || result.error) {
logAudit({ user_id: null, action: 'login.failure', details: { username }, ip_address: req.ip, user_agent: req.get('user-agent') });
// Track failed attempt against known accounts (wrong password only — not unknown usernames)
if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
}
return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR'));
}
logAudit({ user_id: result.user.id, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') });
recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId);
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user });
} catch (err) {
console.error('Login error:', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
}
});
// GET /api/auth/csrf-token
// Public — returns the CSRF token from the httpOnly cookie so the SPA can
// store it in memory and send it in the x-csrf-token header for mutations.
// Cross-origin access is prevented by the same-origin fetch policy (no CORS).
router.get('/csrf-token', (req, res) => {
const token = getCsrfToken(req, res);
res.json({ token });
});
// POST /api/auth/logout
router.post('/logout', requireAuth, (req, res) => {
logout(req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout', ip_address: req.ip, user_agent: req.get('user-agent') });
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
res.json({ success: true });
});
// POST /api/auth/logout-all
router.post('/logout-all', requireAuth, (req, res) => {
// Delete ALL sessions for this user
invalidateOtherSessions(req.user.id, null); // null means delete all sessions
// Also clear the current session
logout(req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout.all', ip_address: req.ip, user_agent: req.get('user-agent') });
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
res.json({ success: true });
});
// GET /api/auth/me
router.get('/me', requireAuth, (req, res) => {
const currentVersion = getAppVersion();
res.json({
user: req.user,
single_user_mode: !!req.singleUserMode,
current_version: currentVersion,
release_notes_version: currentVersion,
has_new_version: req.user.last_seen_version !== currentVersion,
});
});
// GET /api/auth/login-history — last 10 logins for the authenticated user (encrypted at rest, decrypted here)
router.get('/login-history', requireAuth, (req, res) => {
const db = getDb();
const rows = db.prepare(`
SELECT id, logged_in_at, ip_address, user_agent,
browser, os, device_type, device_fingerprint, session_fingerprint,
success, location_city, location_country, location_region, location_isp
FROM user_login_history
WHERE user_id = ?
ORDER BY logged_in_at DESC
LIMIT 10
`).all(req.user.id);
const safeDecrypt = v => {
if (!v) return null;
try { return decryptSecret(v); } catch { return null; }
};
// Compute fingerprint of the current session cookie to mark "this session"
const currentCookie = req.cookies?.[COOKIE_NAME];
const currentFingerprint = currentCookie
? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32)
: null;
const history = rows.map(r => ({
id: r.id,
logged_in_at: r.logged_in_at,
ip_address: safeDecrypt(r.ip_address),
user_agent: safeDecrypt(r.user_agent),
browser: r.browser,
os: r.os,
device_type: r.device_type,
device_fingerprint: r.device_fingerprint,
success: r.success !== 0,
is_current_session: !!(currentFingerprint && r.session_fingerprint === currentFingerprint),
location_city: safeDecrypt(r.location_city),
location_country: safeDecrypt(r.location_country),
location_region: safeDecrypt(r.location_region),
location_isp: safeDecrypt(r.location_isp),
}));
res.json({ history });
});
// POST /api/auth/acknowledge-version — user has seen the release notes
router.post('/acknowledge-version', requireAuth, (req, res) => {
const currentVersion = getAppVersion();
getDb()
.prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?")
.run(currentVersion, req.user.id);
res.json({ success: true, last_seen_version: currentVersion, release_notes_version: currentVersion });
});
// GET /api/auth/mode
// Public — tells the login page which options are available.
// Never returns secrets. local_enabled/oidc_enabled reflect admin settings.
router.get('/mode', (req, res) => {
const oidcInfo = getPublicOidcInfo();
const localEnabled = getSetting('local_login_enabled') !== 'false';
res.json({
auth_mode: getSetting('auth_mode') || 'multi',
local_enabled: localEnabled,
...oidcInfo,
});
});
// POST /api/auth/restore-multi-user-mode
// Recovery path for single-user mode. In single-user mode requireAuth attaches
// the configured default user, so this lets that Settings page restore normal
// login without needing access to Admin routes.
router.post('/restore-multi-user-mode', requireAuth, (req, res) => {
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
return res.status(400).json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
}
setSetting('auth_mode', 'multi');
setSetting('default_user_id', '');
res.json({ success: true, auth_mode: 'multi' });
});
// POST /api/auth/acknowledge-privacy
router.post('/acknowledge-privacy', requireAuth, (req, res) => {
getDb().prepare(
"UPDATE users SET first_login = 0, updated_at = datetime('now') WHERE id = ?"
).run(req.user.id);
res.json({ success: true });
});
// POST /api/auth/change-password
// Password change endpoint with dedicated rate limiter
// CSRF protected via csrfMiddleware on /api/auth mount
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 8) {
return res.status(400).json(standardizeError('New password must be at least 8 characters', 'VALIDATION_ERROR', 'new_password'));
}
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
try {
if (!user.must_change_password) {
const bcrypt = require('bcryptjs');
const valid = await bcrypt.compare(current_password || '', user.password_hash);
if (!valid) return res.status(401).json(standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'));
}
const hash = await hashPassword(new_password);
db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
}
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
res.json({ success: true });
} catch (err) {
console.error('[auth] change-password error:', err.message);
res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
}
});
// ─────────────────────────────────────────
// ADMIN ROUTES (MOUNTED AT /api/admin)
// ─────────────────────────────────────────
// GET /api/admin/has-users
router.get('/has-users', (req, res) => {
const count = getDb()
.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'")
.get().n;
res.json({ has_users: count > 0 });
});
// GET /api/admin/users
router.get('/users', requireAuth, requireAdmin, (req, res) => {
const users = getDb().prepare(
"SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC"
).all();
res.json(users);
});
// POST /api/admin/users
router.post('/users', requireAuth, requireAdmin, async (req, res) => {
const { username, password } = req.body;
if (!username || username.length < 3) {
return res.status(400).json(standardizeError('Username must be at least 3 characters', 'VALIDATION_ERROR', 'username'));
}
if (!password || password.length < 8) {
return res.status(400).json(standardizeError('Password must be at least 8 characters', 'VALIDATION_ERROR', 'password'));
}
const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) return res.status(409).json(standardizeError('Username already taken', 'CONFLICT', 'username'));
try {
const hash = await hashPassword(password);
const result = db.prepare(
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)"
).run(username, hash, getAppVersion());
const created = db.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
res.status(201).json(created);
} catch (err) {
console.error('[auth] create-user error:', err.message);
res.status(500).json(standardizeError('Failed to create user', 'SERVER_ERROR'));
}
});
module.exports = router;