// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). import type { Req, Res, Next } from '../types/http'; 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.cts'); const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin, } = require('../services/authService.cts'); const { decryptSecret } = require('../services/encryptionService.cts'); const { getCsrfToken } = require('../middleware/csrf.cts'); const { requireAuth } = require('../middleware/requireAuth.cts'); const { getPublicOidcInfo } = require('../services/oidcService.cts'); const { ApiError, ValidationError, AuthError, ForbiddenError, NotFoundError, } = require('../utils/apiError.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { logAudit } = require('../services/auditService.cts'); // ───────────────────────────────────────── // PUBLIC AUTH ROUTES // ───────────────────────────────────────── // POST /api/auth/login router.post( '/login', (req: Req, res: Res, next: 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: Req, res: Res) => { // Respect admin-configured login method toggle if (getSetting('local_login_enabled') === 'false') { throw ForbiddenError('Local username/password login is not enabled on this server.'); } const { username, password } = req.body; if (!username || !password) { throw ValidationError( 'Username and password are required', !username ? 'username' : 'password', ); } 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')); } throw AuthError('Invalid username or password'); } // TOTP required — don't create a session yet if (result.requires_totp) { return res.json({ requires_totp: true, challenge_token: result.challenge_token }); } // WebAuthn required — same two-step shape as TOTP; the session is only // created after POST /webauthn/challenge verifies the key. if (result.requires_webauthn) { return res.json({ requires_webauthn: true, challenge_token: result.challenge_token, webauthn_options: result.webauthn_options, }); } 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 }); }, ); // 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: Req, res: Res) => { const token = getCsrfToken(req, res); res.json({ token }); }); // POST /api/auth/logout router.post('/logout', requireAuth, (req: Req, res: 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: Req, res: 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 }); }); // POST /api/auth/logout-others — sign out every OTHER device, keep this one signed in. router.post('/logout-others', requireAuth, (req: Req, res: Res) => { const result = invalidateOtherSessions(req.user.id, req.cookies?.[COOKIE_NAME]); logAudit({ user_id: req.user.id, action: 'logout.others', ip_address: req.ip, user_agent: req.get('user-agent'), }); res.json({ success: true, count: result?.changes || 0 }); }); // GET /api/auth/me router.get('/me', requireAuth, (req: Req, res: 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: Req, res: 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.cts'); const { encryptSecret: encTotpSecret } = require('../services/encryptionService.cts'); // 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: Req, res: Res) => { req.csrfSkip = true; const { challenge_token, code, recovery_code } = req.body || {}; if (!challenge_token) throw ValidationError('challenge_token is required'); const db = getDb(); const userId = consumeChallenge(db, challenge_token); if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.'); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId); if (!user) throw AuthError('User not found.'); 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'), }); throw AuthError('Invalid authenticator code.'); } const { createSession } = require('../services/authService.cts'); const session = await createSession(userId); if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500); 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 }); }); // 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: Req, res: Res) => { if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.'); 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 }); }); // POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP. router.post('/totp/enable', requireAuth, (req: Req, res: Res) => { if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.'); const { secret, code } = req.body || {}; if (!secret || !code) throw ValidationError('secret and code are required'); if (!verifyTokenRaw(secret, code)) throw ValidationError('Invalid authenticator code. Check your app and try again.', '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: Req, res: 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) throw ValidationError('TOTP is not enabled.'); 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) throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: '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: Req, res: 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: Req, res: 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: Req, res: 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: Req, res: Res) => { if (!req.singleUserMode && getSetting('auth_mode') !== 'single') { throw ValidationError('Single-user mode is not enabled.', '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: Req, res: 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: Req, res: Res) => { const { current_password, new_password } = req.body; if (!new_password || new_password.length < 8) { throw ValidationError('New password must be at least 8 characters', 'new_password'); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); if (!user.must_change_password) { const bcrypt = require('bcryptjs'); const valid = await bcrypt.compare(current_password || '', user.password_hash); if (!valid) throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, { field: '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 }); }); // ── WebAuthn / FIDO2 security key ───────────────────────────────────────────── const { createRegistrationChallenge, verifyRegistration, createAuthenticationChallenge, verifyAuthentication, consumeLoginChallenge, getCredentials, deleteCredential, } = require('../services/webauthnService.cts'); // GET /api/auth/webauthn/status router.get('/webauthn/status', requireAuth, (req: Req, res: 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: Req, res: 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: Req, res: Res) => { if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.'); const { options, challengeId } = await createRegistrationChallenge( getDb(), req.user.id, req.user.username, ); res.json({ options, challengeId }); }); // POST /api/auth/webauthn/enable — complete registration router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => { if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.'); const { challengeId, response, credential_name } = req.body || {}; if (!challengeId || !response) throw ValidationError('challengeId and response are required'); const db = getDb(); const result = await verifyRegistration( db, req.user.id, challengeId, response, credential_name, req.get('origin'), ); if (!result.verified) throw ValidationError(result.error || 'Registration failed'); 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 }); }); // DELETE /api/auth/webauthn/credentials/:credentialId — remove one key router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => { const { password } = req.body || {}; if (!password) throw ValidationError('password is required'); const db = getDb(); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); const bcrypt = require('bcryptjs'); if (!(await bcrypt.compare(password, user.password_hash))) throw AuthError('Password is incorrect'); const result = deleteCredential(db, req.params.credentialId, req.user.id); if (result.changes === 0) throw NotFoundError('Credential 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 }); }); // POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => { const { password } = req.body || {}; if (!password) throw ValidationError('password is required'); const db = getDb(); const user = db .prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?') .get(req.user.id); if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.'); const bcrypt = require('bcryptjs'); if (!(await bcrypt.compare(password, user.password_hash))) throw AuthError('Password is incorrect'); 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 }); }); // POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled. // Mirrors POST /totp/challenge exactly. router.post('/webauthn/challenge', async (req: Req, res: Res) => { req.csrfSkip = true; const { challenge_token, response } = req.body || {}; if (!challenge_token || !response) throw ValidationError('challenge_token and response are required'); const db = getDb(); const session = consumeLoginChallenge(db, challenge_token); if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.'); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId); if (!user) throw AuthError('User not found.'); const result = await verifyAuthentication( db, session.userId, session.authChallengeId, response, req.get('origin'), ); if (!result.verified) { logAudit({ user_id: session.userId, action: 'webauthn.failure', ip_address: req.ip, user_agent: req.get('user-agent'), }); throw AuthError('Security key verification failed.'); } const { createSession } = require('../services/authService.cts'); const s = await createSession(session.userId); if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500); 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 }); }); module.exports = router;