38 lines
1.4 KiB
JavaScript
38 lines
1.4 KiB
JavaScript
|
|
// Short-lived MFA tickets bridging the two login steps.
|
||
|
|
//
|
||
|
|
// When a user with TOTP enabled passes password auth, we don't create a session
|
||
|
|
// yet — we hand back an opaque ticket. The second request (code or recovery
|
||
|
|
// code) redeems the ticket to finish login. Tickets are single-use and expire
|
||
|
|
// fast so a stolen ticket is near-useless.
|
||
|
|
//
|
||
|
|
// In-memory + single-instance, matching the existing login rate-limiter
|
||
|
|
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
|
||
|
|
// the second step must hit the same node. Acceptable for Dragonflight's
|
||
|
|
// one-mam-api-per-node shape; revisit if that changes.
|
||
|
|
import { randomBytes } from 'node:crypto';
|
||
|
|
|
||
|
|
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
|
||
|
|
const tickets = new Map(); // id -> { userId, expiresAt }
|
||
|
|
|
||
|
|
function sweep() {
|
||
|
|
const now = Date.now();
|
||
|
|
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function issueTicket(userId) {
|
||
|
|
sweep();
|
||
|
|
const id = randomBytes(32).toString('hex');
|
||
|
|
tickets.set(id, { userId, expiresAt: Date.now() + TTL_MS });
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Redeem (and consume) a ticket. Returns the userId, or null if missing/expired.
|
||
|
|
export function redeemTicket(id) {
|
||
|
|
if (!id) return null;
|
||
|
|
const t = tickets.get(id);
|
||
|
|
if (!t) return null;
|
||
|
|
tickets.delete(id); // single-use
|
||
|
|
if (t.expiresAt <= Date.now()) return null;
|
||
|
|
return t.userId;
|
||
|
|
}
|