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_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
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 { 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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user