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';
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
import {
|
|
|
|
|
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
|
|
|
|
|
} from '../auth/google-oauth.js';
|
|
|
|
|
import { randomBytes } from 'node:crypto';
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
// 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).
|
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
|
|
|
router.post('/login/totp', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
// 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;
|
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
|
|
|
const userId = redeemTicket(ticket);
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
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' });
|
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
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
ipBackoff.recordSuccess(ip);
|
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); }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Check a recovery code against the user's unused codes; mark it spent on match.
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
// 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.
|
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
|
|
|
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)) {
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
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;
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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); }
|
|
|
|
|
});
|
|
|
|
|
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
// ── 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) {
|
|
|
|
|
req.session.mfa_ticket = issueTicket(user.id);
|
|
|
|
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
|
|
|
|
return res.redirect('/?mfa=1');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await establishSession(req, user);
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 14:21:32 -04:00
|
|
|
export default router;
|
feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:51:59 -04:00
|
|
|
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|