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
This commit is contained in:
Radosław Gierwiało
2025-12-05 18:20:26 +01:00
parent f3b8156557
commit d8085f828f
6 changed files with 112 additions and 10 deletions

View File

@@ -16,7 +16,38 @@ const { getClientIP } = require('../utils/request');
// Register new user (Phase 1.5 - with WSDC support and email verification) // Register new user (Phase 1.5 - with WSDC support and email verification)
async function register(req, res, next) { async function register(req, res, next) {
try { 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 // Check if user already exists
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({

View File

@@ -74,6 +74,9 @@ const registerValidation = [
.trim() .trim()
.matches(/^\d{1,10}$/) .matches(/^\d{1,10}$/)
.withMessage('WSDC ID must be numeric (max 10 digits)'), .withMessage('WSDC ID must be numeric (max 10 digits)'),
body('turnstileToken')
.notEmpty()
.withMessage('CAPTCHA verification is required'),
handleValidationErrors, handleValidationErrors,
]; ];

View File

@@ -79,8 +79,6 @@ router.post('/contact', [
const turnstileSecret = process.env.TURNSTILE_SECRET_KEY; const turnstileSecret = process.env.TURNSTILE_SECRET_KEY;
const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
console.log('[Turnstile] Verifying token, secret present:', !!turnstileSecret);
try { try {
const turnstileResponse = await fetch(turnstileVerifyUrl, { const turnstileResponse = await fetch(turnstileVerifyUrl, {
method: 'POST', method: 'POST',
@@ -93,7 +91,6 @@ router.post('/contact', [
}); });
const turnstileResult = await turnstileResponse.json(); const turnstileResult = await turnstileResponse.json();
console.log('[Turnstile] Verification result:', JSON.stringify(turnstileResult));
if (!turnstileResult.success) { if (!turnstileResult.success) {
return res.status(400).json({ return res.status(400).json({

View File

@@ -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 { 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); setUser(userData);
// Save to localStorage for persistence // Save to localStorage for persistence
localStorage.setItem('user', JSON.stringify(userData)); localStorage.setItem('user', JSON.stringify(userData));

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { wsdcAPI } from '../services/api'; import { wsdcAPI } from '../services/api';
@@ -32,6 +32,8 @@ const RegisterPage = () => {
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const turnstileRef = useRef(null);
const { register } = useAuth(); const { register } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -78,6 +80,58 @@ const RegisterPage = () => {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [wsdcId]); }, [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 // Handle WSDC ID confirmation and continue to registration
const handleWsdcContinue = () => { const handleWsdcContinue = () => {
if (!wsdcPreview) { if (!wsdcPreview) {
@@ -130,6 +184,12 @@ const RegisterPage = () => {
return; return;
} }
// Validate Turnstile token
if (!turnstileToken) {
setError('Please complete the CAPTCHA verification');
return;
}
setLoading(true); setLoading(true);
try { try {
@@ -139,11 +199,17 @@ const RegisterPage = () => {
formData.password, formData.password,
formData.firstName || null, formData.firstName || null,
formData.lastName || null, formData.lastName || null,
wsdcData?.wsdcId || null wsdcData?.wsdcId || null,
turnstileToken
); );
navigate('/events'); navigate('/events');
} catch (err) { } catch (err) {
setError(err.message || 'Registration failed'); setError(err.message || 'Registration failed');
// Reset Turnstile on error
if (window.turnstile && turnstileRef.current) {
window.turnstile.reset(turnstileRef.current);
setTurnstileToken('');
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -376,6 +442,11 @@ const RegisterPage = () => {
required required
/> />
{/* Cloudflare Turnstile CAPTCHA */}
<div>
<div ref={turnstileRef}></div>
</div>
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<LoadingButton <LoadingButton
type="submit" type="submit"

View File

@@ -112,10 +112,10 @@ async function fetchAPI(endpoint, options = {}) {
// Auth API // Auth API
export const authAPI = { export const authAPI = {
async register(username, email, password, firstName = null, lastName = null, wsdcId = null) { async register(username, email, password, firstName = null, lastName = null, wsdcId = null, turnstileToken = null) {
const data = await fetchAPI('/auth/register', { const data = await fetchAPI('/auth/register', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, email, password, firstName, lastName, wsdcId }), body: JSON.stringify({ username, email, password, firstName, lastName, wsdcId, turnstileToken }),
}); });
// Save token // Save token