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