From b8b1b9a6d01f503cd979b03e8d2ecb7ef61e5919 Mon Sep 17 00:00:00 2001 From: Agusti Fernandez Pardo <6601142+agustif@users.noreply.github.com> Date: Tue, 30 Aug 2022 21:58:35 +0200 Subject: [PATCH] fix: rate limit auth (#3820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: rate limit auth * fix: replace lru-cache w memory-cache * remove comments * fix: yarn.lock * fix: remove changes yarn lock * fix: add missing EOL empty liune * fix: move rate limiter so it kicks the last, limit to 10 tries per minute * fix: move limiter w rest of code * test: trying fix onboardong * fix: undo changes in globalSetup.ts * test: fix disable login for onboarding * fix: use username instead of email for token check * fix: tests * fix: don't run on test * fix: add missing comma * fix: remove uniqueTokenPerInterval * fix: add errorcode to packages lib auth * Update packages/lib/rateLimit.ts fix: improve readability Co-authored-by: Omar López * Update packages/lib/rateLimit.ts fix: no unnecessary any Co-authored-by: Omar López * Update packages/lib/rateLimit.ts fix: improve readability Co-authored-by: Omar López * fix: rename interval -> intervalInMs * fix: check user.email not username which could be empty * fix: rateLimit update all naming Co-authored-by: Agusti Fernandez Pardo Co-authored-by: Omar López Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/web/lib/auth.ts | 1 + apps/web/pages/api/auth/[...nextauth].tsx | 6 +++++ apps/web/pages/auth/login.tsx | 3 ++- apps/web/public/static/locales/en/common.json | 1 + packages/lib/auth.ts | 22 +++++++++++++++ packages/lib/rateLimit.ts | 27 +++++++++++++++++++ 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/lib/rateLimit.ts diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index c868a62efd..a93f7a06e7 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 64d618d9bd..8b685b0c3c 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 999950c3e4..37903f386d 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 f6ba309f46..8aa60ce502 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 00c60f7a47..691e9775aa 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 0000000000..69a335902a --- /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;