feat: implement Turnstile captcha verification for forgot password and signup actions
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fetcher } from '@/lib/fetcher';
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
import {
|
import {
|
||||||
forgotPasswordValidationSchema,
|
forgotPasswordValidationSchema,
|
||||||
ForgotPasswordValidationType,
|
ForgotPasswordValidationType,
|
||||||
@@ -10,6 +12,14 @@ export async function ForgotPasswordAction(
|
|||||||
request: ForgotPasswordValidationType
|
request: ForgotPasswordValidationType
|
||||||
) {
|
) {
|
||||||
const validRequest = forgotPasswordValidationSchema.parse(request);
|
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', {
|
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@components';
|
} from '@components';
|
||||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { LuLoader } from 'react-icons/lu';
|
import { LuLoader } from 'react-icons/lu';
|
||||||
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
||||||
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
||||||
@@ -20,54 +20,74 @@ import { ForgotPasswordValidationType } from '../_validation/forgot-password-val
|
|||||||
export function ForgotPasswordForm() {
|
export function ForgotPasswordForm() {
|
||||||
const ref = useRef<TurnstileInstance | null>(null);
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<number>(1);
|
||||||
|
const [emailValue, setEmailValue] = useState<string>('');
|
||||||
|
|
||||||
const form = useFormForgotPassword();
|
const form = useFormForgotPassword();
|
||||||
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
||||||
|
|
||||||
const onSubmit = (values: ForgotPasswordValidationType) => {
|
const handleFirstStep = (values: ForgotPasswordValidationType) => {
|
||||||
mutate(values);
|
setEmailValue(values.email);
|
||||||
|
setStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondStep = () => {
|
||||||
|
mutate({ email: emailValue, token: form.getValues('token') });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<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 && (
|
{error && (
|
||||||
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
{(error as Error).message}
|
{(error as Error).message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{step === 1 && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="email"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="email"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Email</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Email</FormLabel>
|
||||||
<Input placeholder="emailmu@mail.com" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Turnstile
|
{step === 2 && (
|
||||||
ref={ref}
|
<Turnstile
|
||||||
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
ref={ref}
|
||||||
onSuccess={(token) => form.setValue('token', token)}
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
options={{
|
onSuccess={(token) => form.setValue('token', token)}
|
||||||
theme: 'light',
|
options={{ theme: 'light', size: 'flexible', language: 'id' }}
|
||||||
size: 'flexible',
|
/>
|
||||||
language: 'id',
|
)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending || (step === 2 && !form.watch('token'))}
|
||||||
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LuLoader className="h-5 w-5 animate-spin" />
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : step === 1 ? (
|
||||||
|
'Selanjutnya'
|
||||||
) : (
|
) : (
|
||||||
'Reset password'
|
'Reset password'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export function usePostForgotPassowrd(
|
|||||||
mutationFn: ForgotPasswordAction,
|
mutationFn: ForgotPasswordAction,
|
||||||
onSuccess: ({ message }) => {
|
onSuccess: ({ message }) => {
|
||||||
form.reset();
|
form.reset();
|
||||||
toast(message);
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
||||||
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
|
|
||||||
import { signupValidationSchema } from '../_validation/signup-validation';
|
import { signupValidationSchema } from '../_validation/signup-validation';
|
||||||
|
|
||||||
export async function SignupAction(
|
export async function SignupAction(
|
||||||
request: z.infer<typeof signupValidationSchema>
|
request: z.infer<typeof signupValidationSchema>
|
||||||
) {
|
) {
|
||||||
const validRequest = signupValidationSchema.parse(request);
|
const validRequest = signupValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
const isCapchaValidationValid = await fetchPostverifyTurnstile(
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
validRequest.token
|
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);
|
const data = await fetchPostSignin(validRequest);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fetcher } from '@/lib/fetcher';
|
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 {
|
import {
|
||||||
resendOTPValidationSchema,
|
resendOTPValidationSchema,
|
||||||
type ResendOTPValidationType,
|
type ResendOTPValidationType,
|
||||||
@@ -9,12 +10,14 @@ import {
|
|||||||
|
|
||||||
export async function resendOTPAction(request: ResendOTPValidationType) {
|
export async function resendOTPAction(request: ResendOTPValidationType) {
|
||||||
const validRequest = resendOTPValidationSchema.parse(request);
|
const validRequest = resendOTPValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
const isCapchaValidationValid = await fetchPostverifyTurnstile(
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
validRequest.token
|
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', {
|
const { data } = await fetcher.POST('/v1/auth/send-otp', {
|
||||||
body: {
|
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[];
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user