2026-07-06 11:26:50 -05:00
// @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' ;
2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
const router = express . Router ( ) ;
2026-05-14 21:00:07 -05:00
let _appVersion ;
function getAppVersion() {
if ( ! _appVersion ) {
try { _appVersion = require ( '../package.json' ) . version ; } catch { _appVersion = '0.0.0' ; }
}
return _appVersion ;
}
2026-05-03 19:51:57 -05:00
const { getDb , getSetting , setSetting } = require ( '../db/database' ) ;
2026-07-06 11:06:05 -05:00
const { login , logout , hashPassword , cookieOpts , COOKIE_NAME , SINGLE_COOKIE_NAME , rotateSessionId , invalidateOtherSessions , recordLogin , recordFailedLogin } = require ( '../services/authService.cts' ) ;
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { decryptSecret } = require ( '../services/encryptionService.cts' ) ;
2026-05-31 15:52:50 -05:00
const { getCsrfToken } = require ( '../middleware/csrf' ) ;
2026-05-03 19:51:57 -05:00
const { requireAuth , requireAdmin } = require ( '../middleware/requireAuth' ) ;
2026-07-06 11:22:44 -05:00
const { getPublicOidcInfo } = require ( '../services/oidcService.cts' ) ;
2026-07-06 10:47:16 -05:00
const { ValidationError , formatError } = require ( '../utils/apiError.cts' ) ;
2026-05-09 13:03:36 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
const { passwordLimiter } = require ( '../middleware/rateLimiter' ) ;
2026-07-06 10:47:16 -05:00
const { logAudit } = require ( '../services/auditService.cts' ) ;
2026-05-03 19:51:57 -05:00
// ─────────────────────────────────────────
// PUBLIC AUTH ROUTES
// ─────────────────────────────────────────
// POST /api/auth/login
2026-07-06 11:26:50 -05:00
router . post ( '/login' , ( req : Req , res : Res , next : Next ) = > {
2026-05-09 13:03:36 -05:00
// Exempt login from CSRF - no session exists yet to hijack
// CSRF validation happens on all other authenticated routes
req . csrfSkip = true ;
next ( ) ;
2026-07-06 11:26:50 -05:00
} , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
// Respect admin-configured login method toggle
if ( getSetting ( 'local_login_enabled' ) === 'false' ) {
2026-05-09 13:03:36 -05:00
return res . status ( 403 ) . json ( standardizeError ( 'Local username/password login is not enabled on this server.' , 'FORBIDDEN' ) ) ;
2026-05-03 19:51:57 -05:00
}
const { username , password } = req . body ;
if ( ! username || ! password ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'Username and password are required' , 'VALIDATION_ERROR' , ! username ? 'username' : 'password' ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-10 12:20:50 -05:00
try {
const result = await login ( username , password ) ;
2026-06-04 03:38:32 -05:00
if ( ! result || result . error ) {
2026-05-10 12:20:50 -05:00
logAudit ( { user_id : null , action : 'login.failure' , details : { username } , ip_address : req.ip , user_agent : req.get ( 'user-agent' ) } ) ;
2026-06-04 03:38:32 -05:00
// 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' ) ) ;
}
2026-05-10 12:20:50 -05:00
return res . status ( 401 ) . json ( standardizeError ( 'Invalid username or password' , 'AUTH_ERROR' ) ) ;
}
2026-05-03 19:51:57 -05:00
2026-06-04 04:10:14 -05:00
// TOTP required — don't create a session yet
if ( result . requires_totp ) {
return res . json ( { requires_totp : true , challenge_token : result.challenge_token } ) ;
}
2026-05-10 12:20:50 -05:00
logAudit ( { user_id : result.user.id , action : 'login.success' , ip_address : req.ip , user_agent : req.get ( 'user-agent' ) } ) ;
2026-06-04 03:38:32 -05:00
recordLogin ( result . user . id , req . ip , req . get ( 'user-agent' ) , result . sessionId ) ;
2026-05-10 00:03:12 -05:00
2026-05-10 12:20:50 -05:00
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' ) ) ;
}
2026-05-03 19:51:57 -05:00
} ) ;
2026-05-31 15:52:50 -05:00
// 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).
2026-07-06 11:26:50 -05:00
router . get ( '/csrf-token' , ( req : Req , res : Res ) = > {
2026-05-31 15:52:50 -05:00
const token = getCsrfToken ( req , res ) ;
res . json ( { token } ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// POST /api/auth/logout
2026-07-06 11:26:50 -05:00
router . post ( '/logout' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
logout ( req . cookies ? . [ COOKIE_NAME ] ) ;
2026-05-10 00:03:12 -05:00
logAudit ( { user_id : req.user.id , action : 'logout' , ip_address : req.ip , user_agent : req.get ( 'user-agent' ) } ) ;
2026-05-03 19:51:57 -05:00
res . clearCookie ( COOKIE_NAME , { path : '/' , . . . cookieOpts ( req ) , maxAge : undefined } ) ;
res . json ( { success : true } ) ;
} ) ;
2026-05-10 03:55:14 -05:00
// POST /api/auth/logout-all
2026-07-06 11:26:50 -05:00
router . post ( '/logout-all' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-10 03:55:14 -05:00
// Delete ALL sessions for this user
invalidateOtherSessions ( req . user . id , null ) ; // null means delete all sessions
2026-07-05 16:05:59 -05:00
2026-05-10 03:55:14 -05:00
// Also clear the current session
logout ( req . cookies ? . [ COOKIE_NAME ] ) ;
2026-07-05 16:05:59 -05:00
2026-05-10 03:55:14 -05:00
logAudit ( { user_id : req.user.id , action : 'logout.all' , ip_address : req.ip , user_agent : req.get ( 'user-agent' ) } ) ;
2026-07-05 16:05:59 -05:00
2026-05-10 03:55:14 -05:00
res . clearCookie ( COOKIE_NAME , { path : '/' , . . . cookieOpts ( req ) , maxAge : undefined } ) ;
res . json ( { success : true } ) ;
} ) ;
2026-07-05 16:05:59 -05:00
// POST /api/auth/logout-others — sign out every OTHER device, keep this one signed in.
2026-07-06 11:26:50 -05:00
router . post ( '/logout-others' , requireAuth , ( req : Req , res : Res ) = > {
2026-07-05 16:05:59 -05:00
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 } ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// GET /api/auth/me
2026-07-06 11:26:50 -05:00
router . get ( '/me' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-14 21:00:07 -05:00
const currentVersion = getAppVersion ( ) ;
2026-05-03 19:51:57 -05:00
res . json ( {
user : req.user ,
single_user_mode : ! ! req . singleUserMode ,
2026-05-14 21:00:07 -05:00
current_version : currentVersion ,
2026-05-15 22:45:38 -05:00
release_notes_version : currentVersion ,
2026-05-14 21:00:07 -05:00
has_new_version : req.user.last_seen_version !== currentVersion ,
2026-05-03 19:51:57 -05:00
} ) ;
} ) ;
2026-06-04 03:38:32 -05:00
// GET /api/auth/login-history — last 10 logins for the authenticated user (encrypted at rest, decrypted here)
2026-07-06 11:26:50 -05:00
router . get ( '/login-history' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-15 01:36:56 -05:00
const db = getDb ( ) ;
2026-06-04 03:38:32 -05:00
const rows = db . prepare ( `
2026-05-15 22:45:38 -05:00
SELECT id , logged_in_at , ip_address , user_agent ,
2026-06-04 03:38:32 -05:00
browser , os , device_type , device_fingerprint , session_fingerprint ,
success , location_city , location_country , location_region , location_isp
2026-05-15 01:36:56 -05:00
FROM user_login_history
WHERE user_id = ?
ORDER BY logged_in_at DESC
2026-06-04 03:38:32 -05:00
LIMIT 10
2026-05-15 01:36:56 -05:00
` ).all(req.user.id);
2026-06-04 03:38:32 -05:00
const safeDecrypt = v = > {
if ( ! v ) return null ;
try { return decryptSecret ( v ) ; } catch { return null ; }
} ;
2026-06-04 03:53:38 -05:00
// 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 ] ;
2026-06-04 03:38:32 -05:00
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 ) ,
} ) ) ;
2026-05-15 01:36:56 -05:00
res . json ( { history } ) ;
} ) ;
2026-06-04 04:10:14 -05:00
// ── TOTP / Authenticator App ─────────────────────────────────────────────────
const {
generateSecret , generateQrCode , verifyToken , verifyTokenRaw ,
generateRecoveryCodes , hashRecoveryCode , consumeRecoveryCode ,
createChallenge , consumeChallenge ,
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
} = require ( '../services/totpService.cts' ) ;
const { encryptSecret : encTotpSecret } = require ( '../services/encryptionService.cts' ) ;
2026-06-04 04:10:14 -05:00
// 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.
2026-07-06 11:26:50 -05:00
router . post ( '/totp/challenge' , async ( req : Req , res : Res ) = > {
2026-06-04 04:10:14 -05:00
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 {
2026-07-06 11:06:05 -05:00
const { createSession } = require ( '../services/authService.cts' ) ;
2026-06-04 04:10:14 -05:00
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.
2026-07-06 11:26:50 -05:00
router . get ( '/totp/setup' , requireAuth , async ( req : Req , res : Res ) = > {
2026-06-04 04:10:14 -05:00
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.
2026-07-06 11:26:50 -05:00
router . post ( '/totp/enable' , requireAuth , ( req : Req , res : Res ) = > {
2026-06-04 04:10:14 -05:00
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.
2026-07-06 11:26:50 -05:00
router . post ( '/totp/disable' , requireAuth , ( req : Req , res : Res ) = > {
2026-06-04 04:10:14 -05:00
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?
2026-07-06 11:26:50 -05:00
router . get ( '/totp/status' , requireAuth , ( req : Req , res : Res ) = > {
2026-06-04 04:10:14 -05:00
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
2026-07-06 11:26:50 -05:00
router . post ( '/acknowledge-version' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-14 21:00:07 -05:00
const currentVersion = getAppVersion ( ) ;
getDb ( )
. prepare ( "UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( currentVersion , req . user . id ) ;
2026-05-15 22:45:38 -05:00
res . json ( { success : true , last_seen_version : currentVersion , release_notes_version : currentVersion } ) ;
2026-05-14 21:00:07 -05:00
} ) ;
2026-05-03 19:51:57 -05:00
// GET /api/auth/mode
// Public — tells the login page which options are available.
// Never returns secrets. local_enabled/oidc_enabled reflect admin settings.
2026-07-06 11:26:50 -05:00
router . get ( '/mode' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
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.
2026-07-06 11:26:50 -05:00
router . post ( '/restore-multi-user-mode' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
if ( ! req . singleUserMode && getSetting ( 'auth_mode' ) !== 'single' ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'Single-user mode is not enabled.' , 'VALIDATION_ERROR' , 'auth_mode' ) ) ;
2026-05-03 19:51:57 -05:00
}
setSetting ( 'auth_mode' , 'multi' ) ;
setSetting ( 'default_user_id' , '' ) ;
res . json ( { success : true , auth_mode : 'multi' } ) ;
} ) ;
// POST /api/auth/acknowledge-privacy
2026-07-06 11:26:50 -05:00
router . post ( '/acknowledge-privacy' , requireAuth , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
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
2026-05-09 13:03:36 -05:00
// Password change endpoint with dedicated rate limiter
v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed
- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
admin endpoints gated, path traversal mitigated, XSS safe)
2026-05-11 21:42:36 -05:00
// CSRF protected via csrfMiddleware on /api/auth mount
2026-07-06 11:26:50 -05:00
router . post ( '/change-password' , passwordLimiter , requireAuth , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const { current_password , new_password } = req . body ;
if ( ! new_password || new_password . length < 8 ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'New password must be at least 8 characters' , 'VALIDATION_ERROR' , 'new_password' ) ) ;
2026-05-03 19:51:57 -05:00
}
const db = getDb ( ) ;
const user = db . prepare ( 'SELECT * FROM users WHERE id = ?' ) . get ( req . user . id ) ;
2026-05-31 15:06:10 -05:00
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 ) ;
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
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 ) ) ;
}
2026-05-10 03:55:14 -05:00
}
2026-05-31 15:06:10 -05:00
logAudit ( { user_id : req.user.id , action : 'password.change' , ip_address : req.ip , user_agent : req.get ( 'user-agent' ) } ) ;
2026-05-10 00:03:12 -05:00
2026-05-31 15:06:10 -05:00
res . json ( { success : true } ) ;
} catch ( err ) {
console . error ( '[auth] change-password error:' , err . message ) ;
res . status ( 500 ) . json ( standardizeError ( 'Password change failed' , 'SERVER_ERROR' ) ) ;
}
2026-05-03 19:51:57 -05:00
} ) ;
// ─────────────────────────────────────────
// ADMIN ROUTES (MOUNTED AT /api/admin)
// ─────────────────────────────────────────
// GET /api/admin/has-users
2026-07-06 11:26:50 -05:00
router . get ( '/has-users' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
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
2026-07-06 11:26:50 -05:00
router . get ( '/users' , requireAuth , requireAdmin , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
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
2026-07-06 11:26:50 -05:00
router . post ( '/users' , requireAuth , requireAdmin , async ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const { username , password } = req . body ;
if ( ! username || username . length < 3 ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'Username must be at least 3 characters' , 'VALIDATION_ERROR' , 'username' ) ) ;
2026-05-03 19:51:57 -05:00
}
if ( ! password || password . length < 8 ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'Password must be at least 8 characters' , 'VALIDATION_ERROR' , 'password' ) ) ;
2026-05-03 19:51:57 -05:00
}
const db = getDb ( ) ;
const existing = db . prepare ( 'SELECT id FROM users WHERE username = ?' ) . get ( username ) ;
2026-05-09 13:03:36 -05:00
if ( existing ) return res . status ( 409 ) . json ( standardizeError ( 'Username already taken' , 'CONFLICT' , 'username' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
try {
const hash = await hashPassword ( password ) ;
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
const result = db . prepare (
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)"
) . run ( username , hash , getAppVersion ( ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
const created = db . prepare (
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
) . get ( result . lastInsertRowid ) ;
2026-05-03 19:51:57 -05:00
2026-05-31 15:06:10 -05:00
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' ) ) ;
}
2026-05-03 19:51:57 -05:00
} ) ;
2026-06-05 22:05:23 -05:00
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
const {
createRegistrationChallenge , verifyRegistration ,
createAuthenticationChallenge , verifyAuthentication ,
consumeLoginChallenge , getCredentials , deleteCredential ,
2026-07-06 11:06:05 -05:00
} = require ( '../services/webauthnService.cts' ) ;
2026-06-05 22:05:23 -05:00
// GET /api/auth/webauthn/status
2026-07-06 11:26:50 -05:00
router . get ( '/webauthn/status' , requireAuth , ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
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
2026-07-06 11:26:50 -05:00
router . get ( '/webauthn/credentials' , requireAuth , ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
if ( req . singleUserMode ) return res . json ( { credentials : [ ] } ) ;
res . json ( { credentials : getCredentials ( getDb ( ) , req . user . id ) } ) ;
} ) ;
// GET /api/auth/webauthn/setup — begin registration
2026-07-06 11:26:50 -05:00
router . get ( '/webauthn/setup' , requireAuth , async ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
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
2026-07-06 11:26:50 -05:00
router . post ( '/webauthn/enable' , requireAuth , async ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
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
2026-07-06 11:26:50 -05:00
router . delete ( '/webauthn/credentials/:credentialId' , requireAuth , async ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
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
2026-07-06 11:26:50 -05:00
router . post ( '/webauthn/disable' , requireAuth , async ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
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.
2026-07-06 11:26:50 -05:00
router . post ( '/webauthn/challenge' , async ( req : Req , res : Res ) = > {
2026-06-05 22:05:23 -05:00
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' ) ) ;
}
2026-07-06 11:06:05 -05:00
const { createSession } = require ( '../services/authService.cts' ) ;
2026-06-05 22:05:23 -05:00
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' ) ) ;
}
} ) ;
2026-05-03 19:51:57 -05:00
module .exports = router ;