dragonflight/services/mam-api/src/auth/mfa-tickets.js

59 lines
2.3 KiB
JavaScript
Raw Normal View History

// Short-lived MFA tickets bridging the two login steps.
//
// When a user with TOTP enabled passes password auth, we don't create a session
// yet — we hand back an opaque ticket. The second request (code or recovery
// code) redeems the ticket to finish login. Tickets are single-use and expire
// fast so a stolen ticket is near-useless.
//
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
// Tickets are bound to the issuing request's IP and User-Agent (hashed). A
// stolen ticket replayed from a different origin redeems to null. This is
// defense in depth against ticket exfiltration via a logged proxy, browser
// extension, or shoulder-surf; it does not stop an attacker who is on the same
// IP and UA.
//
// In-memory + single-instance, matching the existing login rate-limiter
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
// the second step must hit the same node. Acceptable for Dragonflight's
// one-mam-api-per-node shape; revisit if that changes.
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
import { randomBytes, createHash } from 'node:crypto';
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt }
function sweep() {
const now = Date.now();
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
}
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
function hashBinding(value) {
return createHash('sha256').update(String(value || '')).digest('hex');
}
export function issueTicket(userId, { ip, userAgent } = {}) {
sweep();
const id = randomBytes(32).toString('hex');
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
tickets.set(id, {
userId,
ipHash: hashBinding(ip),
uaHash: hashBinding(userAgent),
expiresAt: Date.now() + TTL_MS,
});
return id;
}
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
// Redeem (and consume) a ticket. Returns the userId, or null if missing,
// expired, or the binding doesn't match the redeeming request.
export function redeemTicket(id, { ip, userAgent } = {}) {
if (!id) return null;
const t = tickets.get(id);
if (!t) return null;
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
tickets.delete(id); // single-use — burn even on binding mismatch so a
// wrong-binding probe can't be retried.
if (t.expiresAt <= Date.now()) return null;
fix(mam-api): harden TOTP login flow + tighten Google domain check 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>
2026-05-30 08:52:53 -04:00
// If a caller doesn't supply bindings (e.g. tests), accept — the issue side
// controls whether bindings get recorded.
if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null;
if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null;
return t.userId;
}