From 3becfbd1adc7acca9b74ca894dff24edbcfb9607 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Sat, 13 Dec 2025 16:32:32 -1000 Subject: [PATCH] Add Cloudflare Turnstile for login/signup --- package-lock.json | 28 +++++++++++++++++++++- package.json | 4 +++- src/app/login/actions.ts | 23 ++++++++++++++++++ src/app/signup/actions.ts | 23 ++++++++++++++++++ src/components/Auth/LoginForm.tsx | 38 +++++++++++++++++++++++++++++- src/components/Auth/SignupForm.tsx | 38 +++++++++++++++++++++++++++++- 6 files changed, 150 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d7e417..9fc67df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "mdast-util-to-hast": "^13.2.1", "minio": "^8.0.6", "next": "^15.5.8", + "next-turnstile": "^1.0.7", "nodemailer": "^7.0.11", "react": "^19.1.2", "react-chartjs-2": "^5.3.1", @@ -32,7 +33,8 @@ "remark-gfm": "4.0.0", "rom-patcher-js": "github:Hackdex-App/RomPatcher.js", "schema-dts": "^1.1.5", - "serialize-javascript": "^7.0.0" + "serialize-javascript": "^7.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -5932,6 +5934,17 @@ } } }, + "node_modules/next-turnstile": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/next-turnstile/-/next-turnstile-1.0.7.tgz", + "integrity": "sha512-hN0fr5fwOiK0o8draaJmcYElDqsiYBd4OOCy+0IKMlJgjpPzxtoyagojPBoHsyw9i3jVoBpusPZcUMuKuKQsNw==", + "license": "MIT", + "peerDependencies": { + "next": ">=12", + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7164,6 +7177,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 56bb3bc..8e89d42 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "mdast-util-to-hast": "^13.2.1", "minio": "^8.0.6", "next": "^15.5.8", + "next-turnstile": "^1.0.7", "nodemailer": "^7.0.11", "react": "^19.1.2", "react-chartjs-2": "^5.3.1", @@ -33,7 +34,8 @@ "remark-gfm": "4.0.0", "rom-patcher-js": "github:Hackdex-App/RomPatcher.js", "schema-dts": "^1.1.5", - "serialize-javascript": "^7.0.0" + "serialize-javascript": "^7.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts index d595ec3..5559358 100644 --- a/src/app/login/actions.ts +++ b/src/app/login/actions.ts @@ -3,6 +3,8 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { AuthError, User } from '@supabase/supabase-js' +import { validateTurnstileToken } from 'next-turnstile' +import { v4 } from 'uuid'; import { createClient } from '@/utils/supabase/server' @@ -27,6 +29,27 @@ export type AuthActionState = | null export async function login(state: AuthActionState, payload: FormData) { + // Validate Turnstile token first + const token = payload.get('cf-turnstile-response'); + if (!token || typeof token !== 'string') { + return { error: 'Verification failed. Please try again.', user: null, redirectTo: null }; + } + + try { + const result = await validateTurnstileToken({ + token, + secretKey: process.env.TURNSTILE_SECRET_KEY!, + idempotencyKey: v4(), + }); + + if (!result.success) { + return { error: 'Verification failed. Please try again.', user: null, redirectTo: null }; + } + } catch (error) { + console.error('Turnstile validation error:', error); + return { error: 'Verification failed. Please try again.', user: null, redirectTo: null }; + } + const supabase = await createClient() const data = { diff --git a/src/app/signup/actions.ts b/src/app/signup/actions.ts index dc80321..4fbd9aa 100644 --- a/src/app/signup/actions.ts +++ b/src/app/signup/actions.ts @@ -3,6 +3,8 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { AuthError } from '@supabase/supabase-js' +import { validateTurnstileToken } from 'next-turnstile' +import { v4 } from 'uuid'; import { createClient } from '@/utils/supabase/server' import { validateEmail, validatePassword } from '@/utils/auth' @@ -32,6 +34,27 @@ export interface AuthActionState { } export async function signup(state: AuthActionState, payload: FormData) { + // Validate Turnstile token first + const token = payload.get('cf-turnstile-response'); + if (!token || typeof token !== 'string') { + return { error: 'Verification failed. Please try again.' }; + } + + try { + const result = await validateTurnstileToken({ + token, + secretKey: process.env.TURNSTILE_SECRET_KEY!, + idempotencyKey: v4(), + }); + + if (!result.success) { + return { error: 'Verification failed. Please try again.' }; + } + } catch (error) { + console.error('Turnstile validation error:', error); + return { error: 'Verification failed. Please try again.' }; + } + const supabase = await createClient() const data = { diff --git a/src/components/Auth/LoginForm.tsx b/src/components/Auth/LoginForm.tsx index a86415f..dd6a597 100644 --- a/src/components/Auth/LoginForm.tsx +++ b/src/components/Auth/LoginForm.tsx @@ -3,6 +3,7 @@ import React, { useActionState, useEffect} from "react"; import Link from "next/link"; import { FiEye, FiEyeOff } from "react-icons/fi"; +import { Turnstile } from "next-turnstile"; import { AuthActionState, login } from "@/app/login/actions"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuthContext } from "@/contexts/AuthContext"; @@ -13,6 +14,9 @@ export default function LoginForm() { const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [showPassword, setShowPassword] = React.useState(false); + const [turnstileToken, setTurnstileToken] = React.useState(undefined); + const [turnstileError, setTurnstileError] = React.useState(null); + const [turnstileKey, setTurnstileKey] = React.useState(0); const searchParams = useSearchParams(); const urlError = searchParams.get("error"); const [state, formAction] = useActionState(login, null); @@ -26,6 +30,16 @@ export default function LoginForm() { const passwordValid = password.length > 1; const isValid = emailValid && passwordValid; + // Reset Turnstile token and widget on error to allow retry + useEffect(() => { + if (state?.error && state.error !== null) { + setTurnstileToken(undefined); + setTurnstileError(null); + // Force Turnstile widget to reset by changing key + setTurnstileKey((prev) => prev + 1); + } + }, [state?.error]); + // Update context and immediately redirect after successful login useEffect(() => { if (state && state.error === null && !navigatedRef.current) { @@ -55,6 +69,11 @@ export default function LoginForm() { {errorMessage} )} + {turnstileError && ( +
+ {turnstileError} +
+ )}
)} + { + setTurnstileToken(token); + setTurnstileError(null); + }} + onError={(error) => { + setTurnstileToken(undefined); + setTurnstileError("Verification failed. Please try again."); + console.error("Turnstile error:", error); + }} + onExpire={() => { + setTurnstileToken(undefined); + }} + theme="auto" + />
)} + {turnstileError && ( +
+ {turnstileError} +
+ )}
+ { + setTurnstileToken(token); + setTurnstileError(null); + }} + onError={(error) => { + setTurnstileToken(undefined); + setTurnstileError("Verification failed. Please try again."); + console.error("Turnstile error:", error); + }} + onExpire={() => { + setTurnstileToken(undefined); + }} + theme="auto" + />