From d8085f828fb0804fa13d4d095445ad385465e117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 5 Dec 2025 18:20:26 +0100 Subject: [PATCH] feat(security): add Cloudflare Turnstile CAPTCHA to registration form - Add Turnstile widget rendering in RegisterPage on step 2 - Implement programmatic widget initialization with callbacks - Add token validation before form submission - Update AuthContext and API service to pass turnstileToken - Add backend verification via Cloudflare API in register controller - Include client IP in verification request - Add validation rule for turnstileToken - Reset widget on registration error --- backend/src/controllers/auth.js | 33 +++++++++++- backend/src/middleware/validators.js | 3 ++ backend/src/routes/public.js | 3 -- frontend/src/contexts/AuthContext.jsx | 4 +- frontend/src/pages/RegisterPage.jsx | 75 ++++++++++++++++++++++++++- frontend/src/services/api.js | 4 +- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js index 6d2076e..84e05b3 100644 --- a/backend/src/controllers/auth.js +++ b/backend/src/controllers/auth.js @@ -16,7 +16,38 @@ const { getClientIP } = require('../utils/request'); // Register new user (Phase 1.5 - with WSDC support and email verification) async function register(req, res, next) { try { - const { username, email, password, firstName, lastName, wsdcId } = req.body; + const { username, email, password, firstName, lastName, wsdcId, turnstileToken } = req.body; + + // Verify Turnstile token + const turnstileSecret = process.env.TURNSTILE_SECRET_KEY; + const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + + try { + const turnstileResponse = await fetch(turnstileVerifyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + secret: turnstileSecret, + response: turnstileToken, + remoteip: getClientIP(req), + }), + }); + + const turnstileResult = await turnstileResponse.json(); + + if (!turnstileResult.success) { + return res.status(400).json({ + success: false, + error: 'CAPTCHA verification failed. Please try again.', + }); + } + } catch (turnstileError) { + console.error('Turnstile verification error:', turnstileError); + return res.status(500).json({ + success: false, + error: 'CAPTCHA verification failed. Please try again.', + }); + } // Check if user already exists const existingUser = await prisma.user.findFirst({ diff --git a/backend/src/middleware/validators.js b/backend/src/middleware/validators.js index c56f98c..285c0eb 100644 --- a/backend/src/middleware/validators.js +++ b/backend/src/middleware/validators.js @@ -74,6 +74,9 @@ const registerValidation = [ .trim() .matches(/^\d{1,10}$/) .withMessage('WSDC ID must be numeric (max 10 digits)'), + body('turnstileToken') + .notEmpty() + .withMessage('CAPTCHA verification is required'), handleValidationErrors, ]; diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js index 90b71c0..46acfb8 100644 --- a/backend/src/routes/public.js +++ b/backend/src/routes/public.js @@ -79,8 +79,6 @@ router.post('/contact', [ const turnstileSecret = process.env.TURNSTILE_SECRET_KEY; const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - console.log('[Turnstile] Verifying token, secret present:', !!turnstileSecret); - try { const turnstileResponse = await fetch(turnstileVerifyUrl, { method: 'POST', @@ -93,7 +91,6 @@ router.post('/contact', [ }); const turnstileResult = await turnstileResponse.json(); - console.log('[Turnstile] Verification result:', JSON.stringify(turnstileResult)); if (!turnstileResult.success) { return res.status(400).json({ diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 58e57f9..e4d4a95 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -48,9 +48,9 @@ export const AuthProvider = ({ children }) => { } }; - const register = async (username, email, password, firstName = null, lastName = null, wsdcId = null) => { + const register = async (username, email, password, firstName = null, lastName = null, wsdcId = null, turnstileToken = null) => { try { - const { user: userData } = await authAPI.register(username, email, password, firstName, lastName, wsdcId); + const { user: userData } = await authAPI.register(username, email, password, firstName, lastName, wsdcId, turnstileToken); setUser(userData); // Save to localStorage for persistence localStorage.setItem('user', JSON.stringify(userData)); diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index d551a3f..9b51ce0 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { wsdcAPI } from '../services/api'; @@ -32,6 +32,8 @@ const RegisterPage = () => { }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const turnstileRef = useRef(null); const { register } = useAuth(); const navigate = useNavigate(); @@ -78,6 +80,58 @@ const RegisterPage = () => { return () => clearTimeout(timeoutId); }, [wsdcId]); + // Setup Turnstile callbacks and render widget when on step 2 + useEffect(() => { + if (step !== 2) return; + + // 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; + }; + }, [step]); + // Handle WSDC ID confirmation and continue to registration const handleWsdcContinue = () => { if (!wsdcPreview) { @@ -130,6 +184,12 @@ const RegisterPage = () => { return; } + // Validate Turnstile token + if (!turnstileToken) { + setError('Please complete the CAPTCHA verification'); + return; + } + setLoading(true); try { @@ -139,11 +199,17 @@ const RegisterPage = () => { formData.password, formData.firstName || null, formData.lastName || null, - wsdcData?.wsdcId || null + wsdcData?.wsdcId || null, + turnstileToken ); navigate('/events'); } catch (err) { setError(err.message || 'Registration failed'); + // Reset Turnstile on error + if (window.turnstile && turnstileRef.current) { + window.turnstile.reset(turnstileRef.current); + setTurnstileToken(''); + } } finally { setLoading(false); } @@ -376,6 +442,11 @@ const RegisterPage = () => { required /> + {/* Cloudflare Turnstile CAPTCHA */} +
+
+
+