Files
platform/src/servers/_middlewares/rate-limiter.ts
T

108 lines
3.0 KiB
TypeScript

import type { Context, MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
type RateLimitEntry = {
count: number;
resetAt: number;
};
type RateLimiterOptions = {
windowMs: number;
max: number;
keyGenerator?: (ctx: Context) => string;
message?: string;
};
const stores = new Map<string, Map<string, RateLimitEntry>>();
function cleanupStore(store: Map<string, RateLimitEntry>) {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt <= now) {
store.delete(key);
}
}
}
const { PUBLIC_BUILD_ENV } = process.env;
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
const {
windowMs,
max,
keyGenerator = (ctx) => ctx.req.header('x-forwarded-for') || 'unknown',
message = 'Too many requests, please try again later',
} = options;
const storeId = `${windowMs}-${max}-${Math.random()}`;
const store = new Map<string, RateLimitEntry>();
stores.set(storeId, store);
// Cleanup every minute
setInterval(() => cleanupStore(store), 60_000);
return async (ctx, next) => {
if (!isProduction) return next();
const key = keyGenerator(ctx);
const now = Date.now();
const entry = store.get(key);
if (entry && entry.resetAt > now && entry.count >= max) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
}
await next();
// Only count failed attempts (4xx status codes)
const status = ctx.res.status;
if (status >= 400 && status < 500) {
if (entry && entry.resetAt > now) {
entry.count++;
} else {
store.set(key, { count: 1, resetAt: now + windowMs });
}
}
};
}
// Pre-configured rate limiters for common use cases
export const signinRateLimiter = rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
keyGenerator: (ctx) => {
const body = ctx.get('body');
return body?.email || ctx.req.header('x-forwarded-for') || 'unknown';
},
message: 'Too many sign in attempts, please try again in 15 minutes',
});
export const forgotPasswordRateLimiter = rateLimiter({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
keyGenerator: (ctx) => {
const body = ctx.get('body');
return body?.email || ctx.req.header('x-forwarded-for') || 'unknown';
},
message: 'Too many password reset requests, please try again in 1 hour',
});
export const signupRateLimiter = rateLimiter({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
keyGenerator: (ctx) => ctx.req.header('x-forwarded-for') || 'unknown',
message: 'Too many sign up attempts, please try again in 1 hour',
});
export const passkeyRateLimiter = rateLimiter({
windowMs: 60 * 1000, // 1 minute
max: 10,
keyGenerator: (ctx) => {
const email = ctx.req.param('email');
return email || ctx.req.header('x-forwarded-for') || 'unknown';
},
message: 'Too many passkey requests, please try again in 1 minute',
});