Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SD-172] Added support for CAPTCHAs in webforms #1320

Open
wants to merge 16 commits into
base: release/2.17.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
611794f
feat(@dpc-sdp/ripple-tide-webform): added support for CAPTCHAs in web…
jeffdowdle Sep 10, 2024
18d2596
fix(@dpc-sdp/ripple-tide-webform): fixed rollup import error
jeffdowdle Sep 10, 2024
19754e8
fix(@dpc-sdp/ripple-ui-forms): fixed issue with util imports in story…
jeffdowdle Sep 10, 2024
15ddd5d
feat(@dpc-sdp/ripple-tide-webform): updated captcha mapping after bac…
jeffdowdle Sep 11, 2024
30a0f90
feat(@dpc-sdp/ripple-tide-webform): captchas for content rating and s…
jeffdowdle Sep 16, 2024
6900a3b
Merge branch 'release/2.17.0' into feature/SD-172-captcha-support
jeffdowdle Sep 16, 2024
b615488
fix(@dpc-sdp/ripple-ui-forms): fixed storybook tests
jeffdowdle Sep 16, 2024
e80f274
Merge branch 'feature/SD-172-captcha-support' of https://github.com/d…
jeffdowdle Sep 16, 2024
909f944
fix(@dpc-sdp/ripple-tide-webform): fixed broken test after content ra…
jeffdowdle Sep 16, 2024
d346213
revert(@dpc-sdp/nuxt-ripple): removed captcha from content rating
jeffdowdle Sep 17, 2024
fa53a47
feat(@dpc-sdp/ripple-tide-webform): change the way captcha is configu…
jeffdowdle Sep 17, 2024
6052ba3
Merge branch 'release/2.17.0' into feature/SD-172-captcha-support
jeffdowdle Sep 18, 2024
345bdee
feat(@dpc-sdp/ripple-tide-webform): handle recaptcha v2 quota exceedi…
jeffdowdle Sep 18, 2024
fb045c1
Merge branch 'feature/SD-172-captcha-support' of https://github.com/d…
jeffdowdle Sep 18, 2024
2ecb481
feat(@dpc-sdp/ripple-tide-webform): logged captcha success with form id
jeffdowdle Sep 18, 2024
a66b177
feat(@dpc-sdp/ripple-tide-webform): only load captcha scripts when ca…
jeffdowdle Sep 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/nuxt-ripple/components/TideContentRating.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ const contentRatingFormId = 'tide_webform_content_rating'
const isMounted = ref(false)
const pageUrl = ref('')

const { data: webformData, status: webformFetchStatus } = await useFetch(
jeffdowdle marked this conversation as resolved.
Show resolved Hide resolved
'/api/tide/webform',
{
baseURL: '',
params: {
id: contentRatingFormId
}
}
)

