feat: implement ratelimits on auth endpoints

This commit is contained in:
mrjvs
2026-08-19 14:15:34 +02:00
parent 316aedc69f
commit fd4baf7e16
6 changed files with 79 additions and 1 deletions

View File

@@ -3,6 +3,13 @@ import { LoginSchema } from '#shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
import type { ApiAuthLogin } from '#shared/api-types';
const bucket = createRatelimitBucket({
id: 'login',
points: 30,
durationSec: 5 * 60, // 5 minutes
blockDurationSec: 1 * 60 * 60 // 1 hour
});
const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: User not found': 'INVALID_USERNAME',
'INVALID_ARGUMENT: Password is incorrect': 'INVALID_PASSWORD',
@@ -10,6 +17,7 @@ const errors: Record<string, ApiErrorCodes> = {
};
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, LoginSchema);
const grpc = useApiGrpc(event);

View File

@@ -2,7 +2,15 @@ import { ClientError } from 'nice-grpc';
import { RefreshSchema } from '#shared/api-types';
import type { ApiAuthLogin } from '#shared/api-types';
const bucket = createRatelimitBucket({
id: 'refresh',
points: 10,
durationSec: 1 * 60, // 1 minute
blockDurationSec: 1 * 60 * 60 // 1 hour
});
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, RefreshSchema);
const grpc = useApiGrpc(event);

View File

@@ -3,6 +3,13 @@ import { RegisterSchema } from '#shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
import type { ApiAuthLogin } from '#shared/api-types';
const bucket = createRatelimitBucket({
id: 'register',
points: 15,
durationSec: 5 * 60, // 5 minutes
blockDurationSec: 1 * 60 * 60 // 1 hour
});
const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Captcha verification failed': 'INVALID_CAPTCHA',
'INVALID_ARGUMENT: Invalid email address': 'INVALID_EMAIL',
@@ -39,6 +46,7 @@ function assertAge(birthDate: string | undefined) {
}
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, RegisterSchema);
const grpc = useApiGrpc(event);
assertAge(body.birthday);

View File

@@ -70,7 +70,7 @@ function createCacher(event: H3Event): Cacher {
};
}
export function useCacher(event: H3Event): Cacher | null {
export function useCacher(event: H3Event): Cacher {
if (!cacher) {
cacher = createCacher(event);
}

View File

@@ -0,0 +1,52 @@
import { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
import type { RateLimiterAbstract, IRateLimiterOptions } from 'rate-limiter-flexible';
import type { H3Event } from 'h3';
let redisRatelimit: Redis | null = null;
export type RatelimitBucketOptions = {
id: string;
points: number;
durationSec: number;
blockDurationSec?: number;
};
export function createRatelimitBucket(ops: RatelimitBucketOptions) {
const config = useRuntimeConfig();
const prefix = `pn:website:ratelimit:${ops.id}`;
const ratelimitOps: IRateLimiterOptions = {
keyPrefix: prefix,
duration: ops.durationSec,
blockDuration: ops.blockDurationSec,
points: ops.points
};
if (config.redisUrl) {
if (!redisRatelimit) {
redisRatelimit = new Redis(config.redisUrl, {
enableOfflineQueue: false
});
}
return new RateLimiterRedis({
...ratelimitOps,
storeClient: redisRatelimit
});
}
return new RateLimiterMemory(ratelimitOps);
}
export async function enforceRatelimit(event: H3Event, bucket: RateLimiterAbstract): Promise<void> {
const config = useRuntimeConfig();
const ip = getRequestIP(event, { xForwardedFor: !!config.trustProxy });
if (!ip) {
throw new Error('Could not get IP for request');
}
try {
await bucket.consume(ip, 1);
} catch {
throw createApiError('RATELIMITED');
}
}

View File

@@ -1,5 +1,6 @@
const apiErrorCodes = {
UNPARSABLE_ERROR: 'Fatal exception!',
RATELIMITED: 'Too many requests!',
UNHANDLED_ERROR: 'Something went wrong',
INVALID_INPUT: 'Invalid input',
INTEGRATION_DISABLED: 'Integration with this service is disabled',
@@ -24,6 +25,7 @@ export type ApiErrorCodes = keyof typeof apiErrorCodes;
export const apiErrorCodeStatus: Record<ApiErrorCodes, number> = {
UNPARSABLE_ERROR: 500,
RATELIMITED: 429,
UNHANDLED_ERROR: 500,
INVALID_INPUT: 400,
INTEGRATION_DISABLED: 500,