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:
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user