diff --git a/services/mam-api/package.json b/services/mam-api/package.json index 04101d1..4dca16b 100644 --- a/services/mam-api/package.json +++ b/services/mam-api/package.json @@ -22,7 +22,8 @@ "bullmq": "^5.5.0", "multer": "^1.4.5-lts.1", "uuid": "^9.0.1", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "qrcode": "^1.5.4" }, "engines": { "node": ">=22.0.0" diff --git a/services/mam-api/src/auth/mfa-tickets.js b/services/mam-api/src/auth/mfa-tickets.js new file mode 100644 index 0000000..9600862 --- /dev/null +++ b/services/mam-api/src/auth/mfa-tickets.js @@ -0,0 +1,37 @@ +// Short-lived MFA tickets bridging the two login steps. +// +// When a user with TOTP enabled passes password auth, we don't create a session +// yet — we hand back an opaque ticket. The second request (code or recovery +// code) redeems the ticket to finish login. Tickets are single-use and expire +// fast so a stolen ticket is near-useless. +// +// In-memory + single-instance, matching the existing login rate-limiter +// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment +// the second step must hit the same node. Acceptable for Dragonflight's +// one-mam-api-per-node shape; revisit if that changes. +import { randomBytes } from 'node:crypto'; + +const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code +const tickets = new Map(); // id -> { userId, expiresAt } + +function sweep() { + const now = Date.now(); + for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id); +} + +export function issueTicket(userId) { + sweep(); + const id = randomBytes(32).toString('hex'); + tickets.set(id, { userId, expiresAt: Date.now() + TTL_MS }); + return id; +} + +// Redeem (and consume) a ticket. Returns the userId, or null if missing/expired. +export function redeemTicket(id) { + if (!id) return null; + const t = tickets.get(id); + if (!t) return null; + tickets.delete(id); // single-use + if (t.expiresAt <= Date.now()) return null; + return t.userId; +} diff --git a/services/mam-api/src/auth/totp.js b/services/mam-api/src/auth/totp.js new file mode 100644 index 0000000..39ba18e --- /dev/null +++ b/services/mam-api/src/auth/totp.js @@ -0,0 +1,114 @@ +// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency. +// +// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps +// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors +// in test/auth/totp.test.js. +// +// Defaults match every mainstream authenticator app (Google Authenticator, +// Authy, 1Password): SHA-1, 6 digits, 30-second step. + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +const DIGITS = 6; +const STEP_SECONDS = 30; +const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +// ── base32 (RFC 4648, no padding) ────────────────────────────────────────── +export function base32Encode(buf) { + let bits = 0, value = 0, out = ''; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += RFC4648_B32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31]; + return out; +} + +export function base32Decode(str) { + const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, ''); + let bits = 0, value = 0; + const out = []; + for (const ch of clean) { + const idx = RFC4648_B32.indexOf(ch); + if (idx === -1) continue; // skip stray chars + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(out); +} + +// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended +// SHA-1 key length). +export function generateSecret() { + return base32Encode(randomBytes(20)); +} + +// HOTP for a specific counter (RFC 4226). +function hotp(secretBuf, counter) { + const buf = Buffer.alloc(8); + // 64-bit big-endian counter. + buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0); + buf.writeUInt32BE(counter >>> 0, 4); + const hmac = createHmac('sha1', secretBuf).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const code = ((hmac[offset] & 0x7f) << 24) + | ((hmac[offset + 1] & 0xff) << 16) + | ((hmac[offset + 2] & 0xff) << 8) + | (hmac[offset + 3] & 0xff); + return String(code % (10 ** DIGITS)).padStart(DIGITS, '0'); +} + +// The TOTP code for a given time (defaults to now). +export function generateToken(base32Secret, atMs = Date.now()) { + const counter = Math.floor(atMs / 1000 / STEP_SECONDS); + return hotp(base32Decode(base32Secret), counter); +} + +// Verify a user-supplied code, allowing ±`window` steps of clock drift +// (default ±1 = 90s total tolerance). Constant-time compare per candidate. +export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) { + if (!base32Secret || !token) return false; + const cleaned = String(token).replace(/\s+/g, ''); + if (!/^\d{6}$/.test(cleaned)) return false; + const secretBuf = base32Decode(base32Secret); + const counter = Math.floor(atMs / 1000 / STEP_SECONDS); + const want = Buffer.from(cleaned); + for (let w = -window; w <= window; w++) { + const candidate = Buffer.from(hotp(secretBuf, counter + w)); + if (candidate.length === want.length && timingSafeEqual(candidate, want)) return true; + } + return false; +} + +// The otpauth:// URI an authenticator app scans. label/issuer show in the app. +export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') { + const label = encodeURIComponent(`${issuer}:${accountName}`); + const params = new URLSearchParams({ + secret: base32Secret, + issuer, + algorithm: 'SHA1', + digits: String(DIGITS), + period: String(STEP_SECONDS), + }); + return `otpauth://totp/${label}?${params.toString()}`; +} + +// Generate N human-friendly one-time recovery codes (raw form). Caller hashes +// them before storage and shows the raw set to the user exactly once. +export function generateRecoveryCodes(n = 10) { + const codes = []; + for (let i = 0; i < n; i++) { + // 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5". + const hex = randomBytes(5).toString('hex'); + codes.push(hex.slice(0, 5) + '-' + hex.slice(5)); + } + return codes; +} diff --git a/services/mam-api/src/db/migrations/027-totp-2fa.sql b/services/mam-api/src/db/migrations/027-totp-2fa.sql new file mode 100644 index 0000000..a4a2dbf --- /dev/null +++ b/services/mam-api/src/db/migrations/027-totp-2fa.sql @@ -0,0 +1,20 @@ +-- Migration 027 — TOTP two-factor auth. +-- +-- totp_secret holds the base32 shared secret once enrollment is confirmed +-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after +-- the user verifies their first code, so a half-finished enrollment never locks +-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks +-- a code as spent. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS user_recovery_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id); diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 27c69d8..7722d02 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -104,7 +104,7 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' })); // ── Auth gate ───────────────────────────────────────────────────────────────── // req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login. -const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']); +const UNAUTH_PATHS = new Set(['/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required']); // node-agent now authenticates /cluster/heartbeat with a bound api_token // (migration 019 + bound_hostname on the token). requireAuth handles the // bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index f942ea1..f323e29 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -20,7 +20,7 @@ async function destroyAnd401(req, res) { async function loadUser(id) { const { rows } = await pool.query( - `SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]); + `SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]); return rows[0] || null; } diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index b2dc7b3..889bb38 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -3,6 +3,10 @@ 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'; const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; @@ -76,7 +80,7 @@ router.post('/login', async (req, res, next) => { } const { rows } = await pool.query( - `SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`, + `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) { @@ -93,21 +97,79 @@ router.post('/login', async (req, res, next) => { return res.status(401).json({ error: 'invalid credentials' }); } - 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())); - - 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)); - + // Password is correct — clear the per-IP backoff regardless of MFA outcome. ipBackoff.recordSuccess(ip); - res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } + + // 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. + if (user.totp_enabled) { + return res.json({ mfa_required: true, ticket: issueTicket(user.id) }); + } + + await establishSession(req, user); + 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. +async function establishSession(req, user) { + 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())); + 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. +router.post('/login/totp', async (req, res, next) => { + try { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const { ticket, code } = req.body || {}; + const userId = redeemTicket(ticket); + if (!userId) 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 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' }); + } + + let ok = verifyToken(user.totp_secret, code); + 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' }); + } + + await establishSession(req, user); + 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. +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)) { + await pool.query(`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1`, [row.id]); + return true; + } + } + 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(); @@ -125,6 +187,7 @@ router.get('/me', requireAuth, (req, res) => { username: req.user.username, display_name: req.user.display_name, role: req.user.role, + totp_enabled: !!req.user.totp_enabled, }); }); @@ -149,5 +212,89 @@ router.post('/password', requireAuth, async (req, res, next) => { } 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' }); + if (!verifyToken(row.totp_secret, code)) return badRequest(res, 'incorrect code'); + + const recovery = generateRecoveryCodes(10); + const hashes = await Promise.all(recovery.map(c => hashPassword(c))); + // Enable + 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 WHERE id = $1`, [req.user.id]); + 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 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); } +}); + export default router; export { realUserCount }; diff --git a/services/mam-api/test/auth/totp.test.js b/services/mam-api/test/auth/totp.test.js new file mode 100644 index 0000000..0457f49 --- /dev/null +++ b/services/mam-api/test/auth/totp.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + base32Encode, base32Decode, generateSecret, generateToken, + verifyToken, otpauthURI, generateRecoveryCodes, +} from '../../src/auth/totp.js'; + +// ── base32 round-trips ────────────────────────────────────────────────────── +test('base32 encode/decode round-trips arbitrary bytes', () => { + for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) { + const buf = Buffer.from(s); + assert.deepEqual(base32Decode(base32Encode(buf)), buf); + } +}); + +// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the +// low 6 here, so compare the last 6 digits of each published value). ────────── +// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and +// check the 6-digit code at each published timestamp. +test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => { + const secret = base32Encode(Buffer.from('12345678901234567890')); + // [unix seconds, full 8-digit code from the RFC] → expect last 6 digits. + const vectors = [ + [59, '94287082'], + [1111111109, '07081804'], + [1111111111, '14050471'], + [1234567890, '89005924'], + [2000000000, '69279037'], + [20000000000, '65353130'], + ]; + for (const [secs, full8] of vectors) { + const got = generateToken(secret, secs * 1000); + assert.equal(got, full8.slice(-6), `t=${secs}`); + } +}); + +// ── verify with drift window ──────────────────────────────────────────────── +test('verifyToken accepts the current code and ±1 step of drift', () => { + const secret = generateSecret(); + const now = 1_700_000_000_000; + const code = generateToken(secret, now); + assert.equal(verifyToken(secret, code, now), true); + // 30s earlier / later still inside ±1 window. + assert.equal(verifyToken(secret, code, now + 30_000), true); + assert.equal(verifyToken(secret, code, now - 30_000), true); + // 2 steps away → rejected. + assert.equal(verifyToken(secret, code, now + 90_000), false); +}); + +test('verifyToken rejects malformed / empty input without throwing', () => { + const secret = generateSecret(); + assert.equal(verifyToken(secret, ''), false); + assert.equal(verifyToken(secret, 'abcdef'), false); + assert.equal(verifyToken(secret, '12345'), false); // too short + assert.equal(verifyToken(secret, '1234567'), false); // too long + assert.equal(verifyToken('', '123456'), false); +}); + +test('verifyToken tolerates spaces in the user-entered code', () => { + const secret = generateSecret(); + const now = 1_700_000_000_000; + const code = generateToken(secret, now); + assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), true); +}); + +// ── otpauth URI ───────────────────────────────────────────────────────────── +test('otpauthURI embeds secret, issuer, and account', () => { + const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight'); + assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/); + assert.match(uri, /secret=JBSWY3DPEHPK3PXP/); + assert.match(uri, /issuer=Dragonflight/); + assert.match(uri, /digits=6/); + assert.match(uri, /period=30/); +}); + +// ── recovery codes ────────────────────────────────────────────────────────── +test('generateRecoveryCodes returns N distinct formatted codes', () => { + const codes = generateRecoveryCodes(10); + assert.equal(codes.length, 10); + assert.equal(new Set(codes).size, 10); + for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/); +}); diff --git a/services/mam-api/test/routes/totp.test.js b/services/mam-api/test/routes/totp.test.js new file mode 100644 index 0000000..84f5c4c --- /dev/null +++ b/services/mam-api/test/routes/totp.test.js @@ -0,0 +1,143 @@ +// Integration test for the TOTP two-step login + recovery codes. +// +// Mounts the real auth router with a session store on the throwaway test DB. +// Drives: enroll (setup → enable) → logout → password login returns mfa_required +// → complete with a generated code → and the recovery-code single-use path. +// Skips when TEST_DATABASE_URL is unset. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import session from 'express-session'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import { hashPassword } from '../../src/auth/passwords.js'; +import { generateToken } from '../../src/auth/totp.js'; + +const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; + +async function appWithAuth(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + process.env.SESSION_SECRET = 'test'; + const ConnectPg = (await import('connect-pg-simple')).default(session); + const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now()); + const app = express(); + app.use(express.json()); + app.use(session({ + store: new ConnectPg({ pool, tableName: 'sessions' }), + secret: 'test', name: 'dragonflight.sid', + cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 }, + rolling: false, resave: false, saveUninitialized: false, + })); + app.use('/api/v1/auth', authRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) })); + }); +} + +const J = (cookie, body) => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), +}); + +async function loginPassword(baseUrl, username, password) { + const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password })); + const cookie = (r.headers.get('set-cookie') || '').split(';')[0]; + return { r, body: await r.json().catch(() => ({})), cookie }; +} + +test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]); + const { baseUrl, close } = await appWithAuth(pool); + + // 1. Password login (no TOTP yet) → 200 with a session cookie. + const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery'); + assert.equal(first.r.status, 200); + assert.ok(!first.body.mfa_required); + const cookie = first.cookie; + + // 2. Enroll: setup returns a secret; enable confirms with a live code. + const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json(); + assert.match(setup.secret, /^[A-Z2-7]+$/); + const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) })); + assert.equal(enableRes.status, 200); + const enableBody = await enableRes.json(); + assert.equal(enableBody.enabled, true); + assert.equal(enableBody.recovery_codes.length, 10); + + // 3. Fresh password login now returns mfa_required + a ticket, NO session cookie. + const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery'); + assert.equal(second.r.status, 200); + assert.equal(second.body.mfa_required, true); + assert.ok(second.body.ticket); + assert.ok(!second.cookie, 'no session cookie should be set before the second factor'); + + // 4. Wrong code → 401; the ticket is now spent. + const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' })); + assert.equal(bad.status, 401); + + // 5. New login + correct code → 200 with a session cookie. + const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery'); + const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) })); + assert.equal(ok.status, 200); + assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/); + + await close(); + } finally { await pool.end(); } +}); + +test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]); + const { baseUrl, close } = await appWithAuth(pool); + + const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery'); + const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json(); + const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json(); + const recovery = enableBody.recovery_codes[0]; + + // Use a recovery code to complete a fresh login. + const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery'); + const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery })); + assert.equal(use1.status, 200, 'recovery code should complete login once'); + + // The same recovery code must NOT work a second time. + const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery'); + const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery })); + assert.equal(use2.status, 401, 'a spent recovery code must be rejected'); + + await close(); + } finally { await pool.end(); } +}); + +test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]); + const { baseUrl, close } = await appWithAuth(pool); + + const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery'); + const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json(); + await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) })); + + // Disabling requires the password. + const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' })); + assert.equal(wrongPw.status, 400); + const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' })); + assert.equal(disabled.status, 204); + + // Password login is single-factor again. + const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery'); + assert.equal(relog.r.status, 200); + assert.ok(!relog.body.mfa_required); + + await close(); + } finally { await pool.end(); } +}); diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 6098f2f..16ae8e8 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -1488,6 +1488,135 @@ function AccountSection() { ); } +// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled. +function TotpSection() { + const me = window.ZAMPP_DATA?.ME || {}; + const [enabled, setEnabled] = React.useState(!!me.totp_enabled); + const [phase, setPhase] = React.useState('idle'); // idle | enrolling | recovery + const [enroll, setEnroll] = React.useState(null); // { secret, otpauth_uri, qr } + const [code, setCode] = React.useState(''); + const [recovery, setRecovery] = React.useState(null); // string[] + const [disablePw, setDisablePw] = React.useState(''); + const [showDisable, setShowDisable] = React.useState(false); + const [msg, setMsg] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const api = (path, body) => fetch('/api/v1/auth' + path, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify(body || {}), + }); + + const startSetup = async () => { + setMsg(null); setBusy(true); + try { + const r = await api('/totp/setup'); + if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); } + else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' }); + } finally { setBusy(false); } + }; + + const confirmEnable = async () => { + setMsg(null); setBusy(true); + try { + const r = await api('/totp/enable', { code: code.trim() }); + const body = await r.json().catch(() => ({})); + if (r.status === 200) { + setRecovery(body.recovery_codes || []); setPhase('recovery'); + setEnabled(true); setCode(''); + window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true }; + } else setMsg({ kind: 'err', text: body.error || 'Could not enable' }); + } finally { setBusy(false); } + }; + + const disable = async () => { + setMsg(null); setBusy(true); + try { + const r = await api('/totp/disable', { password: disablePw }); + if (r.status === 204) { + setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle'); + window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false }; + setMsg({ kind: 'ok', text: 'Two-factor disabled' }); + } else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' }); + } finally { setBusy(false); } + }; + + return ( +
+

Two-factor authentication

+ + {/* Status line */} +
+ {enabled ? 'Enabled' : 'Disabled'} + + {enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'} + + + {!enabled && phase === 'idle' && } + {enabled && phase !== 'recovery' && !showDisable && } +
+ + {/* Enrolling: show QR / secret + code field */} + {phase === 'enrolling' && enroll && ( +
+
+ {enroll.qr + ? TOTP QR code + :
Scan the secret below in your app.
} +
+
+
+ Scan the QR with Google Authenticator, Authy, or 1Password — or enter this secret manually: +
+
{enroll.secret}
+
+ + setCode(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }} + placeholder="123456" style={{ width: 140 }} /> +
+
+ + +
+
+
+ )} + + {/* Recovery codes — shown exactly once */} + {phase === 'recovery' && recovery && ( +
+
+ Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again. +
+
+ {recovery.map(c =>
{c}
)} +
+ + +
+ )} + + {/* Disable confirmation */} + {showDisable && ( +
+
+ + setDisablePw(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} /> +
+ + +
+ )} + + {msg &&
{msg.text}
} +
+ ); +} + function ApiTokensSection() { const [tokens, setTokens] = React.useState([]); const [name, setName] = React.useState(''); @@ -1597,6 +1726,7 @@ function Settings() { {section === 'account' && ( <> + )} diff --git a/services/web-ui/public/screens-auth.jsx b/services/web-ui/public/screens-auth.jsx index 551b449..8d7eff2 100644 --- a/services/web-ui/public/screens-auth.jsx +++ b/services/web-ui/public/screens-auth.jsx @@ -121,16 +121,59 @@ const [password, setPassword] = React.useState(''); const [error, setError] = React.useState(''); const [busy, setBusy] = React.useState(false); + // Second factor: when the server returns { mfa_required, ticket }, we switch + // to the code step instead of completing login. + const [ticket, setTicket] = React.useState(null); + const [code, setCode] = React.useState(''); + const submit = async () => { setError(''); setBusy(true); try { const r = await postJson('/auth/login', { username, password }); - if (r.status === 200) { onDone(); return; } + if (r.status === 200) { + const body = await r.json().catch(() => ({})); + if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; } + onDone(); return; + } const body = await r.json().catch(() => ({})); setError(body.error || ('Login failed: ' + r.status)); } catch (e) { setError(e.message || 'Login failed'); } finally { setBusy(false); } }; + + const submitCode = async () => { + setError(''); setBusy(true); + try { + const r = await postJson('/auth/login/totp', { ticket, code: code.trim() }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + // An expired/used ticket means the user must start over. + if (r.status === 401 && /ticket/.test(body.error || '')) { + setTicket(null); setCode(''); setPassword(''); + setError('Session timed out — please sign in again.'); + } else { + setError(body.error || ('Verification failed: ' + r.status)); + } + } catch (e) { setError(e.message || 'Verification failed'); } + finally { setBusy(false); } + }; + + if (ticket) { + return ( + +
+ Two-factor authentication +
+ + +
+ Enter the 6-digit code from your authenticator app, or a recovery code. +
+ +
+ ); + } + return (