mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement auth ip rate-limits as fastify plugin (#20)
This commit is contained in:
@@ -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({
|
||||
|
||||
23
apps/server/src/api/client/plugins/auth-ip-rate-limit.ts
Normal file
23
apps/server/src/api/client/plugins/auth-ip-rate-limit.ts
Normal 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);
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
63
apps/server/src/lib/rate-limits.ts
Normal file
63
apps/server/src/lib/rate-limits.ts
Normal 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
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user