40 lines
1.5 KiB
TypeScript
40 lines
1.5 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { cache } from "~/utils/cache.server";
|
|
import { getClientIPAddress } from "~/utils/server/IpUtils";
|
|
|
|
type AuthRateLimitOptions = {
|
|
request: Request;
|
|
action: "login" | "forgot-password" | "reset-password" | "mfa";
|
|
identifier?: string | null;
|
|
limit: number;
|
|
windowMs: number;
|
|
};
|
|
|
|
type AuthRateLimitResult =
|
|
| { allowed: true }
|
|
| { allowed: false; retryAfterSeconds: number };
|
|
|
|
export function getAuthRateLimitKey({ request, action, identifier }: Omit<AuthRateLimitOptions, "limit" | "windowMs">) {
|
|
const ipAddress = getClientIPAddress(request.headers)?.toString() ?? "unknown";
|
|
const normalizedIdentifier = identifier?.trim().toLowerCase() ?? "";
|
|
const digest = createHash("sha256").update(`${action}:${ipAddress}:${normalizedIdentifier}`).digest("hex");
|
|
return `auth-rate-limit:${action}:${digest}`;
|
|
}
|
|
|
|
export function checkAuthRateLimit(options: AuthRateLimitOptions): AuthRateLimitResult {
|
|
const key = getAuthRateLimitKey(options);
|
|
const now = Date.now();
|
|
const entry = cache.get(key)?.value as { count: number; resetAt: number } | undefined;
|
|
const current = entry && entry.resetAt > now ? entry : { count: 0, resetAt: now + options.windowMs };
|
|
const count = current.count + 1;
|
|
|
|
cache.set(key, { value: { count, resetAt: current.resetAt }, metadata: { createdTime: now } }, { ttl: Math.max(1, current.resetAt - now) });
|
|
|
|
if (count > options.limit) {
|
|
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)) };
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|