mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-09-12 10:57:50 -05:00
feat: implement ratelimits on auth endpoints
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
52
server/utils/ratelimits.ts
Normal file
52
server/utils/ratelimits.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user