Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/mfa/challenge-rate-limit.server.ts
T

103 lines
3.1 KiB
TypeScript

import {
createFixedWindowRateLimiter,
type FixedWindowRateLimiter,
type SharedStore,
} from "@toolctrl/shared-store";
import { getSharedStore } from "~/shared-store.server";
const MFA_CHALLENGE_LIMIT = 5;
const MFA_CHALLENGE_WINDOW_SECONDS = 5 * 60;
export type MfaChallengeRateLimitResult =
| { allowed: true; remaining: number; resetAt: Date }
| { allowed: false; retryAfterSeconds: number; resetAt: Date };
type Limiter = {
limiter: FixedWindowRateLimiter;
store: SharedStore;
};
let cached: Limiter | null = null;
let storeFactory: () => SharedStore = () => getSharedStore();
function getLimiter(): Limiter {
if (cached !== null) return cached;
const store = storeFactory();
cached = {
store,
limiter: createFixedWindowRateLimiter({
store,
prefix: "mfa-challenge",
windowSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
max: MFA_CHALLENGE_LIMIT,
}),
};
return cached;
}
export function mfaChallengeRateLimitKey(request: Request, userId: string) {
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const ip = forwardedFor || request.headers.get("x-real-ip")?.trim() || "unknown";
return `${userId}:${ip}`;
}
export async function checkMfaChallengeRateLimit(key: string, now: Date = new Date()): Promise<MfaChallengeRateLimitResult> {
const { store } = getLimiter();
const suffix = `rl:fixed:mfa-challenge:${key}`;
const raw = await store.get(suffix);
const count = raw === null ? 0 : Number.parseInt(raw, 10) || 0;
if (count >= MFA_CHALLENGE_LIMIT) {
return {
allowed: false,
retryAfterSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
};
}
return {
allowed: true,
remaining: MFA_CHALLENGE_LIMIT - count,
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
};
}
export async function recordFailedMfaChallengeAttempt(key: string, _now: Date = new Date()): Promise<void> {
const { limiter } = getLimiter();
await limiter.consume(key);
}
export async function resetMfaChallengeRateLimit(key: string): Promise<void> {
const { limiter } = getLimiter();
await limiter.reset(key);
}
export async function resetMfaChallengeRateLimiterForTests(): Promise<void> {
const previous = cached;
cached = null;
if (previous) {
try {
for await (const fullKey of previous.store.adapter.scan(
`shared-store:${previous.store.namespace}:rl:fixed:mfa-challenge:*`,
)) {
const prefix = `shared-store:${previous.store.namespace}:`;
if (fullKey.startsWith(prefix)) {
await previous.store.del(fullKey.slice(prefix.length));
}
}
} catch {
// Best-effort; isolated tests inject a fresh store via the seam.
}
}
}
/** Test seam: inject a custom SharedStore. */
export function __setMfaChallengeRateLimitStoreForTests(store: SharedStore): void {
cached = null;
storeFactory = () => store;
}
/** Test seam: revert to the default web shared-store. */
export function __resetMfaChallengeRateLimitStoreForTests(): void {
cached = null;
storeFactory = () => getSharedStore();
}