diff --git a/package-lock.json b/package-lock.json index fac58fa..1ae6fc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,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", @@ -41,6 +42,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", @@ -58,6 +60,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", @@ -1466,6 +1469,11 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -5209,6 +5217,15 @@ "@types/react": "*" } }, + "node_modules/@types/request-ip": { + "version": "0.0.40", + "resolved": "https://registry.npmjs.org/@types/request-ip/-/request-ip-0.0.40.tgz", + "integrity": "sha512-3pBKLRLlE5YwVWp2hQRqv6dOXWkoE3bEt+EXHTVd8Q3XNnIWFJx+VC2Dy8+DAnBI9MmSqHSljUZPeuTBMvsNYg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.5", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", @@ -6123,6 +6140,14 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -6388,6 +6413,14 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -8140,6 +8173,29 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -8884,6 +8940,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.foreach": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", @@ -8899,6 +8960,11 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -10530,6 +10596,25 @@ "node": ">= 0.10" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -10580,6 +10665,11 @@ "node": ">=8" } }, + "node_modules/request-ip": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/request-ip/-/request-ip-3.3.0.tgz", + "integrity": "sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -11003,6 +11093,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/package.json b/package.json index 530e832..979f90b 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/src/app/api/auth/forgotpassword/forgot-password.dto.ts b/src/app/api/auth/forgotpassword/forgot-password.dto.ts new file mode 100644 index 0000000..188233a --- /dev/null +++ b/src/app/api/auth/forgotpassword/forgot-password.dto.ts @@ -0,0 +1,9 @@ +import {z} from "zod"; + +export type ForgotPasswordDto = { + email: string, +} + +export const ForgotPasswordSchema = z.object({ + email: z.string().email("Invalid email!") +}) \ No newline at end of file diff --git a/src/app/api/auth/forgotpassword/forgot-password.service.ts b/src/app/api/auth/forgotpassword/forgot-password.service.ts new file mode 100644 index 0000000..c26a7ed --- /dev/null +++ b/src/app/api/auth/forgotpassword/forgot-password.service.ts @@ -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() { + + } +} \ No newline at end of file diff --git a/src/app/api/auth/forgotpassword/route.ts b/src/app/api/auth/forgotpassword/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/middlewares/rate-limiter.ts b/src/app/api/middlewares/rate-limiter.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/utils/api-utils.ts b/src/app/api/utils/api-utils.ts index a658052..cbdb621 100644 --- a/src/app/api/utils/api-utils.ts +++ b/src/app/api/utils/api-utils.ts @@ -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 = { params: T @@ -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, options?: RateLimiterOptions): Promise => { + 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, options?: AuthenticatedRequestOptions): Promise => { @@ -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) diff --git a/src/app/api/utils/rate-limiter.ts b/src/app/api/utils/rate-limiter.ts new file mode 100644 index 0000000..05d8cb7 --- /dev/null +++ b/src/app/api/utils/rate-limiter.ts @@ -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): Promise { + 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!" + }) + } +} \ No newline at end of file diff --git a/src/app/api/utils/redis.ts b/src/app/api/utils/redis.ts new file mode 100644 index 0000000..37b897c --- /dev/null +++ b/src/app/api/utils/redis.ts @@ -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 \ No newline at end of file