103 lines
3.1 KiB
TypeScript
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();
|
|
}
|