import express from 'express'; import pool from '../db/pool.js'; import { DEV_USER_ID, requireAuth } from '../middleware/auth.js'; import { hashPassword, comparePassword } from '../auth/passwords.js'; import { ipBackoff } from '../auth/rate-limit.js'; import { generateSecret, verifyToken, otpauthURI, generateRecoveryCodes, } from '../auth/totp.js'; import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js'; import { isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify, } from '../auth/google-oauth.js'; import { randomBytes } from 'node:crypto'; const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; const router = express.Router(); // Real users = anyone except the seeded dev row. async function realUserCount() { const { rows } = await pool.query( `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]); return rows[0].n; } // GET /api/v1/auth/setup-required // Cheap, no auth. Used by AuthGate to decide between Login and Setup screens. router.get('/setup-required', async (_req, res, next) => { try { res.json({ required: (await realUserCount()) === 0 }); } catch (err) { next(err); } }); const MIN_PASSWORD_LEN = 12; function badRequest(res, msg) { return res.status(400).json({ error: msg }); } // POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists. router.post('/setup', async (req, res, next) => { try { const { username, password } = req.body || {}; if (!username || typeof username !== 'string') return badRequest(res, 'username required'); if (!password || typeof password !== 'string') return badRequest(res, 'password required'); if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters'); if ((await realUserCount()) > 0) { return res.status(409).json({ error: 'setup already complete' }); } const hash = await hashPassword(password); const { rows } = await pool.query( `INSERT INTO users (username, password_hash, display_name, role) VALUES ($1, $2, $1, 'admin') RETURNING id, username, display_name`, [username.trim(), hash] ); const user = rows[0]; // Immediately log them in. req.session.user_id = user.id; req.session.first_seen_at = Date.now(); req.session.last_seen_at = Date.now(); await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); res.json({ user }); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'username already exists' }); next(err); } }); // POST /api/v1/auth/login — authenticate an existing user by username + password. router.post('/login', async (req, res, next) => { try { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; const delay = ipBackoff.delayMs(ip); if (delay > 0) await new Promise(r => setTimeout(r, delay)); const { username, password } = req.body || {}; if (!username || !password) { ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' }); } const { rows } = await pool.query( `SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`, [username.trim(), DEV_USER_ID] ); if (rows.length === 0) { // Pre-computed bcrypt hash of a value that no real password input will match. // Used to keep the user-not-found response time uniform with the wrong-password // path (~180ms at cost 12) so user enumeration via timing isn't possible. await comparePassword(password, DUMMY_PASSWORD_HASH); ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' }); } const user = rows[0]; if (!(await comparePassword(password, user.password_hash))) { ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' }); } // Second factor: if TOTP is enabled, don't create a session yet. Hand back // a short-lived ticket the client redeems via /login/totp with a code. // Crucially: do NOT clear the per-IP failure counter here. If we did, each // /login retry would reset the backoff and let an attacker brute the 6-digit // TOTP space (10^6) with no per-attempt delay. The counter is cleared // inside establishSession() once MFA has actually passed. if (user.totp_enabled) { return res.json({ mfa_required: true, ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }), }); } await establishSession(req, user, ip); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } }); // Write the session and wait for it to persist before responding. Extracted so // both the password-only and the MFA-completion paths share one implementation. // Clears the per-IP failure counter only here — after every required factor has // actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]). async function establishSession(req, user, ip) { req.session.user_id = user.id; req.session.first_seen_at = Date.now(); req.session.last_seen_at = Date.now(); // The critical line — wait for the row to land in `sessions` before responding. // Without this, the SPA's next request races the store write, hits 401, and // the prior bounce-to-login logic produced an infinite loop. await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); if (ip) ipBackoff.recordSuccess(ip); pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]) .catch(err => console.error('[auth] last_login_at update failed:', err.message)); } // POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is // either a 6-digit TOTP or a one-time recovery code. The ticket comes from the // request body (password-login path) or req.session.mfa_ticket (Google path). router.post('/login/totp', async (req, res, next) => { try { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; // Rate-limit the second factor with the same per-IP backoff as /login so // the 6-digit code space can't be hammered. const delay = ipBackoff.delayMs(ip); if (delay > 0) await new Promise(r => setTimeout(r, delay)); const { ticket: bodyTicket, code } = req.body || {}; const ticket = bodyTicket || req.session?.mfa_ticket; if (req.session?.mfa_ticket) delete req.session.mfa_ticket; // Bound to the issuing request's IP + UA — replays from a different origin // redeem to null. See mfa-tickets.js for the binding model. const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') }); if (!userId) { ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid or expired ticket' }); } if (!code) return res.status(400).json({ error: 'code required' }); const { rows } = await pool.query( `SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter FROM users WHERE id = $1`, [userId]); const user = rows[0]; if (!user || !user.totp_enabled || !user.totp_secret) { return res.status(401).json({ error: 'invalid credentials' }); } // verifyToken returns the matched counter on success. Reject codes at // counters ≤ totp_last_counter to prevent replay within the same step. // The CAS-style UPDATE makes this race-free under concurrent submissions. const matchedCounter = verifyToken(user.totp_secret, code); let ok = false; if (matchedCounter !== null) { const lastCounter = BigInt(user.totp_last_counter || 0); if (BigInt(matchedCounter) > lastCounter) { const upd = await pool.query( `UPDATE users SET totp_last_counter = $1 WHERE id = $2 AND totp_last_counter < $1`, [String(matchedCounter), user.id] ); ok = upd.rowCount === 1; } // matchedCounter ≤ last → silent replay; falls through to recovery-code // path which also fails → 401. Same UX as a wrong code, no info leak. } if (!ok) ok = await consumeRecoveryCode(user.id, code); if (!ok) { ipBackoff.recordFailure(ip); // The ticket was single-use; the client must restart from /login. return res.status(401).json({ error: 'invalid code' }); } // recordSuccess is called by establishSession once the session lands — // that's the first moment we know every required factor has passed. await establishSession(req, user, ip); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } }); // Check a recovery code against the user's unused codes; mark it spent on match. // The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check) // so two concurrent redemptions of the same code can't both succeed. async function consumeRecoveryCode(userId, code) { const cleaned = String(code).trim().toLowerCase(); if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false; const { rows } = await pool.query( `SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]); for (const row of rows) { if (await comparePassword(cleaned, row.code_hash)) { const upd = await pool.query( `UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]); // Lost the race if another request already consumed it. return upd.rowCount === 1; } } return false; } // POST /api/v1/auth/logout — destroys the session and clears the cookie. router.post('/logout', (req, res) => { if (!req.session) return res.status(204).end(); req.session.destroy(err => { if (err) console.error('[auth] session destroy failed:', err.message); res.clearCookie('dragonflight.sid', { path: '/' }); res.status(204).end(); }); }); // GET /api/v1/auth/me router.get('/me', requireAuth, (req, res) => { res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name, role: req.user.role, totp_enabled: !!req.user.totp_enabled, }); }); // POST /api/v1/auth/password { current_password, new_password } router.post('/password', requireAuth, async (req, res, next) => { try { const { current_password, new_password } = req.body || {}; if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required'); if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters'); const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]); if (!rows.length) return res.status(401).json({ error: 'unauthorized' }); if (!(await comparePassword(current_password, rows[0].password_hash))) { return badRequest(res, 'current password is incorrect'); } const newHash = await hashPassword(new_password); await pool.query( `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`, [newHash, req.user.id] ); res.status(204).end(); } catch (err) { next(err); } }); // ── TOTP enrollment (all require an active session) ───────────────────────── // POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret, // stores it (but leaves totp_enabled=false), and returns the otpauth URI + the // base32 secret for manual entry. Enrollment isn't active until /enable // confirms a code, so a started-but-abandoned setup never locks the user out. router.post('/totp/setup', requireAuth, async (req, res, next) => { try { const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]); if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' }); const secret = generateSecret(); await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]); const uri = otpauthURI(secret, req.user.username || 'user'); // QR rendering is optional — the otpauth URI + manual secret are sufficient // to enroll. Render a data-URL QR only if the optional `qrcode` dep is // present, so a missing dependency degrades instead of 500-ing. let qr = null; try { const QRCode = (await import('qrcode')).default; qr = await QRCode.toDataURL(uri); } catch { /* qrcode not installed — client falls back to manual entry */ } res.json({ secret, otpauth_uri: uri, qr }); } catch (err) { next(err); } }); // POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from // the authenticator. On success, flips totp_enabled and returns one-time // recovery codes (shown exactly once). router.post('/totp/enable', requireAuth, async (req, res, next) => { try { const { code } = req.body || {}; if (!code) return badRequest(res, 'code required'); const { rows } = await pool.query( `SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]); const row = rows[0]; if (!row?.totp_secret) return badRequest(res, 'start setup first'); if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' }); const enrollCounter = verifyToken(row.totp_secret, code); if (enrollCounter === null) return badRequest(res, 'incorrect code'); const recovery = generateRecoveryCodes(10); const hashes = await Promise.all(recovery.map(c => hashPassword(c))); // Enable + seed totp_last_counter to the enrollment code's counter so the // same code can't be reused on first login. Replace any stale recovery // codes atomically. const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( `UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`, [req.user.id, String(enrollCounter)] ); await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]); for (const h of hashes) { await client.query( `INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]); } await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK').catch(() => {}); throw e; } finally { client.release(); } res.json({ enabled: true, recovery_codes: recovery }); } catch (err) { next(err); } }); // POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the // account password as a confirmation so a hijacked live session can't silently // strip the second factor. router.post('/totp/disable', requireAuth, async (req, res, next) => { try { const { password } = req.body || {}; if (!password) return badRequest(res, 'password required'); const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]); if (!rows.length) return res.status(401).json({ error: 'unauthorized' }); if (!(await comparePassword(password, rows[0].password_hash))) { return badRequest(res, 'incorrect password'); } await pool.query( `UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`, [req.user.id] ); await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]); res.status(204).end(); } catch (err) { next(err); } }); // ── Google OAuth (OIDC) sign-in ───────────────────────────────────────────── // All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and // OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected. // GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide // whether to render the "Sign in with Google" button. router.get('/google/enabled', (_req, res) => { res.json({ enabled: googleConfigured() }); }); // GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state // in the session and redirects to Google's consent screen. router.get('/google', async (req, res, next) => { try { if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' }); const state = randomBytes(16).toString('hex'); req.session.oauth_state = state; await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); res.redirect(await buildAuthUrl(state)); } catch (err) { next(err); } }); // GET /api/v1/auth/google/callback — Google redirects back here with ?code&state. // Verifies the ID token, enforces the allowed domain, auto-provisions a viewer // on first login, establishes the session, then redirects to the SPA. router.get('/google/callback', async (req, res, next) => { try { if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' }); const { code, state } = req.query; const expected = req.session.oauth_state; delete req.session.oauth_state; if (!code || !state || !expected || state !== expected) { return res.status(400).json({ error: 'invalid oauth state' }); } const profile = await exchangeAndVerify(code); const user = await resolveGoogleUser(profile); // If this account has TOTP enabled, Google is only the FIRST factor — route // through the same second-factor step as password login. The ticket lives in // the session (not the URL) and the SPA prompts for the code. if (user.totp_enabled) { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; req.session.mfa_ticket = issueTicket(user.id, { ip, userAgent: req.get('user-agent'), }); await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); return res.redirect('/?mfa=1'); } const ip = req.ip || req.socket?.remoteAddress || 'unknown'; await establishSession(req, user, ip); // Redirect to the SPA root; AuthGate will re-check /auth/me and render the app. res.redirect('/'); } catch (err) { // Surface a friendly message on the login screen rather than a raw 500. if (err.status === 403) return res.redirect('/?auth_error=domain'); if (err.status === 401) return res.redirect('/?auth_error=google'); next(err); } }); // Map a verified Google profile to a Dragonflight user row. // // Resolution order: // 1. Existing link by google_sub → that user. // 2. Otherwise auto-provision a fresh 'viewer'. // // We deliberately do NOT auto-link to an existing account by matching email: // that would let anyone who controls a Google address with the same email sign // in as a pre-existing local (possibly admin) account, bypassing its password // and TOTP. Linking an existing account to Google is an explicit, authenticated // action (a future "connect Google" under Settings), not something a login does. async function resolveGoogleUser(profile) { const found = await pool.query( `SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]); if (found.rows.length) return found.rows[0]; const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'; let username = base, n = 1; while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) { username = base + (++n); } try { const ins = await pool.query( `INSERT INTO users (username, password_hash, display_name, role, email, google_sub) VALUES ($1, NULL, $2, 'viewer', $3, $4) RETURNING id, username, display_name, totp_enabled`, [username, profile.name, profile.email, profile.sub]); return ins.rows[0]; } catch (err) { // Concurrent first-login race: the unique google_sub index rejected our // INSERT because a sibling request just created the row. Re-resolve. if (err.code === '23505') { const retry = await pool.query( `SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]); if (retry.rows.length) return retry.rows[0]; } throw err; } } export default router; export { realUserCount, resolveGoogleUser, consumeRecoveryCode };