2026-05-27 14:21:32 -04:00
|
|
|
import express from 'express';
|
|
|
|
|
import pool from '../db/pool.js';
|
2026-05-27 14:42:53 -04:00
|
|
|
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
2026-05-27 14:28:18 -04:00
|
|
|
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
2026-05-27 14:58:02 -04:00
|
|
|
import { ipBackoff } from '../auth/rate-limit.js';
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
import {
|
|
|
|
|
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
|
|
|
|
} from '../auth/totp.js';
|
|
|
|
|
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
2026-05-27 14:21:32 -04:00
|
|
|
|
2026-05-27 14:35:59 -04:00
|
|
|
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
|
|
|
|
|
2026-05-27 14:21:32 -04:00
|
|
|
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); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:24:56 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:28:18 -04:00
|
|
|
// POST /api/v1/auth/login — authenticate an existing user by username + password.
|
|
|
|
|
router.post('/login', async (req, res, next) => {
|
|
|
|
|
try {
|
2026-05-27 14:58:02 -04:00
|
|
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
|
|
|
const delay = ipBackoff.delayMs(ip);
|
|
|
|
|
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
|
|
|
|
|
2026-05-27 14:28:18 -04:00
|
|
|
const { username, password } = req.body || {};
|
2026-05-27 14:58:02 -04:00
|
|
|
if (!username || !password) {
|
|
|
|
|
ipBackoff.recordFailure(ip);
|
|
|
|
|
return res.status(401).json({ error: 'invalid credentials' });
|
|
|
|
|
}
|
2026-05-27 14:28:18 -04:00
|
|
|
|
|
|
|
|
const { rows } = await pool.query(
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
|
2026-05-27 14:28:18 -04:00
|
|
|
[username.trim(), DEV_USER_ID]
|
|
|
|
|
);
|
|
|
|
|
if (rows.length === 0) {
|
2026-05-27 14:35:59 -04:00
|
|
|
// 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);
|
2026-05-27 14:58:02 -04:00
|
|
|
ipBackoff.recordFailure(ip);
|
2026-05-27 14:28:18 -04:00
|
|
|
return res.status(401).json({ error: 'invalid credentials' });
|
|
|
|
|
}
|
|
|
|
|
const user = rows[0];
|
|
|
|
|
if (!(await comparePassword(password, user.password_hash))) {
|
2026-05-27 14:58:02 -04:00
|
|
|
ipBackoff.recordFailure(ip);
|
2026-05-27 14:28:18 -04:00
|
|
|
return res.status(401).json({ error: 'invalid credentials' });
|
|
|
|
|
}
|
|
|
|
|
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
// Password is correct — clear the per-IP backoff regardless of MFA outcome.
|
|
|
|
|
ipBackoff.recordSuccess(ip);
|
2026-05-27 14:28:18 -04:00
|
|
|
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
// 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) });
|
|
|
|
|
}
|
2026-05-27 14:28:18 -04:00
|
|
|
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
await establishSession(req, user);
|
|
|
|
|
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
|
|
|
|
} catch (err) { next(err); }
|
2026-05-27 14:28:18 -04:00
|
|
|
});
|
|
|
|
|
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 14:38:05 -04:00
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:42:53 -04:00
|
|
|
// GET /api/v1/auth/me
|
|
|
|
|
router.get('/me', requireAuth, (req, res) => {
|
2026-05-27 19:27:51 -04:00
|
|
|
res.json({
|
|
|
|
|
id: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
display_name: req.user.display_name,
|
|
|
|
|
role: req.user.role,
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
totp_enabled: !!req.user.totp_enabled,
|
2026-05-27 19:27:51 -04:00
|
|
|
});
|
2026-05-27 14:42:53 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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); }
|
|
|
|
|
});
|
|
|
|
|
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
// ── 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); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:21:32 -04:00
|
|
|
export default router;
|
|
|
|
|
export { realUserCount };
|