From 81e4129e510eaa76248003b934b74d736ee147ec Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 6 Jun 2024 20:42:54 +0800 Subject: [PATCH] feat: added base captcha implementation --- .env.example | 5 ++ ...nsecutive-failed-password-attempts-user.ts | 29 ++++++++++++ backend/src/db/schemas/projects.ts | 3 +- backend/src/db/schemas/users.ts | 3 +- backend/src/lib/config/env.ts | 6 ++- backend/src/server/routes/v3/login-router.ts | 4 +- .../src/services/auth/auth-login-service.ts | 46 +++++++++++++++++-- backend/src/services/auth/auth-login-type.ts | 1 + frontend/next.config.js | 9 ++-- frontend/package-lock.json | 20 +++++++- frontend/package.json | 1 + .../components/utilities/attemptCliLogin.ts | 7 ++- .../src/components/utilities/attemptLogin.ts | 5 +- .../src/components/utilities/config/index.ts | 3 +- frontend/src/hooks/api/auth/types.ts | 1 + .../components/InitialStep/InitialStep.tsx | 33 +++++++++++-- .../components/PasswordStep/PasswordStep.tsx | 33 +++++++++++-- 17 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 backend/src/db/migrations/20240606073539_add-consecutive-failed-password-attempts-user.ts diff --git a/.env.example b/.env.example index bdb3e536d..33754d55d 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,8 @@ CLIENT_SECRET_GITHUB_LOGIN= CLIENT_ID_GITLAB_LOGIN= CLIENT_SECRET_GITLAB_LOGIN= + +CAPTCHA_SECRET= +CAPTCHA_ENABLED= + +NEXT_PUBLIC_CAPTCHA_SITE_KEY= diff --git a/backend/src/db/migrations/20240606073539_add-consecutive-failed-password-attempts-user.ts b/backend/src/db/migrations/20240606073539_add-consecutive-failed-password-attempts-user.ts new file mode 100644 index 000000000..66fa03182 --- /dev/null +++ b/backend/src/db/migrations/20240606073539_add-consecutive-failed-password-attempts-user.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConsecutiveFailedPasswordAttempts = await knex.schema.hasColumn( + TableName.Users, + "consecutiveFailedPasswordAttempts" + ); + + await knex.schema.alterTable(TableName.Users, (tb) => { + if (!hasConsecutiveFailedPasswordAttempts) { + tb.integer("consecutiveFailedPasswordAttempts").defaultTo(0); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasConsecutiveFailedPasswordAttempts = await knex.schema.hasColumn( + TableName.Users, + "consecutiveFailedPasswordAttempts" + ); + + await knex.schema.alterTable(TableName.Users, (tb) => { + if (hasConsecutiveFailedPasswordAttempts) { + tb.dropColumn("consecutiveFailedPasswordAttempts"); + } + }); +} diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 3965e24c0..dd7e99990 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -16,7 +16,8 @@ export const ProjectsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), version: z.number().default(1), - upgradeStatus: z.string().nullable().optional() + upgradeStatus: z.string().nullable().optional(), + pitVersionLimit: z.number().default(10) }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 9e0b9a3b5..5134f3ee6 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -25,7 +25,8 @@ export const UsersSchema = z.object({ isEmailVerified: z.boolean().default(false).nullable().optional(), consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), isLocked: z.boolean().default(false).nullable().optional(), - temporaryLockDateEnd: z.date().nullable().optional() + temporaryLockDateEnd: z.date().nullable().optional(), + consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 6ae8bf02d..d9438d77d 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -119,7 +119,11 @@ const envSchema = z .transform((val) => val === "true") .optional(), INFISICAL_CLOUD: zodStrBool.default("false"), - MAINTENANCE_MODE: zodStrBool.default("false") + MAINTENANCE_MODE: zodStrBool.default("false"), + + // CAPTCHA + CAPTCHA_SECRET: zpStr(z.string().optional()), + CAPTCHA_ENABLED: zodStrBool.default("false") }) .transform((data) => ({ ...data, diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 900ad56d2..4c7df5612 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -80,7 +80,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { body: z.object({ email: z.string().trim(), providerAuthToken: z.string().trim().optional(), - clientProof: z.string().trim() + clientProof: z.string().trim(), + captchaToken: z.string().trim().optional() }), response: { 200: z.discriminatedUnion("mfaEnabled", [ @@ -106,6 +107,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); const data = await server.services.login.loginExchangeClientProof({ + captchaToken: req.body.captchaToken, email: req.body.email, ip: req.realIp, userAgent, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index cbf43b245..065e304d4 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -176,12 +177,16 @@ export const authLoginServiceFactory = ({ clientProof, ip, userAgent, - providerAuthToken + providerAuthToken, + captchaToken }: TLoginClientProofDTO) => { + const appCfg = getConfig(); + const userEnc = await userDAL.findUserEncKeyByUsername({ username: email }); if (!userEnc) throw new Error("Failed to find user"); + const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); let authMethod = AuthMethod.EMAIL; @@ -196,6 +201,30 @@ export const authLoginServiceFactory = ({ } } + if ( + user.consecutiveFailedPasswordAttempts && + user.consecutiveFailedPasswordAttempts > 10 && + appCfg.CAPTCHA_ENABLED + ) { + if (!captchaToken) { + throw new BadRequestError({ + name: "Captcha Required" + }); + } + + // validate captcha token + const response = await request.postForm<{ success: boolean }>("https://api.hcaptcha.com/siteverify", { + response: captchaToken, + secret: appCfg.CAPTCHA_SECRET + }); + + if (!response.data.success) { + throw new BadRequestError({ + name: "Invalid Captcha" + }); + } + } + if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); const isValidClientProof = await srpCheckClientProof( userEnc.salt, @@ -204,7 +233,19 @@ export const authLoginServiceFactory = ({ userEnc.clientPublicKey, clientProof ); - if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); + + if (!isValidClientProof) { + await userDAL.update( + { id: userEnc.id }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new Error("Failed to authenticate. Try again?"); + } await userDAL.updateUserEncryptionByUserId(userEnc.userId, { serverPrivateKey: null, @@ -212,7 +253,6 @@ export const authLoginServiceFactory = ({ }); // send multi factor auth token if they it enabled if (userEnc.isMfaEnabled && userEnc.email) { - const user = await userDAL.findById(userEnc.userId); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); const mfaToken = jwt.sign( diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 37b90f548..4f73ec996 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -12,6 +12,7 @@ export type TLoginClientProofDTO = { providerAuthToken?: string; ip: string; userAgent: string; + captchaToken?: string; }; export type TVerifyMfaTokenDTO = { diff --git a/frontend/next.config.js b/frontend/next.config.js index 9d894694f..5e48e70da 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,13 +1,12 @@ - const path = require("path"); const ContentSecurityPolicy = ` default-src 'self'; - script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com 'unsafe-inline' 'unsafe-eval'; - style-src 'self' https://rsms.me 'unsafe-inline'; + script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; + style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; - frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/; - connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:*; + frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com; + connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com; img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:; media-src https://js.intercomcdn.com; font-src 'self' https://fonts.intercomcdn.com/ https://maxcdn.bootstrapcdn.com https://rsms.me https://fonts.gstatic.com; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c33c9dc36..489df0ea1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -19,6 +18,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@hcaptcha/react-hcaptcha": "^1.10.1", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", @@ -3200,6 +3200,24 @@ "react": ">=16.3" } }, + "node_modules/@hcaptcha/loader": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@hcaptcha/loader/-/loader-1.2.4.tgz", + "integrity": "sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw==" + }, + "node_modules/@hcaptcha/react-hcaptcha": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.10.1.tgz", + "integrity": "sha512-P0en4gEZAecah7Pt3WIaJO2gFlaLZKkI0+Tfdg8fNqsDxqT9VytZWSkH4WAkiPRULK1QcGgUZK+J56MXYmPifw==", + "dependencies": { + "@babel/runtime": "^7.17.9", + "@hcaptcha/loader": "^1.2.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/@headlessui/react": { "version": "1.7.18", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index e01ef945e..a4acb5738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@hcaptcha/react-hcaptcha": "^1.10.1", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index e95f5bf88..8f7c4bb9f 100644 --- a/frontend/src/components/utilities/attemptCliLogin.ts +++ b/frontend/src/components/utilities/attemptCliLogin.ts @@ -30,11 +30,13 @@ export interface IsCliLoginSuccessful { const attemptLogin = async ({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }: { email: string; password: string; providerAuthToken?: string; + captchaToken?: string; }): Promise => { const telemetry = new Telemetry().getInstance(); return new Promise((resolve, reject) => { @@ -70,7 +72,8 @@ const attemptLogin = async ({ } = await login2({ email, clientProof, - providerAuthToken + providerAuthToken, + captchaToken }); if (mfaEnabled) { // case: MFA is enabled diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index 195cf9b9a..b909b1ba7 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -22,11 +22,13 @@ interface IsLoginSuccessful { const attemptLogin = async ({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }: { email: string; password: string; providerAuthToken?: string; + captchaToken?: string; }): Promise => { const telemetry = new Telemetry().getInstance(); // eslint-disable-next-line new-cap @@ -58,6 +60,7 @@ const attemptLogin = async ({ iv, tag } = await login2({ + captchaToken, email, clientProof, providerAuthToken diff --git a/frontend/src/components/utilities/config/index.ts b/frontend/src/components/utilities/config/index.ts index 10d4856c0..9b3bf37f1 100644 --- a/frontend/src/components/utilities/config/index.ts +++ b/frontend/src/components/utilities/config/index.ts @@ -2,5 +2,6 @@ const ENV = process.env.NEXT_PUBLIC_ENV! || "development"; // investigate const POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY!; const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com"; const INTERCOMid = process.env.NEXT_PUBLIC_INTERCOMid!; +const CAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY!; -export { ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST }; +export { CAPTCHA_SITE_KEY, ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST }; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 41c324bff..ce1b18bc8 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -30,6 +30,7 @@ export type Login1DTO = { }; export type Login2DTO = { + captchaToken?: string; email: string; clientProof: string; providerAuthToken?: string; diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index a4e5e89f5..0ae253d03 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -1,15 +1,17 @@ -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; +import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config"; import { Button, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; @@ -31,6 +33,9 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: const [loginError, setLoginError] = useState(false); const { config } = useServerConfig(); const queryParams = new URLSearchParams(window.location.search); + const [captchaToken, setCaptchaToken] = useState(""); + const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false); + const captchaRef = useRef(null); useEffect(() => { if ( @@ -61,7 +66,8 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: // attemptCliLogin const isCliLoginSuccessful = await attemptCliLogin({ email: email.toLowerCase(), - password + password, + captchaToken }); if (isCliLoginSuccessful && isCliLoginSuccessful.success) { @@ -83,7 +89,8 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: } else { const isLoginSuccessful = await attemptLogin({ email: email.toLowerCase(), - password + password, + captchaToken }); if (isLoginSuccessful && isLoginSuccessful.success) { @@ -117,6 +124,12 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } + if (err.response.data.error === "Captcha Required") { + setShouldShowCaptcha(true); + setIsLoading(false); + return; + } + setLoginError(true); createNotification({ text: "Login unsuccessful. Double-check your credentials and try again.", @@ -124,6 +137,10 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } + if (captchaRef.current) { + captchaRef.current.resetCaptcha(); + } + setIsLoading(false); }; @@ -245,6 +262,16 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: className="select:-webkit-autofill:focus h-10" /> + {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}