From ec5ee7fb18533727311b9e994f1fb3a8358b4106 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Fri, 26 Jun 2026 21:50:45 +0800 Subject: [PATCH] feat: add Cloudflare Turnstile captcha to auth flow - Add captcha plugin to Better Auth config (cloudflare-turnstile provider) - Create reusable TurnstileWidget component with script injection - Integrate Turnstile into login, signup, and forgot-password forms - Pass x-captcha-response header on protected endpoints - Add TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY to env --- .env.example | 9 ++ components/auth/forgot-password-form.tsx | 20 +++- components/auth/login-form.tsx | 26 ++++- components/auth/signup-form-step.tsx | 20 +++- components/auth/turnstile-widget.tsx | 124 +++++++++++++++++++++++ lib/auth.ts | 6 +- 6 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 components/auth/turnstile-widget.tsx diff --git a/.env.example b/.env.example index c5bd741..26ee53c 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,15 @@ R2_SECRET_ACCESS_KEY= R2_BUCKET_NAME=deckyvault R2_PUBLIC_URL= +# ----------------------------------------------------------------------------- +# CAPTCHA (CLOUDFLARE TURNSTILE) +# ----------------------------------------------------------------------------- +# Cloudflare Turnstile site key (public, used in client-side widget) +NEXT_PUBLIC_TURNSTILE_SITE_KEY="0x4AAAAAADrebUvw0DbPkPSc" + +# Cloudflare Turnstile secret key (server-side verification) +TURNSTILE_SECRET_KEY="0x4AAAAAADrebeHpOUElKVG4bxd3EBbdBrk" + # ----------------------------------------------------------------------------- # CRON # ----------------------------------------------------------------------------- diff --git a/components/auth/forgot-password-form.tsx b/components/auth/forgot-password-form.tsx index 3c82547..262905e 100644 --- a/components/auth/forgot-password-form.tsx +++ b/components/auth/forgot-password-form.tsx @@ -1,11 +1,12 @@ "use client" -import { useState } from "react" +import { useState, useRef } from "react" import { KeyRound, Loader2, ArrowLeft } from "lucide-react" import { authClient } from "@/lib/auth-client" import { forgotPasswordSchema, } from "@/lib/auth/validation" +import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget" import Link from "next/link" import { useRouter } from "next/navigation" @@ -14,6 +15,8 @@ export default function ForgotPasswordForm() { const [email, setEmail] = useState("") const [error, setError] = useState("") const [isLoading, setIsLoading] = useState(false) + const [turnstileToken, setTurnstileToken] = useState("") + const turnstileRef = useRef(null) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -28,6 +31,11 @@ export default function ForgotPasswordForm() { setIsLoading(true) const { error } = await authClient.emailOtp.requestPasswordReset({ email, + fetchOptions: { + headers: { + "x-captcha-response": turnstileToken, + }, + }, }) setIsLoading(false) @@ -35,6 +43,8 @@ export default function ForgotPasswordForm() { setError( error.message || "Something went wrong. Please try again.", ) + turnstileRef.current?.reset() + setTurnstileToken("") return } @@ -76,13 +86,19 @@ export default function ForgotPasswordForm() { + setTurnstileToken("")} + /> + (null) // Redirect to the intended page after successful login const handleLoginSuccess = useCallback(() => { @@ -126,11 +129,21 @@ export default function LoginForm() { } setIsLoading(true) - const { error } = await authClient.signIn.email({ email, password }) + const { error } = await authClient.signIn.email({ + email, + password, + fetchOptions: { + headers: { + "x-captcha-response": turnstileToken, + }, + }, + }) setIsLoading(false) if (error) { setError(error.message || "Invalid credentials. Please try again.") + turnstileRef.current?.reset() + setTurnstileToken("") return } @@ -142,6 +155,8 @@ export default function LoginForm() { setPassword("") setError("") setEmailChecked(false) + turnstileRef.current?.reset() + setTurnstileToken("") } const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => { @@ -282,6 +297,15 @@ export default function LoginForm() { )} + {showPassword && ( + { + setTurnstileToken("") + }} + /> + )} + setTurnstileToken("")} + /> +

Already have an account?{" "} diff --git a/components/auth/turnstile-widget.tsx b/components/auth/turnstile-widget.tsx new file mode 100644 index 0000000..049c367 --- /dev/null +++ b/components/auth/turnstile-widget.tsx @@ -0,0 +1,124 @@ +"use client" + +import { useEffect, useRef, useImperativeHandle, forwardRef, useId } from "react" + +declare global { + interface Window { + turnstile?: { + render: (container: string | HTMLElement, options: TurnstileOptions) => string + reset: (widgetId: string) => void + remove: (widgetId: string) => void + getResponse: (widgetId: string) => string | undefined + } + onloadTurnstileCallback?: () => void + } +} + +interface TurnstileOptions { + sitekey: string + theme?: "light" | "dark" | "auto" + callback?: (token: string) => void + "expired-callback"?: () => void + "error-callback"?: () => void +} + +export interface TurnstileWidgetHandle { + reset: () => void + getToken: () => string | undefined +} + +interface TurnstileWidgetProps { + onToken: (token: string) => void + onExpire?: () => void + onError?: () => void + theme?: "light" | "dark" | "auto" +} + +const SCRIPT_ID = "cf-turnstile-script" +const SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback&render=explicit" + +export default forwardRef( + function TurnstileWidget({ onToken, onExpire, onError, theme = "auto" }, ref) { + const containerRef = useRef(null) + const widgetIdRef = useRef(null) + const scriptLoadedRef = useRef(false) + const id = useId() + + // Expose reset and getToken to parent + useImperativeHandle(ref, () => ({ + reset: () => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.reset(widgetIdRef.current) + } + }, + getToken: () => { + if (widgetIdRef.current && window.turnstile) { + return window.turnstile.getResponse(widgetIdRef.current) + } + return undefined + }, + })) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY + if (!siteKey) { + console.warn("[Turnstile] NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set") + return + } + + function renderWidget() { + if (!window.turnstile || !container || !siteKey) return + // Clear any previous content + container.innerHTML = "" + const widgetId = window.turnstile.render(container, { + sitekey: siteKey, + theme, + callback: (token: string) => onToken(token), + "expired-callback": () => { + widgetIdRef.current = null + onExpire?.() + }, + "error-callback": () => { + onError?.() + }, + }) + widgetIdRef.current = widgetId + scriptLoadedRef.current = true + } + + // If script is already loaded, render immediately + if (window.turnstile) { + renderWidget() + return + } + + // Set up the onload callback before adding the script + window.onloadTurnstileCallback = renderWidget + + // Avoid injecting the script twice + if (!document.getElementById(SCRIPT_ID)) { + const script = document.createElement("script") + script.id = SCRIPT_ID + script.src = SRC + script.async = true + script.defer = true + document.head.appendChild(script) + } + + return () => { + // Cleanup widget on unmount + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = null + } + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return

+ }, +) \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth.ts index 0a35e4a..83daae1 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,5 +1,5 @@ import { betterAuth } from 'better-auth' -import { admin, emailOTP, lastLoginMethod } from 'better-auth/plugins' +import { admin, captcha, emailOTP, lastLoginMethod } from 'better-auth/plugins' import { passkey } from '@better-auth/passkey' import { expo } from '@better-auth/expo' import { drizzleAdapter } from '@better-auth/drizzle-adapter' @@ -38,6 +38,10 @@ export const auth = betterAuth({ provider: 'pg' }), plugins: [ + captcha({ + provider: 'cloudflare-turnstile', + secretKey: process.env.TURNSTILE_SECRET_KEY!, + }), emailOTP({ async sendVerificationOTP({ email, otp, type }) { await sendOTP({ email, otp, type })