Skip to content

Commit

Permalink
Start rate limiter implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
bombies committed Nov 3, 2023
1 parent 67143b3 commit 5cf474c
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 5 deletions.
95 changes: 95 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dayjs": "^1.11.10",
"framer-motion": "^10.16.4",
"intro.js-react": "^1.0.0",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"next": "^14.0.1",
"next-auth": "^4.24.4",
Expand All @@ -44,6 +45,7 @@
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.47.0",
"react-hot-toast": "^2.4.1",
"request-ip": "^3.3.0",
"sass": "^1.69.5",
"sharp": "^0.32.6",
"swr": "^2.2.4",
Expand All @@ -61,6 +63,7 @@
"@types/ramda": "^0.29.7",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/request-ip": "^0.0.40",
"autoprefixer": "^10",
"eslint": "^8",
"eslint-config-next": "14.0.1",
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/auth/forgotpassword/forgot-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {z} from "zod";

export type ForgotPasswordDto = {
email: string,
}

export const ForgotPasswordSchema = z.object({
email: z.string().email("Invalid email!")
})
15 changes: 15 additions & 0 deletions src/app/api/auth/forgotpassword/forgot-password.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class ForgotPasswordService {

/**
* This function will work in a way such that when a user requests
* a password resets, if there's already an existing one, the previous
* request will be invalidated and the new one created and pushed to the
* database. Once the database record has been created, a URL will be
* generated to redirect the user to a page which allows them to reset
* their password. The URL will be "signed", meaning it contains a signed
* JWT token to ensure the integrity of the requests.
*/
public async sendPasswordResetUrl() {

}
}
Empty file.
Empty file.
30 changes: 25 additions & 5 deletions src/app/api/utils/api-utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {NextResponse} from "next/server";
import {NextRequest, NextResponse} from "next/server";
import {getServerSession, Session} from "next-auth";
import authOptions from "@/app/api/auth/[...nextauth]/utils";
import {buildResponse} from "@/app/api/utils/types";
import {Member, Prisma} from "@prisma/client";
import prisma from "@/libs/prisma";
import PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError;
import RateLimiter, {RateLimiterGeoLimit} from "@/app/api/utils/rate-limiter";

export type RouteContext<T extends { [K: string]: string }> = {
params: T
Expand All @@ -18,9 +19,22 @@ type PrismaErrorOptions = {

}

type RateLimiterOptions = {
LIMIT_PER_SECOND?: number,
DURATION?: number,
GEO_LIMITS?: RateLimiterGeoLimit[]
}

type AuthenticatedRequestOptions = {
fetchMember?: boolean
prismaErrors?: PrismaErrorOptions
request?: NextRequest,
fetchMember?: boolean,
prismaErrors?: PrismaErrorOptions,
rateLimiter?: RateLimiterOptions
}

export const rateLimited = async (req: NextRequest, logic: () => Promise<NextResponse>, options?: RateLimiterOptions): Promise<NextResponse> => {
const rateLimiter = new RateLimiter(options?.LIMIT_PER_SECOND, options?.DURATION, options?.GEO_LIMITS)
return rateLimiter.handle(req, logic)
}

export const authenticated = async (logic: (session: Session, member?: Member) => Promise<NextResponse>, options?: AuthenticatedRequestOptions): Promise<NextResponse> => {
Expand All @@ -45,10 +59,16 @@ export const authenticated = async (logic: (session: Session, member?: Member) =
message: "Couldn't find information for your user!"
})

return await logic(session, member)
if (options.request && options.rateLimiter)
return await rateLimited(options.request, () => logic(session, member))
else
return await logic(session, member)
}

return await logic(session)
if (options?.request && options?.rateLimiter)
return await rateLimited(options.request, () => logic(session))
else
return await logic(session)
} catch (e) {
const prismaError = prismaErrorHandler(e, options?.prismaErrors)
if (prismaError)
Expand Down
67 changes: 67 additions & 0 deletions src/app/api/utils/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {NextRequest, NextResponse} from "next/server";
import redis from "@/app/api/utils/redis";
import Redis from "ioredis";
import {buildResponse} from "@/app/api/utils/types";

export type RateLimiterLocation = {
country: string,
city: string,
}

export type RateLimiterGeoLimit = {
location: RateLimiterLocation,
limit: number,
duration: number,
}

export default class RateLimiter {
private readonly redisClient: Redis

constructor(
private readonly LIMIT_PER_SECOND = 10,
private readonly DURATION = 60,
private readonly GEO_LIMITS: RateLimiterGeoLimit[] = []
) {
this.redisClient = redis
}

public async handle(req: NextRequest, work: () => Promise<NextResponse>): Promise<NextResponse> {
const ipAddr = req.ip
if (!ipAddr)
return buildResponse({
status: 403,
message: "Couldn't fetch your IP address for this rate-limited route!"
})

const {country, city} = req.geo ?? {country: undefined, city: undefined}
const matchingLimit = this.GEO_LIMITS.find((limit) =>
limit.location.country === country && limit.location.city === city
)

const doRateLimitCheck = async (key: string, limitMax: number) => {
let count = Number(await this.redisClient.get(key) || 0)

if (count >= limitMax)
return this.tooManyRequests()

this.redisClient.incr(key)
this.redisClient.expire(key, this.DURATION)
return work()
}

if (!matchingLimit)
return doRateLimitCheck(`dreamlogger_${process.env.NODE_ENV}_ratelimit:${ipAddr}`, this.LIMIT_PER_SECOND)
else
return doRateLimitCheck(
`dreamlogger_${process.env.NODE_ENV}_ratelimit:${country}:${city}:${ipAddr}`,
matchingLimit.limit
)
}

private tooManyRequests(): NextResponse {
return NextResponse.json(null, {
status: 429,
statusText: "You are being rate limited!"
})
}
}
10 changes: 10 additions & 0 deletions src/app/api/utils/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Redis, {RedisOptions} from "ioredis";

const redisOptions: RedisOptions = {
host: process.env.REDIS_HOSTNAME!,
port: parseInt(process.env.REDIS_PORT!),
password: process.env.REDIS_PASSWORD!
}

const redis = new Redis(redisOptions)
export default redis

0 comments on commit 5cf474c

Please sign in to comment.