feat(security): implement Cloudflare Turnstile CAPTCHA on contact form

- Add Turnstile script to frontend/index.html
- Implement programmatic widget rendering in ContactPage
- Add backend verification via Cloudflare API
- Include client IP in verification request
- Update CSP headers to allow Cloudflare resources
- Add environment variable configuration for site and secret keys
- Pass VITE_TURNSTILE_SITE_KEY to frontend container
- Add validation and error handling for CAPTCHA tokens
This commit is contained in:
Radosław Gierwiało
2025-12-05 18:08:05 +01:00
parent 25042d0fec
commit f3b8156557
8 changed files with 122 additions and 9 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Send, Mail, User, MessageSquare } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
@@ -11,6 +11,8 @@ export default function ContactPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [turnstileToken, setTurnstileToken] = useState('');
const turnstileRef = useRef(null);
const [formData, setFormData] = useState({
firstName: '',
@@ -20,6 +22,56 @@ export default function ContactPage() {
message: '',
});
// Setup Turnstile callbacks and render widget
useEffect(() => {
// Callback when Turnstile verification is successful
window.onTurnstileSuccess = (token) => {
setTurnstileToken(token);
};
// Callback when Turnstile encounters an error
window.onTurnstileError = () => {
setError('CAPTCHA verification failed. Please try again.');
setTurnstileToken('');
};
// Wait for Turnstile script to load and render widget
const renderTurnstile = () => {
if (window.turnstile && turnstileRef.current && !turnstileRef.current.hasChildNodes()) {
window.turnstile.render(turnstileRef.current, {
sitekey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
callback: window.onTurnstileSuccess,
'error-callback': window.onTurnstileError,
theme: 'light',
});
}
};
// Check if script is already loaded
if (window.turnstile) {
renderTurnstile();
} else {
// Wait for script to load
const checkTurnstile = setInterval(() => {
if (window.turnstile) {
renderTurnstile();
clearInterval(checkTurnstile);
}
}, 100);
return () => {
clearInterval(checkTurnstile);
delete window.onTurnstileSuccess;
delete window.onTurnstileError;
};
}
return () => {
delete window.onTurnstileSuccess;
delete window.onTurnstileError;
};
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
@@ -28,6 +80,13 @@ export default function ContactPage() {
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Validate Turnstile token
if (!turnstileToken) {
setError('Please complete the CAPTCHA verification');
return;
}
setLoading(true);
try {
@@ -36,6 +95,7 @@ export default function ContactPage() {
email: formData.email,
subject: formData.subject,
message: formData.message,
turnstileToken,
};
// Add firstName and lastName only for non-logged-in users
@@ -53,6 +113,11 @@ export default function ContactPage() {
}, 3000);
} catch (err) {
setError(err.data?.error || 'Failed to submit contact form. Please try again.');
// Reset Turnstile on error
if (window.turnstile && turnstileRef.current) {
window.turnstile.reset(turnstileRef.current);
setTurnstileToken('');
}
} finally {
setLoading(false);
}
@@ -208,11 +273,9 @@ export default function ContactPage() {
</p>
</div>
{/* TODO: CAPTCHA will go here */}
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<p className="text-sm text-yellow-800">
CAPTCHA verification coming soon
</p>
{/* Cloudflare Turnstile CAPTCHA */}
<div>
<div ref={turnstileRef}></div>
</div>
{/* Error Message */}