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:
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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<TurnstileWidgetHandle>(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() {
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Send verification code
|
||||
</button>
|
||||
|
||||
<TurnstileWidget
|
||||
ref={turnstileRef}
|
||||
onToken={setTurnstileToken}
|
||||
onExpire={() => setTurnstileToken("")}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
loginSchema,
|
||||
} from "@/lib/auth/validation"
|
||||
import SocialButtons from "./social-buttons"
|
||||
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
@@ -44,8 +45,10 @@ export default function LoginForm() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [emailChecked, setEmailChecked] = useState(false)
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const mountedRef = useRef(true)
|
||||
const passkeyInitiatedRef = useRef(false)
|
||||
const turnstileRef = useRef<TurnstileWidgetHandle>(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() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{showPassword && (
|
||||
<TurnstileWidget
|
||||
ref={turnstileRef}
|
||||
onToken={setTurnstileToken}
|
||||
onExpire={() => {
|
||||
setTurnstileToken("")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { signupSchema } from "@/lib/auth/validation"
|
||||
import SocialButtons from "./social-buttons"
|
||||
import PasswordStrengthMeter from "./password-strength"
|
||||
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
|
||||
import Link from "next/link"
|
||||
|
||||
interface SignupFormStepProps {
|
||||
@@ -19,6 +20,8 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [serverError, setServerError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -41,6 +44,11 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-captcha-response": turnstileToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
||||
@@ -48,6 +56,8 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
|
||||
setServerError(
|
||||
error.message || "Something went wrong. Please try again.",
|
||||
)
|
||||
turnstileRef.current?.reset()
|
||||
setTurnstileToken("")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,13 +144,19 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create account
|
||||
</button>
|
||||
|
||||
<TurnstileWidget
|
||||
ref={turnstileRef}
|
||||
onToken={setTurnstileToken}
|
||||
onExpire={() => setTurnstileToken("")}
|
||||
/>
|
||||
|
||||
<p className="text-center text-sm text-text/50">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline cursor-pointer">
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user