diff --git a/routes/auth.cts b/routes/auth.cts index 306542e..8842ddb 100644 --- a/routes/auth.cts +++ b/routes/auth.cts @@ -1,7 +1,6 @@ // @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 { log } = require('../utils/logger.cts'); const router = express.Router(); let _appVersion; @@ -33,8 +32,13 @@ 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 { ValidationError, formatError } = require('../utils/apiError.cts'); -const { standardizeError } = require('../middleware/errorFormatter.cts'); +const { + ApiError, + ValidationError, + AuthError, + ForbiddenError, + NotFoundError, +} = require('../utils/apiError.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { logAudit } = require('../services/auditService.cts'); @@ -54,75 +58,58 @@ router.post( async (req: Req, res: 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', - ), - ); + throw ForbiddenError('Local username/password login is not enabled on this server.'); } const { username, password } = req.body; if (!username || !password) { - return res - .status(400) - .json( - standardizeError( - 'Username and password are required', - 'VALIDATION_ERROR', - !username ? 'username' : 'password', - ), - ); + throw ValidationError( + 'Username and password are required', + !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 }); - } - - // 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, - }); - } - + const result = await login(username, password); + if (!result || result.error) { logAudit({ - user_id: result.user.id, - action: 'login.success', + user_id: null, + action: 'login.failure', + details: { username }, 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) { - log.error('Login error:', err); - res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); + // 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 }); }, ); @@ -266,20 +253,14 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService. router.post('/totp/challenge', async (req: Req, res: 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')); + if (!challenge_token) throw ValidationError('challenge_token is required'); 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')); + 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) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); + if (!user) throw AuthError('User not found.'); let verified = false; if (recovery_code) { @@ -299,70 +280,42 @@ router.post('/totp/challenge', async (req: Req, res: Res) => { ip_address: req.ip, user_agent: req.get('user-agent'), }); - return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR')); + throw AuthError('Invalid authenticator code.'); } - try { - const { createSession } = require('../services/authService.cts'); - const session = await createSession(userId); - if (!session) - return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); + 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); + 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) { - log.error('[totp/challenge]', err); - res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); - } + 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) - 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) { - log.error('[totp/setup]', err); - res.status(500).json(standardizeError('Failed to generate setup data', 'SERVER_ERROR')); - } + 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) - return res - .status(400) - .json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR')); + if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.'); const { secret, code } = req.body || {}; - if (!secret || !code) - return res - .status(400) - .json(standardizeError('secret and code are required', 'VALIDATION_ERROR')); + if (!secret || !code) throw ValidationError('secret and code are required'); if (!verifyTokenRaw(secret, code)) - return res - .status(400) - .json( - standardizeError( - 'Invalid authenticator code. Check your app and try again.', - 'VALIDATION_ERROR', - 'code', - ), - ); + throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code'); const plainCodes = generateRecoveryCodes(); const hashedCodes = plainCodes.map(hashRecoveryCode); @@ -388,8 +341,7 @@ 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) - return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR')); + if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.'); let verified = false; if (recovery_code) { @@ -398,9 +350,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => { verified = verifyToken(user.totp_secret, code); } if (!verified) - return res - .status(401) - .json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code')); + 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=?`, @@ -452,9 +402,7 @@ router.get('/mode', (req: Req, res: Res) => { // 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') { - return res - .status(400) - .json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode')); + throw ValidationError('Single-user mode is not enabled.', 'auth_mode'); } setSetting('auth_mode', 'multi'); @@ -479,62 +427,47 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r 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', - ), - ); + 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); - 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) { - log.error('[auth] change-password error:', err.message); - res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR')); + 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 ───────────────────────────────────────────── @@ -568,138 +501,96 @@ router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => { // GET /api/auth/webauthn/setup — begin registration router.get('/webauthn/setup', requireAuth, async (req: Req, res: 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) { - log.error('[webauthn/setup]', err); - res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR')); - } + 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) - return res - .status(400) - .json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR')); + if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.'); const { challengeId, response, credential_name } = req.body || {}; - if (!challengeId || !response) - return res - .status(400) - .json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR')); + if (!challengeId || !response) throw ValidationError('challengeId and response are required'); - 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')); + const db = getDb(); + const result = await verifyRegistration(db, req.user.id, challengeId, response, credential_name); + 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); + 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) { - log.error('[webauthn/enable]', err); - res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR')); - } + 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) - return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR')); + 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); - 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 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) - return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND')); + 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); + 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) { - log.error('[webauthn/credentials/delete]', err); - res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR')); - } + 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) - return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR')); + 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) - return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR')); + if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.'); - 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 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); + 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) { - log.error('[webauthn/disable]', err); - res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR')); - } + 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. @@ -708,59 +599,41 @@ router.post('/webauthn/challenge', async (req: Req, res: 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')); + throw ValidationError('challenge_token and response are required'); 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')); + 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) 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.cts'); - const s = await createSession(session.userId); - if (!s) - return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); + if (!user) throw AuthError('User not found.'); + const result = await verifyAuthentication(db, session.userId, session.authChallengeId, response); + if (!result.verified) { logAudit({ user_id: session.userId, - action: 'login.success', - details: { method: 'webauthn' }, + action: 'webauthn.failure', 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) { - log.error('[webauthn/challenge]', err); - res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); + 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; diff --git a/tests/authLoginWebauthnRoute.test.js b/tests/authLoginWebauthnRoute.test.js index dbbfd18..2dba596 100644 --- a/tests/authLoginWebauthnRoute.test.js +++ b/tests/authLoginWebauthnRoute.test.js @@ -14,6 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-webauthn-login-${process.pid process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database.cts'); +const { formatError } = require('../utils/apiError.cts'); const { hashPassword } = require('../services/authService.cts'); const router = require('../routes/auth.cts'); @@ -50,7 +51,15 @@ function callLogin(body) { resolve({ status: this.statusCode, body: data, cookies }); }, }; - Promise.resolve(h(req, res)).catch(reject); + // Routes follow the throw-pattern: mirror the app's terminal error handler + // so thrown/rejected ApiErrors resolve to the same wire shape. + Promise.resolve(h(req, res)).catch((err) => { + if (err && (err.status || err.name === 'ApiError')) { + resolve({ status: err.status || 500, body: formatError(err), cookies }); + } else { + reject(err); + } + }); }); }