Skip to content

Commit 336cb19

Browse files
IhorMasechkoVitalyyPAnton-88yuramax
authored
[659] feat(form): Add reCAPTCHA validation to form submissions (#197)
- add server-side reCAPTCHA verification to form submission route - add client-side reCAPTCHA validation and error handling - update form widget template to include reCAPTCHA widget and error message - update styles for reCAPTCHA and error messages - add abort-controller and node-fetch dependencies for server-side validation --------- Co-authored-by: Vitalii Pikozh <wisart@gmail.com> Co-authored-by: Anton Kovalchuk <79926879+Anton-88@users.noreply.github.com> Co-authored-by: Yuriy <yuramax@gmail.com>
1 parent c06f792 commit 336cb19

File tree

14 files changed

+554
-69
lines changed

14 files changed

+554
-69
lines changed

sonar-project.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ sonar.coverage.exclusions=\
2020

2121
sonar.exclusions=\
2222
website/node_modules/**,\
23-
website/coverage/**
23+
website/coverage/**,\
24+
website/modules/@apostrophecms/form-widget/views/recaptcha-script.html
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<script src="https://www.google.com/recaptcha/api.js" async defer></script>

website/modules/@apostrophecms/form-widget/views/widget.html

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,24 @@
2020
method="post"
2121
action="/api/v1/@apostrophecms/form/submit"
2222
>
23-
{% area form, 'contents' %} {% if recaptchaReady %}
24-
<noscript>
25-
<p>{{ __t('aposForm:widgetNoScript') }}</p>
26-
</noscript>
27-
{% endif %}
28-
29-
<button
30-
type="submit"
31-
class="sf-button"
32-
{%
33-
if
34-
recaptchaReady
35-
%}disabled{%
36-
endif
37-
%}
23+
{% area form, 'contents' %} {% if recaptchaReady %} {% include
24+
"recaptcha-script.html" %}
25+
<div
26+
class="g-recaptcha"
27+
data-sitekey="{{ recaptchaSite }}"
28+
data-size="compact"
29+
></div>
30+
{% if recaptchaSite %}
31+
<p
32+
role="alert"
33+
data-apos-form-recaptcha-error
34+
class="apos-form-hidden apos-form-captcha-error {{ prependIfPrefix('__error') }}"
3835
>
36+
Please confirm you are not a robot.
37+
</p>
38+
{% endif %} {% endif %}
39+
40+
<button type="submit" class="sf-button">
3941
{{ form.submitLabel or __t('aposForm:widgetSubmit') }}
4042
</button>
4143
</form>
@@ -50,16 +52,6 @@
5052
<span data-apos-form-global-error></span>
5153
</p>
5254

53-
{% if recaptchaSite %}
54-
<p
55-
role="alert"
56-
data-apos-form-recaptcha-error
57-
class="apos-form-hidden apos-form-error {{ prependIfPrefix('__error') }}"
58-
>
59-
{{ __t('aposForm:widgetCaptchaError') }}
60-
</p>
61-
{% endif %}
62-
6355
<p
6456
class="apos-form-hidden {{ prependIfPrefix('__spinner') }}"
6557
data-apos-form-spinner

website/modules/@apostrophecms/form/index.js

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const GoogleSheetsFormSubmissionHandler = require('./lib/GoogleSheetsFormSubmiss
33
const GoogleSheetsErrorHandler = require('./lib/GoogleSheetsErrorHandler');
44
const { formatForSpreadsheet } = require('./lib/formatForSpreadsheet');
55
const { getSheetsAuthConfig } = require('./lib/getSheetsAuthConfig');
6+
const { verifyRecaptcha } = require('./lib/verifyRecaptcha');
67

78
const VALIDATION_INSTRUCTIONS =
89
'For proper validation, place the name, email, and phone number fields at the beginning of the form, in this exact order. Use a text input for each. Add all other fields afterward.';
@@ -13,6 +14,41 @@ const validateSubmissionSuccess = (result) => {
1314
}
1415
};
1516

17+
const submitRouteHandler = function (self) {
18+
return async function (req, res) {
19+
try {
20+
const formData = req?.body?.data ?? null;
21+
if (!formData) {
22+
return res.status(400).json({ error: 'Invalid form data' });
23+
}
24+
25+
const globalDoc = await self.apos.global.find(req).toObject();
26+
const recaptchaToken = formData['g-recaptcha-response'];
27+
if (globalDoc.useRecaptcha && globalDoc.recaptchaSecret) {
28+
const result = await verifyRecaptcha({
29+
secret: globalDoc.recaptchaSecret,
30+
token: recaptchaToken,
31+
remoteip:
32+
req.headers['x-forwarded-for']?.split(',').shift().trim() || req.ip,
33+
});
34+
if (!result.success) {
35+
return res.status(400).json({ error: result.error });
36+
}
37+
}
38+
39+
const result = await self.formSubmissionHandler.handle(formData);
40+
if (!result) {
41+
return res.status(500).json({ error: 'Form submission failed' });
42+
}
43+
44+
return res.json({ success: true });
45+
} catch (error) {
46+
self.apos.util.error('Form submission error:', error);
47+
return res.status(500).json({ error: 'An error occurred' });
48+
}
49+
};
50+
};
51+
1652
module.exports = {
1753
improve: '@apostrophecms/form',
1854
fields: {
@@ -86,24 +122,7 @@ module.exports = {
86122
routes(self) {
87123
return {
88124
post: {
89-
submit: async (req, res) => {
90-
try {
91-
const formData = req?.body?.data ?? null;
92-
if (!formData) {
93-
return res.status(400).json({ error: 'Invalid form data' });
94-
}
95-
96-
const result = await self.formSubmissionHandler.handle(formData);
97-
if (!result) {
98-
return res.status(500).json({ error: 'Form submission failed' });
99-
}
100-
101-
return res.json({ success: true });
102-
} catch (error) {
103-
self.apos.util.error('Form submission error:', error);
104-
return res.status(500).json({ error: 'An error occurred' });
105-
}
106-
},
125+
submit: submitRouteHandler(self),
107126
},
108127
};
109128
},

website/modules/@apostrophecms/form/lib/formatForSpreadsheet.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ const generateHeaders = (formData) => {
55
const { _id, ...formFields } = formData;
66

77
for (const key of Object.keys(formFields)) {
8-
headers.push(formatHeaderName(key));
8+
if (key !== 'g-recaptcha-response') {
9+
headers.push(formatHeaderName(key));
10+
}
911
}
1012

1113
return headers;
@@ -18,11 +20,13 @@ const generateRowData = (formData) => {
1820

1921
const { _id, ...formFields } = formData;
2022

21-
for (const value of Object.values(formFields)) {
22-
if (Array.isArray(value)) {
23-
rowData.push(value.join(', '));
24-
} else {
25-
rowData.push(value);
23+
for (const [key, value] of Object.entries(formFields)) {
24+
if (key !== 'g-recaptcha-response') {
25+
if (Array.isArray(value)) {
26+
rowData.push(value.join(', '));
27+
} else {
28+
rowData.push(value);
29+
}
2630
}
2731
}
2832

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const fetch = require('node-fetch');
2+
const AbortController = require('abort-controller');
3+
4+
const validateRecaptchaParams = function ({ secret, token, remoteip }) {
5+
if (!secret || secret.trim() === '') {
6+
return { success: false, error: 'Missing reCAPTCHA secret.' };
7+
}
8+
if (!token || token.trim() === '') {
9+
return { success: false, error: 'Missing reCAPTCHA token.' };
10+
}
11+
if (!remoteip || remoteip.trim() === '') {
12+
return { success: false, error: 'Missing remote IP address.' };
13+
}
14+
return null;
15+
};
16+
17+
const sendRecaptchaRequest = async function ({ secret, token, remoteip }) {
18+
const params = new URLSearchParams();
19+
params.append('secret', secret);
20+
params.append('response', token);
21+
params.append('remoteip', remoteip);
22+
23+
const controller = new AbortController();
24+
const timeoutId = setTimeout(() => controller.abort(), 5000);
25+
26+
try {
27+
const response = await fetch(
28+
'https://www.google.com/recaptcha/api/siteverify',
29+
{
30+
method: 'POST',
31+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
32+
body: params,
33+
signal: controller.signal,
34+
},
35+
);
36+
clearTimeout(timeoutId);
37+
38+
if (!response.ok) {
39+
return {
40+
success: false,
41+
error: `HTTP error! status: ${response.status}`,
42+
};
43+
}
44+
const data = await response.json();
45+
if (!data.success) {
46+
return {
47+
success: false,
48+
error: 'reCAPTCHA verification failed.',
49+
details: data,
50+
};
51+
}
52+
return { success: true, details: data };
53+
} catch (error) {
54+
clearTimeout(timeoutId);
55+
if (error.name === 'AbortError') {
56+
return { success: false, error: 'reCAPTCHA verification timed out.' };
57+
}
58+
return { success: false, error: `Network error: ${error.message}` };
59+
}
60+
};
61+
62+
const verifyRecaptcha = function ({ secret, token, remoteip }) {
63+
const validationError = validateRecaptchaParams({ secret, token, remoteip });
64+
if (validationError) {
65+
return validationError;
66+
}
67+
return sendRecaptchaRequest({ secret, token, remoteip });
68+
};
69+
70+
module.exports = { verifyRecaptcha };
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
const { verifyRecaptcha } = require('./verifyRecaptcha');
2+
3+
jest.mock('node-fetch');
4+
const fetch = require('node-fetch');
5+
6+
describe('verifyRecaptcha', () => {
7+
beforeEach(() => {
8+
fetch.mockReset();
9+
});
10+
11+
it('should fail if token is missing', async () => {
12+
const result = await verifyRecaptcha({
13+
secret: 'test',
14+
token: '',
15+
remoteip: '127.0.0.1',
16+
});
17+
expect(result.success).toBe(false);
18+
expect(result.error).toBe('Missing reCAPTCHA token.');
19+
});
20+
21+
it('should fail if Google returns error', async () => {
22+
fetch.mockResolvedValueOnce({
23+
ok: true,
24+
json: () => ({ success: false }),
25+
});
26+
const result = await verifyRecaptcha({
27+
secret: 'test',
28+
token: 'sometoken',
29+
remoteip: '127.0.0.1',
30+
});
31+
expect(result.success).toBe(false);
32+
expect(result.error).toBe('reCAPTCHA verification failed.');
33+
});
34+
35+
it('should succeed if Google returns success', async () => {
36+
fetch.mockResolvedValueOnce({
37+
ok: true,
38+
json: () => ({ success: true }),
39+
});
40+
const result = await verifyRecaptcha({
41+
secret: 'test',
42+
token: 'sometoken',
43+
remoteip: '127.0.0.1',
44+
});
45+
expect(result.success).toBe(true);
46+
});
47+
48+
it('should fail if Google returns HTTP error', async () => {
49+
fetch.mockResolvedValueOnce({
50+
ok: false,
51+
status: 500,
52+
json: () => ({}),
53+
});
54+
const result = await verifyRecaptcha({
55+
secret: 'test',
56+
token: 'sometoken',
57+
remoteip: '127.0.0.1',
58+
});
59+
expect(result.success).toBe(false);
60+
expect(result.error).toMatch(/HTTP error/u);
61+
});
62+
63+
it('should fail if secret is missing', async () => {
64+
const result = await verifyRecaptcha({
65+
secret: '',
66+
token: 'sometoken',
67+
remoteip: '127.0.0.1',
68+
});
69+
expect(result.success).toBe(false);
70+
expect(result.error).toBe('Missing reCAPTCHA secret.');
71+
});
72+
73+
it('should fail if remoteip is missing', async () => {
74+
const result = await verifyRecaptcha({
75+
secret: 'test',
76+
token: 'sometoken',
77+
remoteip: '',
78+
});
79+
expect(result.success).toBe(false);
80+
expect(result.error).toBe('Missing remote IP address.');
81+
});
82+
});

website/modules/asset/ui/src/js/formValidation.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { validateField } = require('./formValidator');
22
const { showValidationError, clearValidationError } = require('./domHelpers');
3+
const { addRecaptchaValidationHandlers } = require('./recaptchaValidation');
34

45
// Test-specific DOM helpers
56
const testShowValidationError = (field, message) => {
@@ -210,19 +211,51 @@ const sendFormData = (form, formData) => {
210211

211212
const handleFormSubmit = (event, form, validateFieldFn) => {
212213
event.preventDefault();
214+
215+
let hasError = false;
216+
217+
// ReCAPTCHA validation (client-side)
218+
const recaptchaWidget = form.querySelector('.g-recaptcha');
219+
const recaptchaError = document.querySelector(
220+
'[data-apos-form-recaptcha-error]',
221+
);
222+
if (
223+
typeof window.grecaptcha !== 'undefined' &&
224+
recaptchaWidget &&
225+
!window.grecaptcha.getResponse()
226+
) {
227+
if (recaptchaError) {
228+
recaptchaError.classList.remove('apos-form-hidden');
229+
recaptchaError.scrollIntoView({ behavior: 'smooth', block: 'center' });
230+
}
231+
hasError = true;
232+
} else if (recaptchaError) {
233+
recaptchaError.classList.add('apos-form-hidden');
234+
}
235+
213236
// Disable submit button(s) to prevent multiple submissions
214237
const submitButtons = form.querySelectorAll(
215238
'button[type="submit"], input[type="submit"]',
216239
);
217240
submitButtons.forEach((btn) => (btn.disabled = true));
218241

219242
validateForm(form, validateFieldFn)
220-
.then((isValid) => onValidateForm(isValid, form, validateFieldFn))
243+
.then((isValid) => {
244+
if (!isValid) {
245+
hasError = true;
246+
}
247+
if (!hasError) {
248+
return onValidateForm(true, form, validateFieldFn);
249+
}
250+
return null;
251+
})
221252
.finally(() => {
222253
// Re-enable submit button(s) after processing
223254
submitButtons.forEach((btn) => (btn.disabled = false));
224255
})
225256
.catch(() => false);
257+
258+
return true;
226259
};
227260

228261
const initFormWithValidation = (form, validateFieldFn) => {
@@ -240,6 +273,9 @@ const initFormWithValidation = (form, validateFieldFn) => {
240273
},
241274
true,
242275
);
276+
277+
// Add reCAPTCHA validation handlers
278+
addRecaptchaValidationHandlers(form);
243279
};
244280

245281
module.exports = { initFormValidation };

0 commit comments

Comments
 (0)