feat: implement Turnstile captcha verification for forgot password and signup actions

This commit is contained in:
arraysid
2025-05-28 02:02:53 +07:00
parent 8fb3e9a37f
commit d96df63660
8 changed files with 88 additions and 78 deletions
@@ -1,6 +1,8 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
forgotPasswordValidationSchema,
ForgotPasswordValidationType,
@@ -10,6 +12,14 @@ export async function ForgotPasswordAction(
request: ForgotPasswordValidationType
) {
const validRequest = forgotPasswordValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
body: {
@@ -11,7 +11,7 @@ import {
Input,
} from '@components';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { LuLoader } from 'react-icons/lu';
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
@@ -20,54 +20,74 @@ import { ForgotPasswordValidationType } from '../_validation/forgot-password-val
export function ForgotPasswordForm() {
const ref = useRef<TurnstileInstance | null>(null);
const [step, setStep] = useState<number>(1);
const [emailValue, setEmailValue] = useState<string>('');
const form = useFormForgotPassword();
const { mutate, isPending, error } = usePostForgotPassowrd(form);
const onSubmit = (values: ForgotPasswordValidationType) => {
mutate(values);
const handleFirstStep = (values: ForgotPasswordValidationType) => {
setEmailValue(values.email);
setStep(2);
};
const handleSecondStep = () => {
mutate({ email: emailValue, token: form.getValues('token') });
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-4">
<form
onSubmit={
step === 1
? form.handleSubmit(handleFirstStep)
: (e) => {
e.preventDefault();
handleSecondStep();
}
}
className="w-full space-y-4"
>
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{step === 1 && (
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => form.setValue('token', token)}
options={{
theme: 'light',
size: 'flexible',
language: 'id',
}}
/>
{step === 2 && (
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => form.setValue('token', token)}
options={{ theme: 'light', size: 'flexible', language: 'id' }}
/>
)}
<Button
type="submit"
disabled={isPending}
disabled={isPending || (step === 2 && !form.watch('token'))}
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
) : step === 1 ? (
'Selanjutnya'
) : (
'Reset password'
)}
@@ -10,7 +10,11 @@ export function usePostForgotPassowrd(
mutationFn: ForgotPasswordAction,
onSuccess: ({ message }) => {
form.reset();
toast(message);
toast.success(message);
},
onError: ({ message }) => {
form.reset();
toast.error(message);
},
});
}
@@ -1,20 +1,23 @@
'use server';
import { getRemoteIp } from '@/lib/headers';
import { z } from 'zod';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import { fetchPostSignin } from '../_http/fetch-post-signup';
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
import { signupValidationSchema } from '../_validation/signup-validation';
export async function SignupAction(
request: z.infer<typeof signupValidationSchema>
) {
const validRequest = signupValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const data = await fetchPostSignin(validRequest);
@@ -1,7 +1,8 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
resendOTPValidationSchema,
type ResendOTPValidationType,
@@ -9,12 +10,14 @@ import {
export async function resendOTPAction(request: ResendOTPValidationType) {
const validRequest = resendOTPValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data } = await fetcher.POST('/v1/auth/send-otp', {
body: {
@@ -1,41 +0,0 @@
export async function fetchPostverifyTurnstile(
token: string,
remoteIp?: string
): Promise<boolean> {
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const params = new URLSearchParams({
secret: String(process.env.TURNSTILE_SECRET_KEY),
response: token,
});
if (remoteIp) {
params.append('remoteip', remoteIp);
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!res.ok) {
console.error('Turnstile verify HTTP error', res.status);
return false;
}
const data = (await res.json()) as TurnstileVerifyResponse;
if (!data.success) {
console.warn('Turnstile failure', data['error-codes']);
return false;
}
return true;
}
interface TurnstileVerifyResponse {
success: boolean;
challenge_ts: string;
hostname: string;
'error-codes'?: string[];
}
+11
View File
@@ -0,0 +1,11 @@
import { headers } from 'next/headers';
export async function getRemoteIp() {
const hdrs = await headers();
const xff = hdrs.get('x-forwarded-for');
if (!xff) return undefined;
// 'x-forwarded-for' can be a comma-separated list of IPs
const ips = xff.split(',').map((ip) => ip.trim());
return ips[0] || undefined;
}