Skip to content

Commit 7002be6

Browse files
leilabbtonioriol
andauthored
135 implement recaptcha in forms (#140)
* add recaptcha v3 * refactor * read secrets from GitHub actions env * add fixes * add recaptcha to signin wip * fix recaptcha * add recaptcha to forgot password * clean logs * add recaptcha env example * revernt time changes --------- Co-authored-by: Toni Oriol <[email protected]>
1 parent 65bdb9a commit 7002be6

File tree

11 files changed

+113
-19
lines changed

11 files changed

+113
-19
lines changed

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ SENTRY_ORG=prototyp-vm
1212
SENTRY_PROJECT=taco
1313
PUBLIC_SITE_NAME=TACO
1414
PUBLIC_SITE_URL=http://localhost:5173
15+
PUBLIC_RECAPTCHA_SITE_KEY=
16+
RECAPTCHA_SECRET_KEY=

.github/workflows/test.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@ jobs:
2525
--health-retries 5
2626
2727
env:
28-
POSTMARK_API_KEY: SECRET
28+
POSTMARK_API_KEY: ${{ secrets.POSTMARK_API_KEY }}
2929
PUBLIC_SITE_NAME: TACO [TEST]
3030
EMAIL_SENDER_SIGNATURE: [email protected]
31-
PUBLIC_SENTRY_DSN: SECRET
31+
PUBLIC_SENTRY_DSN: ${{ secrets.PUBLIC_SENTRY_DSN }}
3232
PUBLIC_SENTRY_ENV: test
3333
PUBLIC_SITE_URL: http://localhost:5173/
3434
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/db
3535
NODE_ENV: test
36+
PUBLIC_RECAPTCHA_SITE_KEY: ${{ secrets.PUBLIC_RECAPTCHA_SITE_KEY }}
37+
RECAPTCHA_SECRET_KEY: ${{ secrets.RECAPTCHA_SECRET_KEY }}
38+
3639

3740
steps:
3841
- name: Checkout repository

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/utils/recaptcha.client.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { PUBLIC_RECAPTCHA_SITE_KEY } from '$env/static/public'
2+
3+
export interface ReCaptcha {
4+
ready(callback: () => void): void
5+
6+
execute(siteKey: string, options: { action: string }): Promise<string>
7+
}
8+
9+
export const executeRecaptcha = async (grecaptcha: ReCaptcha): Promise<string> => {
10+
return new Promise<string>((resolve, reject) => {
11+
if (!grecaptcha) {
12+
reject(new Error('reCAPTCHA is not available'))
13+
return
14+
}
15+
grecaptcha.ready(() => {
16+
try {
17+
grecaptcha
18+
.execute(PUBLIC_RECAPTCHA_SITE_KEY, { action: 'submit' })
19+
.then((token: string) => {
20+
resolve(token)
21+
})
22+
.catch((error: Error) => {
23+
console.error('reCAPTCHA error:', error)
24+
reject(error)
25+
})
26+
} catch (error) {
27+
console.error('reCAPTCHA error:', error)
28+
reject(error)
29+
}
30+
})
31+
})
32+
}

src/lib/utils/recaptcha.server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { RECAPTCHA_SECRET_KEY } from '$env/static/private'
2+
3+
export async function verifyRecaptcha(response: string) {
4+
const recaptchaResponse = await fetch('https://www.google.com/recaptcha/api/siteverify', {
5+
method: 'POST',
6+
headers: {
7+
'Content-Type': 'application/x-www-form-urlencoded',
8+
},
9+
body: `secret=${RECAPTCHA_SECRET_KEY}&response=${response}`,
10+
})
11+
12+
const result = await recaptchaResponse.json()
13+
if (!result.success || result.score <= 0.5) {
14+
throw new Error(`Recaptcha verification failed. result: ${JSON.stringify(result, null, 2)}`)
15+
}
16+
}

src/routes/forgot-password/+page.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { generateSecureRandomToken } from '$lib/server/utils/crypto'
44
import { fail } from '@sveltejs/kit'
55
import { z, ZodError } from 'zod'
66
import type { Actions } from './$types'
7+
import { verifyRecaptcha } from '$lib/utils/recaptcha.server'
78

89
export const actions: Actions = {
910
default: async ({ request, url }) => {
@@ -12,9 +13,12 @@ export const actions: Actions = {
1213
const schema = z
1314
.object({
1415
email: z.string().email().toLowerCase(),
16+
recaptchaToken: z.string().min(1),
1517
})
1618
.parse(fields)
1719

20+
await verifyRecaptcha(schema.recaptchaToken)
21+
1822
let user = await getUserByEmail(schema.email)
1923

2024
if (!user) {

src/routes/forgot-password/+page.svelte

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
import Input from '$lib/components/Input.svelte'
55
import TacoIcon from '$lib/components/icons/TacoIcon.svelte'
66
import type { ActionData } from './$types'
7+
import { PUBLIC_RECAPTCHA_SITE_KEY } from '$env/static/public'
8+
import { executeRecaptcha } from '$lib/utils/recaptcha.client'
79
810
export let form: ActionData
911
</script>
1012

1113
<svelte:head>
1214
<title>Forgot Password</title>
15+
<script
16+
src={`https://www.google.com/recaptcha/api.js?render=${PUBLIC_RECAPTCHA_SITE_KEY}`}
17+
></script>
1318
</svelte:head>
1419

1520
<div
@@ -37,8 +42,12 @@
3742
class="space-y-6 mt-4"
3843
method="POST"
3944
novalidate
40-
use:enhance={() => {
41-
return ({ update }) => update({ reset: false }) // workaround for this known issue: @link: https://github.com/sveltejs/kit/issues/8513#issuecomment-1382500465
45+
use:enhance={async ({ formData }) => {
46+
const recaptchaToken = await executeRecaptcha(window.grecaptcha)
47+
formData.append('recaptchaToken', recaptchaToken)
48+
return async ({ update }) => {
49+
return update({ reset: false })
50+
}
4251
}}
4352
>
4453
<Input

src/routes/signin/+page.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
doesCredentialsMatch,
44
getUserByEmail,
55
} from '$lib/server/entities/user'
6+
import { verifyRecaptcha } from '$lib/utils/recaptcha.server'
67
import { fail, redirect } from '@sveltejs/kit'
78
import { z, ZodError } from 'zod'
89
import type { Actions } from './$types'
@@ -16,13 +17,15 @@ export const actions: Actions = {
1617
email: z.string().email(),
1718
password: z.string().min(1),
1819
remember: z.preprocess((value) => value === 'on', z.boolean()),
20+
recaptchaToken: z.string().min(1),
1921
})
2022
.refine(async (data) => doesCredentialsMatch(data.email, data.password), {
2123
message: 'Wrong credentials',
2224
path: ['password'],
2325
})
2426
.parseAsync(fields)
2527

28+
await verifyRecaptcha(schema.recaptchaToken)
2629
const user = await getUserByEmail(schema.email)
2730

2831
if (!user) {

src/routes/signin/+page.svelte

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
import Alert from '$lib/components/Alert.svelte'
44
import Input from '$lib/components/Input.svelte'
55
import TacoIcon from '$lib/components/icons/TacoIcon.svelte'
6+
import { executeRecaptcha } from '$lib/utils/recaptcha.client'
67
import type { ActionData } from './$types'
8+
import { PUBLIC_RECAPTCHA_SITE_KEY } from '$env/static/public'
79
810
export let form: ActionData
911
</script>
1012

1113
<svelte:head>
1214
<title>Sign in</title>
15+
<script
16+
src={`https://www.google.com/recaptcha/api.js?render=${PUBLIC_RECAPTCHA_SITE_KEY}`}
17+
></script>
1318
</svelte:head>
1419

1520
<div
@@ -37,8 +42,12 @@
3742
class="space-y-6 mt-4"
3843
method="POST"
3944
novalidate
40-
use:enhance={() => {
41-
return ({ update }) => update({ reset: false }) // workaround for this known issue: @link: https://github.com/sveltejs/kit/issues/8513#issuecomment-1382500465
45+
use:enhance={async ({ formData }) => {
46+
const recaptchaToken = await executeRecaptcha(window.grecaptcha)
47+
formData.append('recaptchaToken', recaptchaToken)
48+
return async ({ update }) => {
49+
return update({ reset: false })
50+
}
4251
}}
4352
>
4453
<Input
@@ -55,6 +64,7 @@
5564
errors={form?.errors?.password}
5665
label="Password"
5766
id="password"
67+
l78fv
5868
name="password"
5969
type="password"
6070
autocomplete="current-password"

src/routes/signup/+page.server.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,42 @@
11
import { sendVerifyUserEmail } from '$lib/email/mailer'
22
import { createUser, createUserSessionAndCookie } from '$lib/server/entities/user'
3-
import type { Actions } from './$types'
4-
import { z, ZodError } from 'zod'
5-
import { fail, redirect } from '@sveltejs/kit'
3+
import { verifyRecaptcha } from '$lib/utils/recaptcha.server'
64
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
5+
import { fail, redirect } from '@sveltejs/kit'
6+
import { z, ZodError } from 'zod'
7+
import type { Actions } from './$types'
78

89
export const actions: Actions = {
910
default: async ({ request, cookies, url }) => {
1011
const fields = Object.fromEntries(await request.formData())
12+
1113
try {
1214
const schema = z
1315
.object({
1416
name: z.string().min(1),
1517
email: z.string().email().toLowerCase(),
1618
password: z.string().min(6),
1719
confirmPassword: z.string().min(6),
20+
recaptchaToken: z.string().min(1),
1821
})
1922
.refine((data) => data.password === data.confirmPassword, {
2023
message: "Passwords don't match",
2124
path: ['confirmPassword'],
2225
})
2326
.parse(fields)
2427

28+
await verifyRecaptcha(schema.recaptchaToken)
29+
2530
const user = await createUser(schema.name, schema.email, schema.password)
2631

2732
await createUserSessionAndCookie(user.id, cookies)
28-
2933
if (user.password?.verificationToken) {
3034
await sendVerifyUserEmail(user, url.origin, user.password.verificationToken)
3135
} else {
32-
throw new Error('User verification token not found')
36+
return fail(500, {
37+
fields,
38+
error: 'User verification token not found',
39+
})
3340
}
3441
} catch (error) {
3542
if (error instanceof ZodError) {
@@ -47,7 +54,6 @@ export const actions: Actions = {
4754
},
4855
})
4956
}
50-
5157
return fail(500, {
5258
fields,
5359
error: `${error}`,

src/routes/signup/+page.svelte

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
<script lang="ts">
22
import { enhance } from '$app/forms'
33
import Alert from '$lib/components/Alert.svelte'
4-
import Input from '$lib/components/Input.svelte'
54
import TacoIcon from '$lib/components/icons/TacoIcon.svelte'
5+
import Input from '$lib/components/Input.svelte'
6+
import { executeRecaptcha } from '$lib/utils/recaptcha.client'
67
import type { ActionData } from './$types'
8+
import { PUBLIC_RECAPTCHA_SITE_KEY } from '$env/static/public'
79
810
export let form: ActionData
911
</script>
1012

1113
<svelte:head>
1214
<title>Signup</title>
15+
<script
16+
src={`https://www.google.com/recaptcha/api.js?render=${PUBLIC_RECAPTCHA_SITE_KEY}`}
17+
></script>
1318
</svelte:head>
1419

1520
<div
@@ -33,11 +38,16 @@
3338
title={form?.error || form?.success}
3439
/>
3540
<form
41+
id="signup"
3642
class="space-y-6"
3743
method="POST"
3844
novalidate
39-
use:enhance={() => {
40-
return ({ update }) => update({ reset: false }) // workaround for this known issue: @link: https://github.com/sveltejs/kit/issues/8513#issuecomment-1382500465
45+
use:enhance={async ({ formData }) => {
46+
const recaptchaToken = await executeRecaptcha(window.grecaptcha)
47+
formData.append('recaptchaToken', recaptchaToken)
48+
return async ({ update }) => {
49+
return update({ reset: false })
50+
}
4151
}}
4252
>
4353
<Input
@@ -74,7 +84,6 @@
7484
name="confirmPassword"
7585
type="password"
7686
/>
77-
7887
<div>
7988
<button
8089
type="submit"

0 commit comments

Comments
 (0)