Implement auth ip rate-limits as fastify plugin (#20)

This commit is contained in:
Hakan Shehu
2025-05-01 11:30:12 +02:00
committed by GitHub
parent 90c7a22685
commit b86ae4e5e4
12 changed files with 128 additions and 146 deletions

View File

@@ -3,7 +3,7 @@ import fp from 'fastify-plugin';
import { ApiErrorCode } from '@colanode/core';
import { parseToken, verifyToken } from '@/lib/tokens';
import { rateLimitService } from '@/services/rate-limit-service';
import { isDeviceApiRateLimited } from '@/lib/rate-limits';
import { RequestAccount } from '@/types/api';
declare module 'fastify' {
@@ -48,9 +48,7 @@ const accountAuthenticatorCallback: FastifyPluginCallback = (
});
}
const isRateLimited = await rateLimitService.isDeviceApiRateLimitted(
tokenData.deviceId
);
const isRateLimited = await isDeviceApiRateLimited(tokenData.deviceId);
if (isRateLimited) {
return reply.code(429).send({

View File

@@ -0,0 +1,23 @@
import { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { ApiErrorCode } from '@colanode/core';
import { isAuthIpRateLimited } from '@/lib/rate-limits';
const authIpRateLimiterCallback: FastifyPluginCallback = (fastify, _, done) => {
fastify.addHook('onRequest', async (request, reply) => {
const ip = request.client.ip;
const isIpRateLimited = await isAuthIpRateLimited(ip);
if (isIpRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
});
done();
};
export const authIpRateLimiter = fp(authIpRateLimiterCallback);

View File

@@ -1,7 +1,6 @@
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import {
AccountUpdateInput,
accountUpdateInputSchema,
AccountUpdateOutput,
accountUpdateOutputSchema,
@@ -34,7 +33,7 @@ export const accountUpdateRoute: FastifyPluginCallbackZod = (
},
handler: async (request, reply) => {
const accountId = request.params.accountId;
const input = request.body as AccountUpdateInput;
const input = request.body;
if (accountId !== request.account.id) {
return reply.code(400).send({

View File

@@ -8,7 +8,7 @@ import {
} from '@colanode/core';
import { database } from '@/data/database';
import { rateLimitService } from '@/services/rate-limit-service';
import { isAuthEmailRateLimited } from '@/lib/rate-limits';
import { configuration } from '@/lib/configuration';
import {
buildLoginSuccessOutput,
@@ -34,20 +34,10 @@ export const emailLoginRoute: FastifyPluginCallbackZod = (
},
},
handler: async (request, reply) => {
const ip = request.client.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input = request.body;
const email = input.email.toLowerCase();
const isEmailRateLimited =
await rateLimitService.isAuthEmailRateLimitted(email);
const isEmailRateLimited = await isAuthEmailRateLimited(email);
if (isEmailRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,

View File

@@ -3,14 +3,13 @@ import {
AccountStatus,
ApiErrorCode,
apiErrorOutputSchema,
EmailPasswordResetCompleteInput,
emailPasswordResetCompleteInputSchema,
EmailPasswordResetCompleteOutput,
emailPasswordResetCompleteOutputSchema,
} from '@colanode/core';
import { database } from '@/data/database';
import { generatePasswordHash, verifyOtpCode } from '@/lib/accounts';
import { rateLimitService } from '@/services/rate-limit-service';
export const emailPasswordResetCompleteRoute: FastifyPluginCallbackZod = (
instance,
@@ -21,6 +20,7 @@ export const emailPasswordResetCompleteRoute: FastifyPluginCallbackZod = (
method: 'POST',
url: '/emails/passwords/reset/complete',
schema: {
body: emailPasswordResetCompleteInputSchema,
response: {
200: emailPasswordResetCompleteOutputSchema,
400: apiErrorOutputSchema,
@@ -29,16 +29,7 @@ export const emailPasswordResetCompleteRoute: FastifyPluginCallbackZod = (
},
},
handler: async (request, reply) => {
const ip = request.client.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input = request.body as EmailPasswordResetCompleteInput;
const input = request.body;
const accountId = await verifyOtpCode(input.id, input.otp);
if (!accountId) {

View File

@@ -3,14 +3,14 @@ import {
generateId,
IdType,
ApiErrorCode,
EmailPasswordResetInitInput,
EmailPasswordResetInitOutput,
apiErrorOutputSchema,
emailPasswordResetInitOutputSchema,
emailPasswordResetInitInputSchema,
} from '@colanode/core';
import { database } from '@/data/database';
import { rateLimitService } from '@/services/rate-limit-service';
import { isAuthEmailRateLimited } from '@/lib/rate-limits';
import { configuration } from '@/lib/configuration';
import { generateOtpCode, saveOtp } from '@/lib/otps';
import { AccountPasswordResetOtpAttributes, Otp } from '@/types/otps';
@@ -25,6 +25,7 @@ export const emailPasswordResetInitRoute: FastifyPluginCallbackZod = (
method: 'POST',
url: '/emails/passwords/reset/init',
schema: {
body: emailPasswordResetInitInputSchema,
response: {
200: emailPasswordResetInitOutputSchema,
400: apiErrorOutputSchema,
@@ -32,20 +33,10 @@ export const emailPasswordResetInitRoute: FastifyPluginCallbackZod = (
},
},
handler: async (request, reply) => {
const ip = request.client.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input = request.body as EmailPasswordResetInitInput;
const input = request.body;
const email = input.email.toLowerCase();
const isEmailRateLimited =
await rateLimitService.isAuthEmailRateLimitted(email);
const isEmailRateLimited = await isAuthEmailRateLimited(email);
if (isEmailRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,

View File

@@ -1,17 +1,17 @@
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import {
AccountStatus,
EmailRegisterInput,
generateId,
IdType,
ApiErrorCode,
apiErrorOutputSchema,
loginOutputSchema,
emailRegisterInputSchema,
} from '@colanode/core';
import { database } from '@/data/database';
import { SelectAccount } from '@/data/schema';
import { rateLimitService } from '@/services/rate-limit-service';
import { isAuthEmailRateLimited } from '@/lib/rate-limits';
import { configuration } from '@/lib/configuration';
import {
buildLoginSuccessOutput,
@@ -28,6 +28,7 @@ export const emailRegisterRoute: FastifyPluginCallbackZod = (
method: 'POST',
url: '/emails/register',
schema: {
body: emailRegisterInputSchema,
response: {
200: loginOutputSchema,
400: apiErrorOutputSchema,
@@ -35,20 +36,10 @@ export const emailRegisterRoute: FastifyPluginCallbackZod = (
},
},
handler: async (request, reply) => {
const ip = request.client.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input = request.body as EmailRegisterInput;
const input = request.body;
const email = input.email.toLowerCase();
const isEmailRateLimited =
await rateLimitService.isAuthEmailRateLimitted(email);
const isEmailRateLimited = await isAuthEmailRateLimited(email);
if (isEmailRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,

View File

@@ -3,7 +3,7 @@ import {
AccountStatus,
ApiErrorCode,
apiErrorOutputSchema,
EmailVerifyInput,
emailVerifyInputSchema,
loginOutputSchema,
} from '@colanode/core';
@@ -19,6 +19,7 @@ export const emailVerifyRoute: FastifyPluginCallbackZod = (
method: 'POST',
url: '/emails/verify',
schema: {
body: emailVerifyInputSchema,
response: {
200: loginOutputSchema,
400: apiErrorOutputSchema,
@@ -26,7 +27,7 @@ export const emailVerifyRoute: FastifyPluginCallbackZod = (
},
},
handler: async (request, reply) => {
const input = request.body as EmailVerifyInput;
const input = request.body;
const accountId = await verifyOtpCode(input.id, input.otp);
if (!accountId) {

View File

@@ -2,23 +2,22 @@ import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import {
AccountStatus,
generateId,
GoogleLoginInput,
GoogleUserInfo,
IdType,
ApiErrorCode,
apiErrorOutputSchema,
loginOutputSchema,
googleLoginInputSchema,
} from '@colanode/core';
import axios from 'axios';
import { database } from '@/data/database';
import { rateLimitService } from '@/services/rate-limit-service';
import { configuration } from '@/lib/configuration';
import { buildLoginSuccessOutput } from '@/lib/accounts';
const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo';
export const loginWithGoogleRoute: FastifyPluginCallbackZod = (
export const googleLoginRoute: FastifyPluginCallbackZod = (
instance,
_,
done
@@ -27,6 +26,7 @@ export const loginWithGoogleRoute: FastifyPluginCallbackZod = (
method: 'POST',
url: '/google/login',
schema: {
body: googleLoginInputSchema,
response: {
200: loginOutputSchema,
400: apiErrorOutputSchema,
@@ -41,16 +41,7 @@ export const loginWithGoogleRoute: FastifyPluginCallbackZod = (
});
}
const ip = request.client.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return reply.code(429).send({
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input = request.body as GoogleLoginInput;
const input = request.body;
const url = `${GoogleUserInfoUrl}?access_token=${input.access_token}`;
const userInfoResponse = await axios.get(url);

View File

@@ -2,30 +2,35 @@ import { FastifyPluginCallback } from 'fastify';
import { accountSyncRoute } from './account-sync';
import { emailLoginRoute } from './email-login';
import { loginWithGoogleRoute } from './login-google';
import { logoutRoute } from './logout';
import { emailRegisterRoute } from './email-register';
import { accountUpdateRoute } from './account-update';
import { emailVerifyRoute } from './email-verify';
import { emailPasswordResetInitRoute } from './email-password-reset-init';
import { emailPasswordResetCompleteRoute } from './email-password-reset-complete';
import { googleLoginRoute } from './google-login';
import { accountAuthenticator } from '@/api/client/plugins/account-auth';
import { authIpRateLimiter } from '@/api/client/plugins/auth-ip-rate-limit';
export const accountRoutes: FastifyPluginCallback = (instance, _, done) => {
instance.register(emailLoginRoute);
instance.register(loginWithGoogleRoute);
instance.register(emailRegisterRoute);
instance.register(emailVerifyRoute);
instance.register(emailPasswordResetInitRoute);
instance.register(emailPasswordResetCompleteRoute);
instance.register((subInstance) => {
subInstance.register(authIpRateLimiter);
instance.register(async (subInstance) => {
await subInstance.register(accountAuthenticator);
subInstance.register(emailLoginRoute);
subInstance.register(emailRegisterRoute);
subInstance.register(emailVerifyRoute);
subInstance.register(emailPasswordResetInitRoute);
subInstance.register(emailPasswordResetCompleteRoute);
subInstance.register(googleLoginRoute);
});
await subInstance.register(accountSyncRoute);
await subInstance.register(logoutRoute);
await subInstance.register(accountUpdateRoute);
instance.register((subInstance) => {
subInstance.register(accountAuthenticator);
subInstance.register(accountSyncRoute);
subInstance.register(accountUpdateRoute);
subInstance.register(logoutRoute);
});
done();

View File

@@ -0,0 +1,63 @@
import { sha256 } from 'js-sha256';
import { redis } from '@/data/redis';
interface RateLimitConfig {
limit: number;
window: number;
}
const defaultConfig: RateLimitConfig = {
limit: 10,
window: 300, // 5 minutes
};
const isRateLimited = async (
key: string,
config: RateLimitConfig = defaultConfig
): Promise<boolean> => {
const redisKey = `rt:${key}`;
const attempts = await redis.incr(redisKey);
// Set expiry on first attempt
if (attempts === 1) {
await redis.expire(redisKey, config.window);
}
return attempts > config.limit;
};
export const isAuthIpRateLimited = async (ip: string): Promise<boolean> => {
return await isRateLimited(`ai:${ip}`, {
limit: 100,
window: 600, // 10 minutes
});
};
export const isAuthEmailRateLimited = async (
email: string
): Promise<boolean> => {
const emailHash = sha256(email);
return await isRateLimited(`ae:${emailHash}`, {
limit: 10,
window: 600, // 10 minutes
});
};
export const isDeviceApiRateLimited = async (
deviceId: string
): Promise<boolean> => {
return await isRateLimited(`da:${deviceId}`, {
limit: 100,
window: 60, // 1 minute
});
};
export const isDeviceSocketRateLimited = async (
deviceId: string
): Promise<boolean> => {
return await isRateLimited(`ds:${deviceId}`, {
limit: 20,
window: 60, // 1 minute
});
};

View File

@@ -1,61 +0,0 @@
import { sha256 } from 'js-sha256';
import { redis } from '@/data/redis';
interface RateLimitConfig {
limit: number;
window: number;
}
class RateLimitService {
private defaultConfig: RateLimitConfig = {
limit: 10,
window: 300, // 5 minutes
};
public async isAuthIpRateLimitted(ip: string): Promise<boolean> {
return await this.isRateLimited(`ai:${ip}`, {
limit: 100,
window: 600, // 10 minutes
});
}
public async isAuthEmailRateLimitted(email: string): Promise<boolean> {
const emailHash = sha256(email);
return await this.isRateLimited(`ae:${emailHash}`, {
limit: 10,
window: 600, // 10 minutes
});
}
public async isDeviceApiRateLimitted(deviceId: string): Promise<boolean> {
return await this.isRateLimited(`da:${deviceId}`, {
limit: 100,
window: 60, // 1 minute
});
}
public async isDeviceSocketRateLimitted(deviceId: string): Promise<boolean> {
return await this.isRateLimited(`ds:${deviceId}`, {
limit: 20,
window: 60, // 1 minute
});
}
private async isRateLimited(
key: string,
config: RateLimitConfig = this.defaultConfig
): Promise<boolean> {
const redisKey = `rt:${key}`;
const attempts = await redis.incr(redisKey);
// Set expiry on first attempt
if (attempts === 1) {
await redis.expire(redisKey, config.window);
}
return attempts > config.limit;
}
}
export const rateLimitService = new RateLimitService();