onMounted(() => {
isMounted.value = true
pageUrl.value = window.location.href
Expand All @@ -29,12 +39,13 @@ onMounted(() => {
<div class="rpl-grid">
<div class="rpl-col-12 rpl-col-7-m">
<TideLandingPageWebForm
v-if="isMounted"
v-if="isMounted && webformFetchStatus === 'success'"
:formId="contentRatingFormId"
hideFormOnSubmit
title="Was this page helpful?"
successMessageHTML="Thank you! Your response has been submitted."
errorMessageHTML="We are experiencing a server error. Please try again, otherwise contact us."
:captcha-config="webformData?.captchaConfig"
>
<template #default="{ value }">
<div class="tide-content-rating__rating">
Expand Down Expand Up @@ -83,7 +94,7 @@ onMounted(() => {
<RplContent
v-if="contentRatingText"
:html="contentRatingText"
class="tide-content-rating__text"
class="tide-content-rating__text rpl-u-margin-b-6"
/>
<FormKit
v-if="value.was_this_page_helpful"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ import { createProxyMiddleware } from 'http-proxy-middleware'

export const createWebformProxyHandler = async (event: H3Event) => {
const { public: config } = useRuntimeConfig()
const formId = event.context.params?.formId

if (!formId) {
throw new BadRequestError('Form id is required')
}

try {
await verifyCaptcha(event)
} catch (error) {
logger.error(`CAPTCHA validation error`, error)
sendError(
event,
createError({
statusCode: 400,
statusMessage: 'Unable to verify CAPTCHA'
})
)
return
}

const proxyMiddleware = createProxyMiddleware({
target: config.tide.baseUrl,
Expand Down
172 changes: 172 additions & 0 deletions packages/nuxt-ripple/server/utils/verifyCaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useRuntimeConfig } from '#imports'
import { logger } from '@dpc-sdp/ripple-tide-api'
import {
ApplicationError,
UnauthorisedError
} from '@dpc-sdp/ripple-tide-api/errors'

import type { H3Event } from 'h3'
import {
CaptchaType,
MappedCaptchaConfig
} from '@dpc-sdp/ripple-tide-webform/types'

const genericCaptchaVerify = async (
verifyUrl: string,
secretKey: string,
responseToken: string,
responseCallback: (response: any) => boolean
) => {
try {
const formData = new FormData()

formData.append('secret', secretKey)
formData.append('response', responseToken)

const verifyResponse = await $fetch(verifyUrl, {
method: 'POST',
body: formData
})

const isValid = responseCallback(verifyResponse)

if (!isValid) {
logger.error('CAPTCHA verification failed, response was:', verifyResponse)
}

return isValid
} catch (error) {
return false
}
}

const verifyGoogleRecaptchaV3 = async (
secretKey: string,
captchaConfig: MappedCaptchaConfig,
captchaResponse: string
) => {
const defaultScoreThreshold = 0.5

const scoreThreshold =
typeof captchaConfig?.scoreThreshold === 'number'
? captchaConfig?.scoreThreshold
: defaultScoreThreshold

return await genericCaptchaVerify(
'https://www.google.com/recaptcha/api/siteverify',
secretKey,
captchaResponse,
(verifyResponse) => {
return (
!!verifyResponse?.success && verifyResponse?.score >= scoreThreshold
)
}
)
}

const verifyGoogleRecaptchaV2 = async (
secretKey: string,
captchaResponse: string
) => {
return await genericCaptchaVerify(
'https://www.google.com/recaptcha/api/siteverify',
secretKey,
captchaResponse,
(verifyResponse) => {
return !!verifyResponse?.success
}
)
}

const verifyCloudfareTurnstile = async (
secretKey: string,
captchaResponse: string
) => {
return await genericCaptchaVerify(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
secretKey,
captchaResponse,
(verifyResponse) => {
return !!verifyResponse?.success
}
)
}

const verify = async (
secretKey: string,
captchaConfig: MappedCaptchaConfig,
captchaResponse?: string
) => {
if (!captchaResponse) {
return false
}

switch (captchaConfig?.type) {
case CaptchaType.RECAPTCHA_V3:
return await verifyGoogleRecaptchaV3(
secretKey,
captchaConfig,
captchaResponse
)
case CaptchaType.RECAPTCHA_V2:
return await verifyGoogleRecaptchaV2(secretKey, captchaResponse)
case CaptchaType.TURNSTILE:
return await verifyCloudfareTurnstile(secretKey, captchaResponse)
default:
return false
}
}

const verifyCaptcha = async (event: H3Event) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we take the token generated in the browser and validate it which the CAPTCHA provider, this needs to happen server side.

We have to get the webform from drupal here to check if the captcha is enabled or not.

const config = useRuntimeConfig()
const captchaResponse = getHeader(event, 'x-captcha-response')
const formId = event.context.params?.formId
let webform

try {
webform = await $fetch('/api/tide/webform', {
baseURL: config.apiUrl || '',
params: {
id: formId
}
})
} catch (error) {
throw new ApplicationError(
`Couldn't get webform data, unable to continue because we don't know if a captcha is required`,
{ cause: error }
)
}

if (!webform) {
throw new ApplicationError(
`Couldn't get webform data, unable to continue because we don't know if a captcha is required`
)
}

const formHasCaptcha = webform?.captchaConfig?.enabled

if (!formHasCaptcha) {
return true
}

const secretKey =
config.tide.captchaSecret[webform?.captchaConfig?.siteIdentifier]

if (!secretKey) {
throw new ApplicationError(
`Secret key missing for site identifier: ${webform?.captchaConfig?.siteIdentifier} (site key: ${webform?.captchaConfig?.siteKey})`
)
}

const isValid = await verify(
secretKey,
webform?.captchaConfig,
captchaResponse
)

if (!isValid) {
throw new UnauthorisedError('Failed to verify CAPTCHA response token')
}
}

export default verifyCaptcha
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { FormKitSchemaNode } from '@formkit/core'
import { computed, nextTick, ref, watch } from 'vue'
import type { MappedCaptchaConfig } from '@dpc-sdp/ripple-tide-webform/types'

interface Props {
title?: string
Expand All @@ -11,19 +12,26 @@ interface Props {
errorMessageTitle?: string
errorMessageHTML: string
schema?: Array<FormKitSchemaNode>
captchaConfig?: MappedCaptchaConfig | null
}

const props = withDefaults(defineProps<Props>(), {
title: undefined,
hideFormOnSubmit: false,
successMessageTitle: 'Form submitted',
errorMessageTitle: 'Form not submitted',
schema: undefined
schema: undefined,
captchaConfig: null
})

const honeypotId = `${props.formId}-important-email`

const { submissionState, submitHandler } = useWebformSubmit(props.formId)
const { submissionState, submitHandler } = useWebformSubmit(
props.formId,
props.captchaConfig
)

const { captchaWidgetId } = useCaptcha(props.formId, props.captchaConfig)

const serverSuccessRef = ref(null)

Expand All @@ -36,6 +44,9 @@ watch(
if (serverSuccessRef.value) {
serverSuccessRef.value.focus()
}

// Need to reset captcha because some captchas won't allow using the same captcha challenge token twice
useResetCaptcha(captchaWidgetId.value, props.captchaConfig)
}
}
)
Expand Down Expand Up @@ -103,7 +114,7 @@ customInputs.library = (node: any) => {
:customInputs="customInputs"
:schema="schema"
:submissionState="submissionState as any"
@submit="submitHandler(props, $event.data)"
@submit="submitHandler(props, $event.data, captchaWidgetId)"
>
<template #belowForm>
<div class="tide-webform-important-email">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { TideDynamicPageComponent } from '@dpc-sdp/ripple-tide-api/types'
import { TidePageApi } from '@dpc-sdp/ripple-tide-api'
import type { TideWebform, ApiField } from '@dpc-sdp/ripple-tide-webform/types'
import { getFormSchemaFromMapping } from '@dpc-sdp/ripple-tide-webform/mapping'
import {
getFormSchemaFromMapping,
getCaptchaSettings
} from '@dpc-sdp/ripple-tide-webform/mapping'

const componentMapping = async (field: ApiField, tidePageApi: TidePageApi) => {
return {
Expand All @@ -22,7 +25,8 @@ const componentMapping = async (field: ApiField, tidePageApi: TidePageApi) => {
schema: await getFormSchemaFromMapping(
field.field_paragraph_webform,
tidePageApi
)
),
captchaConfig: getCaptchaSettings(field.field_paragraph_webform)
}
}
export const webformMapping = async (
Expand Down
47 changes: 41 additions & 6 deletions packages/ripple-tide-webform/composables/use-webform-submit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { $fetch } from 'ofetch'
import { ref, useRuntimeConfig } from '#imports'

export function useWebformSubmit(formId: string) {
const postForm = async (formId: string, formData = {}) => {
import { MappedCaptchaConfig } from '../types'

export function useWebformSubmit(
formId: string,
captchaConfig?: MappedCaptchaConfig | null
) {
const postForm = async (
formId: string,
formData = {},
maybeCaptchaResponse: string | null
) => {
const { public: config } = useRuntimeConfig()

const formResource = 'webform_submission'
Expand All @@ -29,7 +37,8 @@ export function useWebformSubmit(formId: string) {
site: config.tide.site
},
headers: {
'Content-Type': 'application/vnd.api+json;charset=UTF-8'
'Content-Type': 'application/vnd.api+json;charset=UTF-8',
'x-captcha-response': maybeCaptchaResponse || undefined
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The captcha token is sent via a header to the nuxt server endpoint

}
})

Expand Down Expand Up @@ -67,7 +76,11 @@ export function useWebformSubmit(formId: string) {
return honeypotElement && !!honeypotElement.value
}

const submitHandler = async (props: FormConfig, data: any) => {
const submitHandler = async (
props: FormConfig,
data: any,
captchaWidgetId?: string
) => {
submissionState.value = {
status: 'submitting',
title: '',
Expand All @@ -87,8 +100,30 @@ export function useWebformSubmit(formId: string) {
return
}

let maybeCaptchaResponse = null

try {
maybeCaptchaResponse = await getCaptchaResponse(
formId,
captchaConfig,
captchaWidgetId,
window
)
} catch (e) {
console.error(e)

submissionState.value = {
status: 'error',
title: props.errorMessageTitle,
message: 'Invalid CAPTCHA',
receipt: ''
}

return
}

try {
const resData = await postForm(props.formId, data)
const resData = await postForm(props.formId, data, maybeCaptchaResponse)

const [code, note] = resData.attributes?.notes?.split('|') || []

Expand Down
Loading