Turnstile protected sign-in, sign-up and password-reset, but three gaps remained: - updatePassword had no verification and its page had no widget, so the final step of the reset flow was unprotected. The reset token is now carried through the failure redirect so a failed challenge doesn't strand the user on a form whose emailed link can't be replayed. - /api/auth/[...all] exposed better-auth's handler directly, accepting unlimited credential guesses and email sends with no bot protection -- a full bypass of the page-level checks. Credential-bearing POSTs now require a verified token. The gate lives in the route handler, so the server actions (which call auth.api.* in-process) are unaffected. GET is untouched for OAuth callbacks and verify-email links, and /sign-in/social stays open since it only redirects to the provider. - Turnstile tokens expire after ~5 minutes and the widget never reset, so a form left open submitted a stale token and failed with "complete the verification challenge" despite the challenge visibly passing. Verified with Cloudflare's test keys: all four auth forms block an invalid token, pass a valid one through to real auth logic, and the API gate returns 403 without a token and 401 with one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
declare global {
|
|
interface Window {
|
|
turnstile?: {
|
|
render: (el: HTMLElement, opts: Record<string, unknown>) => string
|
|
remove: (id: string) => void
|
|
reset: (id?: string) => void
|
|
}
|
|
}
|
|
}
|
|
|
|
const SCRIPT_ID = "cf-turnstile-script"
|
|
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
|
|
|
/**
|
|
* Cloudflare Turnstile widget (explicit render).
|
|
*
|
|
* Renders inside the enclosing <form>; on success Turnstile injects a hidden
|
|
* <input name="cf-turnstile-response"> that is submitted with the form and
|
|
* verified server-side by verifyTurnstile(). Renders nothing when the public
|
|
* site key is not configured, so the forms still work without Turnstile.
|
|
*/
|
|
export function TurnstileWidget({ className }: { className?: string }) {
|
|
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const widgetIdRef = useRef<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!siteKey) return
|
|
let cancelled = false
|
|
|
|
const render = () => {
|
|
if (cancelled || widgetIdRef.current || !containerRef.current || !window.turnstile) return
|
|
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
|
sitekey: siteKey,
|
|
theme: "dark",
|
|
// A Turnstile token is only valid for ~5 minutes. Without these the
|
|
// widget goes quietly stale on a form left open, and the submit fails
|
|
// server-side with "complete the verification challenge" even though
|
|
// the challenge visibly passed. Re-running it keeps the hidden
|
|
// cf-turnstile-response input fresh.
|
|
"refresh-expired": "auto",
|
|
"expired-callback": () => {
|
|
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current)
|
|
},
|
|
"timeout-callback": () => {
|
|
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current)
|
|
},
|
|
"error-callback": () => {
|
|
// Returning false lets Turnstile surface its own error UI rather than
|
|
// leaving an empty box the user can't act on.
|
|
return false
|
|
},
|
|
})
|
|
}
|
|
|
|
if (!document.getElementById(SCRIPT_ID)) {
|
|
const script = document.createElement("script")
|
|
script.id = SCRIPT_ID
|
|
script.src = SCRIPT_SRC
|
|
script.async = true
|
|
script.defer = true
|
|
script.onload = render
|
|
document.head.appendChild(script)
|
|
} else {
|
|
render()
|
|
}
|
|
|
|
// Fallback: the script may already be cached/loaded so onload won't fire.
|
|
const poll = window.setInterval(() => {
|
|
if (window.turnstile) {
|
|
render()
|
|
window.clearInterval(poll)
|
|
}
|
|
}, 150)
|
|
|
|
return () => {
|
|
cancelled = true
|
|
window.clearInterval(poll)
|
|
if (widgetIdRef.current && window.turnstile) {
|
|
try {
|
|
window.turnstile.remove(widgetIdRef.current)
|
|
} catch {
|
|
// widget already removed
|
|
}
|
|
widgetIdRef.current = null
|
|
}
|
|
}
|
|
}, [siteKey])
|
|
|
|
if (!siteKey) return null
|
|
return <div ref={containerRef} className={cn("min-h-[65px]", className)} />
|
|
}
|