ciscoheat / sveltekit-superforms

Making SvelteKit forms a pleasure to use!
https://superforms.rocks
MIT License
2.12k stars 62 forks source link

Integrate ReCaptcha v3 / v2 with SvelteKit Superforms #323

Closed itaibaruch closed 3 months ago

itaibaruch commented 7 months ago

Hi team,

Thank you for a great package.

I have tried to integrate ReCaptcha with your package, but I don't know what is the best way to implement it. can you provide example on the site or in the repo.

Thanks.

dookanooka commented 6 months ago

I've been trying to do this as well. The problem i'm having is getting the token on the client and sending it to an action on the server.

I tried using onSubmit with an async..await to get the ReCaptcha token on the client, add it to formData and submit but that didn't work.

I went outside use:enhance with a function call via on:submit|preventDefault={onFormSubmit} but it gets executed too late for the server action to get the token to query Google.

I need the server action to hold off until the client side function call has completed. I thought simple enough, but proving tricky with Superforms in the mix.

dookanooka commented 6 months ago

Had this working quite shortly after my last post as below, getting into the swing of Superforms, it's a very nice package :)

I say yes to 'browser error' from Google, not a good idea but hey, it's generally just a guide.

I added localhost to the approved domains on the Google side for development.

+page.svelte

    const { form, errors, enhance } = superForm(data.form, {
        onSubmit(cancel) {
            onFormSubmit(cancel)
        },
    })
    /** @param {{ currentTarget: EventTarget & HTMLFormElement}} event */
    const onFormSubmit = async ({ cancel }) => {
        const formStatus = await superValidate($form, zod(lostPasswordSchema))

        if (!formStatus.valid) {
            cancel()
            return fail(400, {
                formStatus,
            })
        } else {
            try {
                state = State.requesting
                await window.grecaptcha.ready(async function () {
                    const token = await window.grecaptcha
                        .execute(PUBLIC_RECAPTCHA_SITE_KEY, {
                            action: 'submit',
                        })
                        .then(async function (t) {
                            $form.recaptchaToken = t
                            const response = await fetch('/api/lostPassword', {
                                method: 'POST',
                                body: JSON.stringify($form),
                                headers: {
                                    'content-type': 'application/json',
                                },
                            })
                            /** @type {import('@sveltejs/kit').ActionResult} */
                            const result = deserialize(await response.text())
                            if (result.type == 'success') {
                                // Do stuff if its good
                            } else {
                                toastStore.trigger(errEmail)
                            }
                            return result
                        })
                })
            } catch (error) {
                console.log(`ERROR: ${error}`)
                toastStore.trigger(errEmail)
            }
        }
    }

<form method="POST" class="mt-8 space-y-8" use:enhance>

/api/lostpassword/+server.ts

import { actionResult } from 'sveltekit-superforms';
import { SECRET_RECAPTCHA_KEY } from '$env/static/private';

/**
 * This function is used to verify the reCAPTCHA token received from the client.
 * It sends a POST request to Google's reCAPTCHA API and checks the response.
 * 
 * @async
 * @param {Object} event - The event object containing the client's request.
 * @param {Object} event.request - The client's request.
 * @param {Function} event.getClientAddress - Function to get the client's IP address.
 * 
 * @returns {Promise<Object>} Returns an object with the result of the operation. 
 * If the reCAPTCHA token is successfully verified, it returns an object with a success message. 
 * If the verification fails, it returns an object with an error message.
 * 
 * @throws {Error} If the hostname is 'localhost' and the environment is not development, 
 * an error is thrown indicating that the operation is not permitted.
 */
export async function POST(event) {
    const clientIp = event.getClientAddress();
    const data = await event.request.json()
    const isDev = process.env.NODE_ENV === 'development';

    /* Google verify recaptcha */
    const postData = new URLSearchParams();
    postData.append('secret', SECRET_RECAPTCHA_KEY);
    postData.append('response', data.recaptchaToken);
    postData.append('remoteip', clientIp);
    // Make the request
    const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: postData
    });
    const captchaData = await response.json();
    if (captchaData.hostname === 'localhost' && !isDev) {
        return actionResult('failure', { error: 'Operation not permitted for localhost in production' });
    }
    else if (captchaData.success && captchaData.score > 0.6 && captchaData.action === 'submit') {
        return actionResult('success', { text: `Good score returned from Google` });
    } else if (captchaData["error-codes"][0] == "browser-error") {
        return actionResult('success', { text: `Google returned browser-error` });
    } else {
        return actionResult('failure', { error: `Failed to verify the token` });
    }
}
itaibaruch commented 6 months ago

I solve it the following way:

$lib/helpers/recaptcha.ts

import { PUBLIC_GOOGLE_RECAPTCHA_SITE_KEY } from '$env/static/public';

export async function validateReCaptchaServer(
    token: string,
    fetch: typeof window.fetch,
    secret: string
) {
    const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
        method: 'POST',
        headers: {
            'content-type': 'application/x-www-form-urlencoded',
        },
        body: `secret=${secret}&response=${token}`,
    });
    const json = await res.json();

    return json;
}

export async function createReCaptchaClient(
    formToken: string | undefined,
    grecaptcha: ReCaptchaV2.ReCaptcha
) {
    return new Promise((resolve) => {
        if (formToken) {
            resolve(formToken);
        } else {
            return grecaptcha.ready(function () {
                grecaptcha.execute(PUBLIC_GOOGLE_RECAPTCHA_SITE_KEY, { action: 'submit' }).then(function (
                    token: string
                ) {
                    resolve(token);
                });
            });
        }
    });
}

+page.svelte

<script lang="ts">
        import { createReCaptchaClient } from '$lib/helpers/recaptcha';
    const { form, errors, constraints, enhance, message } = superForm(data.contactForm, {
        // Reset the form upon a successful result
        resetForm: true,
        onSubmit: async ({ formData }) => {
            const token = await createReCaptchaClient($form.token, window.grecaptcha);
            formData.append('token', String(token));
        },
    });
</script>

<svelte:head>
    <script
        src="https://www.google.com/recaptcha/api.js?render={PUBLIC_GOOGLE_RECAPTCHA_SITE_KEY}"
        async
        defer
    ></script>
</svelte:head>

<form method="POST" action="?/contact" use:enhance>
.....form

+page.server.ts

import { validateReCaptchaServer } from '$src/lib/helpers/recaptcha';

export const actions: Actions = {
    contact: async (event) => {
        const { request, fetch } = event;
        const form = await superValidate(request, contactSchema);

        if (!form.valid) {
            return fail(400, { form });
        }

        // reCAPTCHA
        const gToken = form.data.token;
        if (!gToken) {
            return message(form, 'Invalid reCAPTCHA', { status: 400 });
        }
        const res = await validateReCaptchaServer(gToken, fetch, GOOGLE_RECAPTCHA_SECRET_KEY);
        if (!res.success) {
            return message(form, 'Failed ReCaptcha', { status: 400 });
        }
....

Hope it helps