Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding recaptcha_v3 #2031

Merged
merged 16 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions blocks/identity-block/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@
@include scss.block-components("login-form-privacy-statement");
@include scss.block-properties("login-form-privacy-statement");
}
&__sign-up-div {
@include scss.block-components("login-form-sign-up-div");
@include scss.block-properties("login-form-sign-up-div");
}
&__sign-up-button {
@include scss.block-components("login-form-sign-up-button");
@include scss.block-properties("login-form-sign-up-button");
}
&__login-form-error {
@include scss.block-components("login-form-error");
@include scss.block-properties("login-form-error");
}
}

.b-login-links {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,64 +1,57 @@
import React, { useState, useEffect } from "react";
import { useFusionContext } from "fusion:context";
import getProperties from "fusion:properties";
import getTranslatedPhrases from "fusion:intl";
import { Paragraph, useIdentity, useSales } from "@wpmedia/arc-themes-components";
import React from "react";
import ReCAPTCHA from "react-google-recaptcha";
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";

const BotChallengeProtection = ({ challengeIn, setCaptchaToken, className, captchaError, setCaptchaError }) => {
const { Identity, isInitialized } = useIdentity();
const { Sales } = useSales();
const [siteKey, setSiteKey] = useState();
import { usePhrases, useIdentity, Paragraph } from "@wpmedia/arc-themes-components";
import useRecaptcha, { RECAPTCHA_V2, RECAPTCHA_V3 } from "../../utils/useRecaptcha";

// eslint-disable-next-line
import RecaptchaV3 from "./reCaptchaV3";

export const ARCXP_CAPTCHA= "ArcXP_captchaToken"

const BotChallengeProtection = ({ challengeIn, setCaptchaToken, className, captchaError, setCaptchaError, resetRecaptcha }) => {

const { isInitialized } = useIdentity();
const { recaptchaVersion, siteKey, isRecaptchaEnabled } = useRecaptcha(challengeIn);
const phrases = usePhrases();

const onChange = (value) => {
setCaptchaToken(value);
setCaptchaError(null);
localStorage.setItem('ArcXP_captchaToken', value);
localStorage.setItem(ARCXP_CAPTCHA, value);
};

useEffect(() => {
const checkCaptcha = async () => {
const config = await Identity.getConfig();
const {recaptchaSiteKey, recaptchaScore } = config;
const isIdentityCaptchaEnabled = config?.[`${challengeIn}Recaptcha`];

if(['signup', 'signin', 'magicLink'].includes(challengeIn)) {
if (isIdentityCaptchaEnabled && recaptchaScore === '-1' && recaptchaSiteKey) {
setSiteKey(recaptchaSiteKey);
}
}

if (challengeIn === 'checkout') {
const salesConfig = await Sales.getConfig();
const isSalesCaptchaEnabled = salesConfig?.checkoutRecaptchaEnabled;
if (isSalesCaptchaEnabled && recaptchaScore === '-1' && recaptchaSiteKey) {
setSiteKey(recaptchaSiteKey);
}
}

};
checkCaptcha();

}, [Identity, Sales, challengeIn]);

const { arcSite } = useFusionContext();
const { locale } = getProperties(arcSite);
const phrases = getTranslatedPhrases(locale);

if (!isInitialized) {
return null;
}

return (
<section className={`${className}__bot-protection-section`} data-testid="bot-challege-protection-container">
{!!siteKey && <ReCAPTCHA
sitekey={siteKey}
onChange={onChange}
onExpired={() => {}}
/>}
{captchaError && <Paragraph data-testid="bot-challege-captcha-error">{phrases.t("identity-block.bot-protection-error")}</Paragraph>}
</section>
);
if (isRecaptchaEnabled && !!siteKey && !!recaptchaVersion) {
if (recaptchaVersion === RECAPTCHA_V2) {
return (
/* istanbul ignore next */
<section
className={`${className}__bot-protection-section`}
data-testid="bot-challege-protection-container"
>
<ReCAPTCHA sitekey={siteKey} onChange={onChange} onExpired={() => {}}/>
{captchaError && <Paragraph>{phrases.t("identity-block.bot-protection-error")}</Paragraph>}
</section>
);
}
if (recaptchaVersion === RECAPTCHA_V3) {
return (
/* istanbul ignore next */
<GoogleReCaptchaProvider reCaptchaKey={siteKey} scriptProps={{ async: true }}>
<RecaptchaV3 setCaptchaToken={setCaptchaToken} resetRecaptcha={resetRecaptcha} />
</GoogleReCaptchaProvider>
);
}
} else {
return null;
}

return null;
};

export default BotChallengeProtection;
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { useIdentity } from "@wpmedia/arc-themes-components";
import BotChallengeProtection from ".";
import * as useRecaptcha from "../../utils/useRecaptcha";

jest.mock("../../utils/useRecaptcha");

const mockLogin = jest.fn(() => Promise.resolve());

Expand All @@ -12,8 +15,8 @@ const mockIdentity = {
};

const mockSales = {
getConfig: jest.fn(() => {})
}
getConfig: jest.fn(() => {}),
};

jest.mock("@wpmedia/arc-themes-components", () => ({
...jest.requireActual("@wpmedia/arc-themes-components"),
Expand All @@ -32,18 +35,33 @@ jest.mock("@wpmedia/arc-themes-components", () => ({
}));

describe("Bot challenge protection", () => {
it("renders with required items", () => {
it("renders with required items", async () => {
useRecaptcha.default.mockReturnValue({
recaptchaVersion: "V2",
siteKey: "123",
isRecaptchaEnabled: true,
});

render(<BotChallengeProtection challengeIn="signin" />);

expect(screen.getByTestId("bot-challege-protection-container")).not.toBeNull();
await waitFor(() => {
expect(screen.getByTestId("bot-challege-protection-container")).not.toBeNull();
});
});
it("it does not render if identity is not initialized", () => {

useRecaptcha.default.mockReturnValue({
recaptchaVersion: "V2",
siteKey: "123",
isRecaptchaEnabled: true,
});

useIdentity.mockImplementation(() => ({
isInitialized: false,
Identity: {
...mockIdentity,
},
}))
}));
render(<BotChallengeProtection challengeIn="test" />);
expect(screen.queryByTestId("bot-challege-protection-container")).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useEffect, useCallback } from "react";
import { useGoogleReCaptcha } from "react-google-recaptcha-v3";

// eslint-disable-next-line
import { ARCXP_CAPTCHA } from "./index";

/* istanbul ignore file */
const RecaptchaV3 = ({ setCaptchaToken, resetRecaptcha }) => {
const { executeRecaptcha } = useGoogleReCaptcha();
const handleReCaptcha3Verify = useCallback(async () => {
if (!executeRecaptcha) {
// eslint-disable-next-line
console.log("ArcXP - Execute recaptcha not yet available");
return;
}
const token = await executeRecaptcha();
setCaptchaToken(token);
localStorage.setItem(ARCXP_CAPTCHA, token);
/* eslint-disable-next-line */
}, [executeRecaptcha]);

useEffect(() => {
handleReCaptcha3Verify();
/* eslint-disable-next-line */
}, [executeRecaptcha, resetRecaptcha]);

return null;
};

export default RecaptchaV3;
54 changes: 42 additions & 12 deletions blocks/identity-block/features/login/default.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@ import useLogin from "../../components/login";
import BotChallengeProtection from "../../components/bot-challenge-protection";
import useOIDCLogin from "../../utils/useOIDCLogin";
import validateURL from "../../utils/validate-redirect-url";
import { RECAPTCHA_LOGIN } from "../../utils/useRecaptcha";

const BLOCK_CLASS_NAME = "b-login-form";

const errorCodes = {
100015: "identity-block.login-form-error.account-is-disabled",
130001: "identity-block.login-form-error.captcha-token-invalid",
130051: "identity-block.login-form-error.unverified-email-address",
100013: "identity-block.login-form-error.max-devices",
0: "identity-block.login-form-error.invalid-email-password",
};

export function definedMessageByCode(code) {
return errorCodes[code] || errorCodes["0"];
}

const Login = ({ customFields }) => {
const { redirectURL, redirectToPreviousPage, loggedInPageLocation, OIDC } = customFields;

Expand All @@ -23,9 +36,11 @@ const Login = ({ customFields }) => {
const { locale } = getProperties(arcSite);
const phrases = getTranslatedPhrases(locale);

const isOIDC = OIDC && url.searchParams.get("client_id") && url.searchParams.get("response_type") === "code";
const isOIDC =
OIDC && url.searchParams.get("client_id") && url.searchParams.get("response_type") === "code";
const { Identity, isInitialized } = useIdentity();
const [captchaToken, setCaptchaToken] = useState();
const [resetRecaptcha, setResetRecaptcha] = useState(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the purpose of this variable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @edwardcho1231 similar to reCaptchav2, reCaptchav3 also expires, but in order to grab a new token we need to call executeRecaptcha(). Thus, resetRecaptcha is changing from true to false and viceversa everytime the reCaptcha3 is expired, thus the useEffect() detects this change and we need to grab a new one.

const [error, setError] = useState();
const [captchaError, setCaptchaError] = useState();
const { loginRedirect } = useLogin({
Expand All @@ -45,7 +60,6 @@ const Login = ({ customFields }) => {
<HeadlinedSubmitForm
buttonLabel={phrases.t("identity-block.log-in")}
className={BLOCK_CLASS_NAME}
formErrorText={error}
headline={phrases.t("identity-block.log-in-headline")}
onSubmit={({ email, password }) => {
setError(null);
Expand All @@ -63,19 +77,25 @@ const Login = ({ customFields }) => {
}
})
.catch((e) => {
setResetRecaptcha(!resetRecaptcha);
if (e?.code === "130001") {
setCaptchaError(true);
}
else {
setError(phrases.t("identity-block.login-form-error"));
setError(phrases.t(definedMessageByCode(e.code)));
}
if (grecaptcha) {
grecaptcha.reset();
}
})
}
}
});
}}
>
{error ? (
<div className={`${BLOCK_CLASS_NAME}__login-form-error`}>
<Paragraph>{error}</Paragraph>
</div>
) : null}
<Input
autoComplete="email"
label={phrases.t("identity-block.email-label")}
Expand All @@ -93,8 +113,17 @@ const Login = ({ customFields }) => {
showDefaultError={false}
type="password"
/>
<BotChallengeProtection className={BLOCK_CLASS_NAME} challengeIn="signin" setCaptchaToken={setCaptchaToken} captchaError={captchaError} setCaptchaError={setCaptchaError} />
<Paragraph className={`${BLOCK_CLASS_NAME}__privacy-statement`}>{phrases.t("identity-block.privacy-statement")}</Paragraph>
<BotChallengeProtection
className={BLOCK_CLASS_NAME}
challengeIn={RECAPTCHA_LOGIN}
setCaptchaToken={setCaptchaToken}
captchaError={captchaError}
setCaptchaError={setCaptchaError}
resetRecaptcha={resetRecaptcha}
/>
<Paragraph className={`${BLOCK_CLASS_NAME}__privacy-statement`}>
{phrases.t("identity-block.privacy-statement")}
</Paragraph>
</HeadlinedSubmitForm>
);
};
Expand All @@ -120,10 +149,11 @@ Login.propTypes = {
"The URL to which a user would be redirected to if visiting a login page when already logged in.",
}),
OIDC: PropTypes.bool.tag({
name: 'Login with OIDC',
defaultValue: false,
description: 'Used when authenticating a third party site with OIDC PKCE flow. This will use an ArcXp Org as an auth provider',
}),
name: "Login with OIDC",
defaultValue: false,
description:
"Used when authenticating a third party site with OIDC PKCE flow. This will use an ArcXp Org as an auth provider",
}),
}),
};

