Add Cloudflare Turnstile for login/signup

This commit is contained in:
Jared Schoeny 2025-12-13 16:32:32 -10:00
parent c36b7c81df
commit 3becfbd1ad
6 changed files with 150 additions and 4 deletions

28
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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<string | undefined>(undefined);
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
const [turnstileKey, setTurnstileKey] = React.useState(0);
const searchParams = useSearchParams();
const urlError = searchParams.get("error");
const [state, formAction] = useActionState<AuthActionState, FormData>(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}
</div>
)}
{turnstileError && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{turnstileError}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
@ -117,10 +136,27 @@ export default function LoginForm() {
) : (
<div className="h-3" />
)}
<Turnstile
key={turnstileKey}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onVerify={(token) => {
setTurnstileToken(token);
setTurnstileError(null);
}}
onError={(error) => {
setTurnstileToken(undefined);
setTurnstileError("Verification failed. Please try again.");
console.error("Turnstile error:", error);
}}
onExpire={() => {
setTurnstileToken(undefined);
}}
theme="auto"
/>
<button
type="submit"
formAction={formAction}
disabled={!isValid}
disabled={!isValid || !turnstileToken}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Log in</span>

View File

@ -3,6 +3,7 @@
import React, { useActionState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { Turnstile } from "next-turnstile";
import { AuthActionState, signup } from "@/app/signup/actions";
import { useAuthContext } from "@/contexts/AuthContext";
import { validateEmail, validatePassword } from "@/utils/auth";
@ -17,11 +18,24 @@ export default function SignupForm() {
const [showPassword, setShowPassword] = React.useState(false);
const [emailError, setEmailError] = React.useState<string | null>(null);
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
const [turnstileKey, setTurnstileKey] = React.useState(0);
const [state, formAction, isPending] = useActionState<AuthActionState, FormData>(signup, { error: null });
const passwordsMatch = password === confirm;
const isValid = !emailError && !passwordError && passwordsMatch;
// Reset Turnstile token and widget on error to allow retry
useEffect(() => {
if (state?.error && !isPending) {
setTurnstileToken(undefined);
setTurnstileError(null);
// Force Turnstile widget to reset by changing key
setTurnstileKey((prev) => prev + 1);
}
}, [state?.error, isPending]);
useEffect(() => {
const { error } = validateEmail(email);
setEmailError(error);
@ -52,6 +66,11 @@ export default function SignupForm() {
{state?.error}
</div>
)}
{turnstileError && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{turnstileError}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
@ -138,10 +157,27 @@ export default function SignupForm() {
</div>
<div className="flex flex-col items-center gap-3">
<Turnstile
key={turnstileKey}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onVerify={(token) => {
setTurnstileToken(token);
setTurnstileError(null);
}}
onError={(error) => {
setTurnstileToken(undefined);
setTurnstileError("Verification failed. Please try again.");
console.error("Turnstile error:", error);
}}
onExpire={() => {
setTurnstileToken(undefined);
}}
theme="auto"
/>
<button
type="submit"
formAction={formAction}
disabled={!isValid || isPending}
disabled={!isValid || isPending || !turnstileToken}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Sign up</span>