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, SINGLE_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')); } // TOTP required — don't create a session yet if (result.requires_totp) { return res.json({ requires_totp: true, challenge_token: result.challenge_token }); } 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". // Single-user mode has no COOKIE_NAME — use the presence cookie instead. const currentCookie = req.singleUserMode ? req.cookies?.[SINGLE_COOKIE_NAME] : 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 }); }); // ── TOTP / Authenticator App ───────────────────────────────────────────────── const { generateSecret, generateQrCode, verifyToken, verifyTokenRaw, generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, createChallenge, consumeChallenge, } = require('../services/totpService'); const { encryptSecret: encTotpSecret } = require('../services/encryptionService'); // POST /api/auth/totp/challenge — second step of login when TOTP is enabled. // Takes challenge_token (from first login step) + totp_code, creates a session. router.post('/totp/challenge', async (req, res) => { req.csrfSkip = true; const { challenge_token, code, recovery_code } = req.body || {}; if (!challenge_token) return res.status(400).json(standardizeError('challenge_token is required', 'VALIDATION_ERROR')); const db = getDb(); const userId = consumeChallenge(db, challenge_token); if (!userId) return res.status(401).json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR')); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId); if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); let verified = false; if (recovery_code) { const result = consumeRecoveryCode(db, userId, recovery_code); verified = result.used; if (verified && result.remaining === 0) { // Warn but don't block — they're in, but should regenerate codes } } else if (code) { verified = verifyToken(user.totp_secret, code); } if (!verified) { logAudit({ user_id: userId, action: 'totp.failure', ip_address: req.ip, user_agent: req.get('user-agent') }); return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR')); } try { const { createSession } = require('../services/authService'); const session = await createSession(userId); if (!session) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); logAudit({ user_id: userId, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') }); recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId); res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req)); res.json({ user: session.user }); } catch (err) { console.error('[totp/challenge]', err); res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); } }); // GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user. // The secret is NOT saved yet; the user must confirm a valid code via /totp/enable. router.get('/totp/setup', requireAuth, async (req, res) => { if (req.singleUserMode) return res.status(400).json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR')); try { const secret = generateSecret(); const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id); const { uri, qr_data_url } = await generateQrCode(secret, user.username); res.json({ secret, uri, qr_data_url }); } catch (err) { console.error('[totp/setup]', err); res.status(500).json(standardizeError('Failed to generate setup data', 'SERVER_ERROR')); } }); // POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP. router.post('/totp/enable', requireAuth, (req, res) => { if (req.singleUserMode) return res.status(400).json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR')); const { secret, code } = req.body || {}; if (!secret || !code) return res.status(400).json(standardizeError('secret and code are required', 'VALIDATION_ERROR')); if (!verifyTokenRaw(secret, code)) return res.status(400).json(standardizeError('Invalid authenticator code. Check your app and try again.', 'VALIDATION_ERROR', 'code')); const plainCodes = generateRecoveryCodes(); const hashedCodes = plainCodes.map(hashRecoveryCode); const db = getDb(); db.prepare(` UPDATE users SET totp_enabled=1, totp_secret=?, totp_recovery_codes=?, updated_at=datetime('now') WHERE id=? `).run(encTotpSecret(secret), encTotpSecret(JSON.stringify(hashedCodes)), req.user.id); logAudit({ user_id: req.user.id, action: 'totp.enabled', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ enabled: true, recovery_codes: plainCodes }); }); // POST /api/auth/totp/disable — disable TOTP. Requires a valid TOTP code or recovery code. router.post('/totp/disable', requireAuth, (req, res) => { const { code, recovery_code } = req.body || {}; const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); if (!user?.totp_enabled) return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR')); let verified = false; if (recovery_code) { verified = consumeRecoveryCode(db, req.user.id, recovery_code).used; } else if (code) { verified = verifyToken(user.totp_secret, code); } if (!verified) return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code')); db.prepare(`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`) .run(req.user.id); logAudit({ user_id: req.user.id, action: 'totp.disabled', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ enabled: false }); }); // GET /api/auth/totp/status — is TOTP enabled for the current user? router.get('/totp/status', requireAuth, (req, res) => { const user = getDb().prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id); res.json({ enabled: !!user?.totp_enabled }); }); // POST /api/auth/totp/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')); } }); // ── WebAuthn / FIDO2 security key ───────────────────────────────────────────── const { createRegistrationChallenge, verifyRegistration, createAuthenticationChallenge, verifyAuthentication, consumeLoginChallenge, getCredentials, deleteCredential, } = require('../services/webauthnService'); // GET /api/auth/webauthn/status router.get('/webauthn/status', requireAuth, (req, res) => { if (req.singleUserMode) return res.json({ enabled: false, credential_count: 0 }); const db = getDb(); const user = db.prepare('SELECT webauthn_enabled FROM users WHERE id = ?').get(req.user.id); const { n } = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(req.user.id); res.json({ enabled: !!user?.webauthn_enabled, credential_count: n }); }); // GET /api/auth/webauthn/credentials router.get('/webauthn/credentials', requireAuth, (req, res) => { if (req.singleUserMode) return res.json({ credentials: [] }); res.json({ credentials: getCredentials(getDb(), req.user.id) }); }); // GET /api/auth/webauthn/setup — begin registration router.get('/webauthn/setup', requireAuth, async (req, res) => { if (req.singleUserMode) return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR')); try { const { options, challengeId } = await createRegistrationChallenge(getDb(), req.user.id, req.user.username); res.json({ options, challengeId }); } catch (err) { console.error('[webauthn/setup]', err); res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR')); } }); // POST /api/auth/webauthn/enable — complete registration router.post('/webauthn/enable', requireAuth, async (req, res) => { if (req.singleUserMode) return res.status(400).json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR')); const { challengeId, response, credential_name } = req.body || {}; if (!challengeId || !response) return res.status(400).json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR')); try { const db = getDb(); const result = await verifyRegistration(db, req.user.id, challengeId, response, credential_name); if (!result.verified) return res.status(400).json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR')); db.prepare("UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?").run(req.user.id); logAudit({ user_id: req.user.id, action: 'webauthn.credential_added', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ enabled: true, credential_id: result.credentialId }); } catch (err) { console.error('[webauthn/enable]', err); res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR')); } }); // DELETE /api/auth/webauthn/credentials/:credentialId — remove one key router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req, res) => { const { password } = req.body || {}; if (!password) return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR')); const db = getDb(); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); try { const bcrypt = require('bcryptjs'); if (!await bcrypt.compare(password, user.password_hash)) return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR')); const result = deleteCredential(db, req.params.credentialId, req.user.id); if (result.changes === 0) return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND')); const { n } = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(req.user.id); if (n === 0) db.prepare("UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?").run(req.user.id); logAudit({ user_id: req.user.id, action: 'webauthn.credential_removed', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ success: true, webauthn_enabled: n > 0 }); } catch (err) { console.error('[webauthn/credentials/delete]', err); res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR')); } }); // POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn router.post('/webauthn/disable', requireAuth, async (req, res) => { const { password } = req.body || {}; if (!password) return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR')); const db = getDb(); const user = db.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?').get(req.user.id); if (!user?.webauthn_enabled) return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR')); try { const bcrypt = require('bcryptjs'); if (!await bcrypt.compare(password, user.password_hash)) return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR')); db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id); db.prepare("UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?").run(req.user.id); logAudit({ user_id: req.user.id, action: 'webauthn.disabled', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ enabled: false }); } catch (err) { console.error('[webauthn/disable]', err); res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR')); } }); // POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled. // Mirrors POST /totp/challenge exactly. router.post('/webauthn/challenge', async (req, res) => { req.csrfSkip = true; const { challenge_token, response } = req.body || {}; if (!challenge_token || !response) return res.status(400).json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR')); const db = getDb(); const session = consumeLoginChallenge(db, challenge_token); if (!session) return res.status(401).json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR')); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId); if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); try { const result = await verifyAuthentication(db, session.userId, session.authChallengeId, response); if (!result.verified) { logAudit({ user_id: session.userId, action: 'webauthn.failure', ip_address: req.ip, user_agent: req.get('user-agent') }); return res.status(401).json(standardizeError('Security key verification failed.', 'AUTH_ERROR')); } const { createSession } = require('../services/authService'); const s = await createSession(session.userId); if (!s) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); logAudit({ user_id: session.userId, action: 'login.success', details: { method: 'webauthn' }, ip_address: req.ip, user_agent: req.get('user-agent') }); recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId); res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req)); res.json({ user: s.user }); } catch (err) { console.error('[webauthn/challenge]', err); res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); } }); module.exports = router;