Expand Down
40 changes: 37 additions & 3 deletions blocks/identity-block/features/login/default.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ describe("Identity Login Feature", () => {
});
});

describe("Identity Login Feature - rejected Login", () => {
describe("Identity Login Feature - rejected Login, general message", () => {
beforeEach(() => {
mockLogin.mockImplementation(() => Promise.reject());
mockLogin.mockRejectedValueOnce({ code: 0 });
global.grecaptcha = {
reset: jest.fn()
}
Expand All @@ -97,7 +97,41 @@ describe("Identity Login Feature - rejected Login", () => {
fireEvent.click(screen.getByRole("button"));

await waitFor(() => expect(mockLogin).toHaveBeenCalled());
await screen.findByText("identity-block.login-form-error");
await screen.findByText("identity-block.login-form-error.invalid-email-password");
});
});

describe("Identity Login Feature - rejected Login, error code 130001", () => {
beforeEach(() => {
mockLogin.mockRejectedValueOnce({ code: 130001 });
global.grecaptcha = {
reset: jest.fn()
}
});

afterEach(() => {
jest.clearAllMocks();
});

it("rejects the login", async () => {

render(<Login customFields={defaultCustomFields} />);

await waitFor(() => expect(screen.getByLabelText("identity-block.email-label")));
fireEvent.change(screen.getByLabelText("identity-block.email-label"), {
target: { value: "[email protected]" },
});

await waitFor(() => expect(screen.getByLabelText("identity-block.password")));
fireEvent.change(screen.getByLabelText("identity-block.password"), {
target: { value: "thisNotIsMyPassword" },
});

await waitFor(() => expect(screen.getByRole("button")));
fireEvent.click(screen.getByRole("button"));

await waitFor(() => expect(mockLogin).toHaveBeenCalled());
await screen.findByText("identity-block.login-form-error.captcha-token-invalid");
});
});

Expand Down
Loading
Loading