0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(experience): add catpcha for sign in and forgot password (#7209)

* refactor(experience): execute captcha when click

* refactor(experience): execute captcha when click

* feat(experience): add catpcha for sign in and forgot password
This commit is contained in:
wangsijie 2025-04-03 10:15:10 +08:00 committed by GitHub
parent c92202674b
commit 9f488ae160
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 82 additions and 30 deletions

View file

@ -0,0 +1,15 @@
import { CaptchaType } from '@logto/schemas';
import { type SignInExperienceResponse } from '@/types';
export const getScript = (config: SignInExperienceResponse['captchaConfig']) => {
if (!config) {
throw new Error('Captcha config is not found');
}
if (config.type === CaptchaType.Turnstile) {
return `https://challenges.cloudflare.com/turnstile/v0/api.js`;
}
return `https://www.google.com/recaptcha/enterprise.js?render=${config.siteKey}`;
};

View file

@ -63,8 +63,11 @@ export const signInWithVerifiedIdentifier = async (verificationId: string) => {
};
// Password APIs
export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => {
await initInteraction(InteractionEvent.SignIn);
export const signInWithPasswordIdentifier = async (
payload: PasswordVerificationPayload,
captchaToken?: string
) => {
await initInteraction(InteractionEvent.SignIn, captchaToken);
const { verificationId } = await api
.post(`${experienceApiRoutes.verification}/password`, {

View file

@ -174,16 +174,19 @@ describe('UsernamePasswordSignInForm', () => {
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({
identifier: {
type,
value:
type === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${identifier}`
: identifier,
expect(signInWithPasswordIdentifier).toBeCalledWith(
{
identifier: {
type,
value:
type === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${identifier}`
: identifier,
},
password: 'password',
},
password: 'password',
});
undefined
);
});
});

View file

@ -4,12 +4,14 @@ import { useCallback, useContext, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import CaptchaContext from '@/Providers/CaptchaContextProvider/CaptchaContext';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg?react';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { SmartInputField, PasswordInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import CaptchaBox from '@/containers/CaptchaBox';
import ForgotPasswordLink from '@/containers/ForgotPasswordLink';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
@ -41,6 +43,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { termsValidation, agreeToTermsPolicy } = useTerms();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const prefilledIdentifier = usePrefilledIdentifier({ enabledIdentifiers: signInMethods });
const { executeCaptcha } = useContext(CaptchaContext);
const {
watch,
@ -81,10 +84,13 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
return;
}
await onSubmit({
identifier: { type, value },
password,
});
await onSubmit(
{
identifier: { type, value },
password,
},
await executeCaptcha()
);
})(event);
},
[
@ -96,6 +102,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
setIdentifierInputValue,
showSingleSignOnForm,
termsValidation,
executeCaptcha,
]
);
@ -148,6 +155,8 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
/>
)}
<CaptchaBox />
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && !showSingleSignOnForm && (

View file

@ -38,7 +38,7 @@ const usePasswordSignIn = () => {
);
const onSubmit = useCallback(
async (payload: PasswordVerificationPayload) => {
async (payload: PasswordVerificationPayload, captchaToken?: string) => {
const { identifier } = payload;
// Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step
@ -50,7 +50,7 @@ const usePasswordSignIn = () => {
}
}
const [error, result] = await asyncSignIn(payload);
const [error, result] = await asyncSignIn(payload, captchaToken);
if (error) {
await handleError(error, errorHandlers);

View file

@ -3,10 +3,12 @@ import { useCallback, useContext, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import CaptchaContext from '@/Providers/CaptchaContextProvider/CaptchaContext';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { SmartInputField } from '@/components/InputFields';
import CaptchaBox from '@/containers/CaptchaBox';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types';
@ -34,6 +36,7 @@ const ForgotPasswordForm = ({ className, autoFocus, defaultValue = '', enabledTy
const { errorMessage, clearErrorMessage, onSubmit } = useSendVerificationCode(
UserFlow.ForgotPassword
);
const { executeCaptcha } = useContext(CaptchaContext);
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
@ -68,10 +71,16 @@ const ForgotPasswordForm = ({ className, autoFocus, defaultValue = '', enabledTy
// Cache or update the forgot password identifier input value
setForgotPasswordIdentifierInputValue({ type, value });
await onSubmit({ identifier: type, value });
await onSubmit({ identifier: type, value }, undefined, await executeCaptcha());
})(event);
},
[clearErrorMessage, handleSubmit, onSubmit, setForgotPasswordIdentifierInputValue]
[
clearErrorMessage,
handleSubmit,
onSubmit,
setForgotPasswordIdentifierInputValue,
executeCaptcha,
]
);
return (
@ -111,6 +120,8 @@ const ForgotPasswordForm = ({ className, autoFocus, defaultValue = '', enabledTy
)}
/>
<CaptchaBox />
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<Button title="action.continue" htmlType="submit" isLoading={isSubmitting} />

View file

@ -71,13 +71,16 @@ describe('PasswordSignInForm', () => {
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({
identifier: {
type: identifier,
value,
expect(signInWithPasswordIdentifier).toBeCalledWith(
{
identifier: {
type: identifier,
value,
},
password,
},
password,
});
undefined
);
});
if (isVerificationCodeEnabled) {

View file

@ -4,11 +4,13 @@ import { useCallback, useContext, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import CaptchaContext from '@/Providers/CaptchaContextProvider/CaptchaContext';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { PasswordInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import CaptchaBox from '@/containers/CaptchaBox';
import ForgotPasswordLink from '@/containers/ForgotPasswordLink';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
@ -42,6 +44,7 @@ const PasswordForm = ({
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const { executeCaptcha } = useContext(CaptchaContext);
const {
register,
@ -76,13 +79,16 @@ const PasswordForm = ({
setIdentifierInputValue({ type, value });
await onSubmit({
identifier: { type, value },
password,
});
await onSubmit(
{
identifier: { type, value },
password,
},
await executeCaptcha()
);
})(event);
},
[clearErrorMessage, handleSubmit, onSubmit, setIdentifierInputValue]
[clearErrorMessage, handleSubmit, onSubmit, setIdentifierInputValue, executeCaptcha]
);
return (
@ -108,6 +114,8 @@ const PasswordForm = ({
{...register('password', { required: t('error.password_required') })}
/>
<CaptchaBox />
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && (