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
This commit is contained in:
2026-06-26 21:50:45 +08:00
parent 7d78f1834d
commit ec5ee7fb18
6 changed files with 199 additions and 6 deletions
+9
View File
@@ -58,6 +58,15 @@ R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=deckyvault R2_BUCKET_NAME=deckyvault
R2_PUBLIC_URL= 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 # CRON
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+18 -2
View File
@@ -1,11 +1,12 @@
"use client" "use client"
import { useState } from "react" import { useState, useRef } from "react"
import { KeyRound, Loader2, ArrowLeft } from "lucide-react" import { KeyRound, Loader2, ArrowLeft } from "lucide-react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { import {
forgotPasswordSchema, forgotPasswordSchema,
} from "@/lib/auth/validation" } from "@/lib/auth/validation"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@@ -14,6 +15,8 @@ export default function ForgotPasswordForm() {
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [error, setError] = useState("") const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -28,6 +31,11 @@ export default function ForgotPasswordForm() {
setIsLoading(true) setIsLoading(true)
const { error } = await authClient.emailOtp.requestPasswordReset({ const { error } = await authClient.emailOtp.requestPasswordReset({
email, email,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
}) })
setIsLoading(false) setIsLoading(false)
@@ -35,6 +43,8 @@ export default function ForgotPasswordForm() {
setError( setError(
error.message || "Something went wrong. Please try again.", error.message || "Something went wrong. Please try again.",
) )
turnstileRef.current?.reset()
setTurnstileToken("")
return return
} }
@@ -76,13 +86,19 @@ export default function ForgotPasswordForm() {
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || !turnstileToken}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />} {isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Send verification code Send verification code
</button> </button>
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => setTurnstileToken("")}
/>
<Link <Link
href="/login" href="/login"
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer" className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
+25 -1
View File
@@ -8,6 +8,7 @@ import {
loginSchema, loginSchema,
} from "@/lib/auth/validation" } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons" import SocialButtons from "./social-buttons"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
@@ -44,8 +45,10 @@ export default function LoginForm() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [emailChecked, setEmailChecked] = useState(false) const [emailChecked, setEmailChecked] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const mountedRef = useRef(true) const mountedRef = useRef(true)
const passkeyInitiatedRef = useRef(false) const passkeyInitiatedRef = useRef(false)
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
// Redirect to the intended page after successful login // Redirect to the intended page after successful login
const handleLoginSuccess = useCallback(() => { const handleLoginSuccess = useCallback(() => {
@@ -126,11 +129,21 @@ export default function LoginForm() {
} }
setIsLoading(true) 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) setIsLoading(false)
if (error) { if (error) {
setError(error.message || "Invalid credentials. Please try again.") setError(error.message || "Invalid credentials. Please try again.")
turnstileRef.current?.reset()
setTurnstileToken("")
return return
} }
@@ -142,6 +155,8 @@ export default function LoginForm() {
setPassword("") setPassword("")
setError("") setError("")
setEmailChecked(false) setEmailChecked(false)
turnstileRef.current?.reset()
setTurnstileToken("")
} }
const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => { const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => {
@@ -282,6 +297,15 @@ export default function LoginForm() {
</Link> </Link>
</div> </div>
)} )}
{showPassword && (
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => {
setTurnstileToken("")
}}
/>
)}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
+18 -2
View File
@@ -1,11 +1,12 @@
"use client" "use client"
import { useState } from "react" import { useState, useRef } from "react"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { signupSchema } from "@/lib/auth/validation" import { signupSchema } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons" import SocialButtons from "./social-buttons"
import PasswordStrengthMeter from "./password-strength" import PasswordStrengthMeter from "./password-strength"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link" import Link from "next/link"
interface SignupFormStepProps { interface SignupFormStepProps {
@@ -19,6 +20,8 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [serverError, setServerError] = useState("") const [serverError, setServerError] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -41,6 +44,11 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
name, name,
email, email,
password, password,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
}) })
setIsLoading(false) setIsLoading(false)
@@ -48,6 +56,8 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
setServerError( setServerError(
error.message || "Something went wrong. Please try again.", error.message || "Something went wrong. Please try again.",
) )
turnstileRef.current?.reset()
setTurnstileToken("")
return return
} }
@@ -134,13 +144,19 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || !turnstileToken}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />} {isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Create account Create account
</button> </button>
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => setTurnstileToken("")}
/>
<p className="text-center text-sm text-text/50"> <p className="text-center text-sm text-text/50">
Already have an account?{" "} Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline cursor-pointer"> <Link href="/login" className="text-primary hover:underline cursor-pointer">
+124
View File
@@ -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<TurnstileWidgetHandle, TurnstileWidgetProps>(
function TurnstileWidget({ onToken, onExpire, onError, theme = "auto" }, ref) {
const containerRef = useRef<HTMLDivElement>(null)
const widgetIdRef = useRef<string | null>(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 <div ref={containerRef} id={`turnstile-container-${id}`} className="flex justify-center min-h-[65px]" />
},
)
+5 -1
View File
@@ -1,5 +1,5 @@
import { betterAuth } from 'better-auth' 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 { passkey } from '@better-auth/passkey'
import { expo } from '@better-auth/expo' import { expo } from '@better-auth/expo'
import { drizzleAdapter } from '@better-auth/drizzle-adapter' import { drizzleAdapter } from '@better-auth/drizzle-adapter'
@@ -38,6 +38,10 @@ export const auth = betterAuth({
provider: 'pg' provider: 'pg'
}), }),
plugins: [ plugins: [
captcha({
provider: 'cloudflare-turnstile',
secretKey: process.env.TURNSTILE_SECRET_KEY!,
}),
emailOTP({ emailOTP({
async sendVerificationOTP({ email, otp, type }) { async sendVerificationOTP({ email, otp, type }) {
await sendOTP({ email, otp, type }) await sendOTP({ email, otp, type })