diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index c868a62efd06df..a93f7a06e7b28c 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -32,6 +32,7 @@ export enum ErrorCode { InternalServerError = "internal-server-error", NewPasswordMatchesOld = "new-password-matches-old", ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled", + RateLimitExceeded = "rate-limit-exceeded", InvalidPassword = "invalid-password", } diff --git a/apps/web/pages/api/auth/[...nextauth].tsx b/apps/web/pages/api/auth/[...nextauth].tsx index 64d618d9bd6d9f..8b685b0c3c023e 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -15,6 +15,7 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso import { WEBAPP_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultCookies } from "@calcom/lib/default-cookies"; +import rateLimit from "@calcom/lib/rateLimit"; import { serverConfig } from "@calcom/lib/serverConfig"; import prisma from "@calcom/prisma"; @@ -100,6 +101,11 @@ const providers: Provider[] = [ } } + const limiter = rateLimit({ + intervalInMs: 60 * 1000, // 1 minute + }); + await limiter.check(10, user.email); // 10 requests per minute + return { id: user.id, username: user.username, diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 999950c3e4dd65..37903f386d8478 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -53,7 +53,8 @@ export default function Login({ const [errorMessage, setErrorMessage] = useState(null); const errorMessages: { [key: string]: string } = { - // [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), + [ErrorCode.RateLimitExceeded]: t("rate_limit_exceeded"), + [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`, [ErrorCode.UserNotFound]: t("no_account_exists"), [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index f6ba309f46b59e..8aa60ce502908d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1044,6 +1044,7 @@ "using_additional_inputs_as_variables": "How to use additional inputs as variables?", "download_desktop_app": "Download desktop app", "set_ping_link": "Set Ping link", + "rate_limit_exceeded": "Rate limit exceeded", "when_something_happens": "When something happens", "action_is_performed": "An action is performed", "test_action": "Test action", diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index 00c60f7a47dba6..691e9775aa1146 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -1,3 +1,4 @@ +import { IdentityProvider } from "@prisma/client"; import { compare, hash } from "bcryptjs"; import type { NextApiRequest } from "next"; import type { Session } from "next-auth"; @@ -60,3 +61,24 @@ export const ensureSession = async (ctxOrReq: CtxOrReq) => { if (!session?.user.id) throw new HttpError({ statusCode: 401, message: "Unauthorized" }); return session; }; + +export enum ErrorCode { + UserNotFound = "user-not-found", + IncorrectPassword = "incorrect-password", + UserMissingPassword = "missing-password", + TwoFactorDisabled = "two-factor-disabled", + TwoFactorAlreadyEnabled = "two-factor-already-enabled", + TwoFactorSetupRequired = "two-factor-setup-required", + SecondFactorRequired = "second-factor-required", + IncorrectTwoFactorCode = "incorrect-two-factor-code", + InternalServerError = "internal-server-error", + NewPasswordMatchesOld = "new-password-matches-old", + ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled", + RateLimitExceeded = "rate-limit-exceeded", +} + +export const identityProviderNameMap: { [key in IdentityProvider]: string } = { + [IdentityProvider.CAL]: "Cal", + [IdentityProvider.GOOGLE]: "Google", + [IdentityProvider.SAML]: "SAML", +}; diff --git a/packages/lib/rateLimit.ts b/packages/lib/rateLimit.ts new file mode 100644 index 00000000000000..69a335902a57d5 --- /dev/null +++ b/packages/lib/rateLimit.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import cache from "memory-cache"; + +import { ErrorCode } from "./auth"; + +const rateLimit = (options: { intervalInMs: number }) => { + return { + check: (requestLimit: number, uniqueIdentifier: string) => { + const count = cache.get(uniqueIdentifier) || [0]; + if (count[0] === 0) { + cache.put(uniqueIdentifier, count, options.intervalInMs); + } + count[0] += 1; + + const currentUsage = count[0]; + const isRateLimited = currentUsage >= requestLimit; + + if (isRateLimited) { + throw new Error(ErrorCode.RateLimitExceeded); + } + + return { isRateLimited, requestLimit, remaining: isRateLimited ? 0 : requestLimit - currentUsage }; + }, + }; +}; + +export default rateLimit;