feat(auth): finish Turnstile coverage across every auth entry point
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
595a5e3e04
commit
8f90347659
@@ -1,7 +1,16 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { Logo } from "@/components/shared/logo"
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
|
||||
import { updatePassword } from "@/app/actions/auth"
|
||||
|
||||
// A password-reset form reached from a one-time emailed link — nothing here
|
||||
// should ever enter the index.
|
||||
export const metadata: Metadata = {
|
||||
title: "Set a new password",
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default async function UpdatePasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -43,6 +52,8 @@ export default async function UpdatePasswordPage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TurnstileWidget />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
|
||||
|
||||
+12
-1
@@ -131,15 +131,26 @@ export async function signOut() {
|
||||
export async function updatePassword(formData: FormData) {
|
||||
const password = formData.get("password") as string
|
||||
const token = formData.get("token") as string
|
||||
const captchaToken = formData.get("cf-turnstile-response") as string | null
|
||||
|
||||
if (!token) {
|
||||
redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`)
|
||||
}
|
||||
|
||||
const h = await headers()
|
||||
// Same bot protection as the other credential forms. The reset token is
|
||||
// carried through so a failed challenge doesn't strand the user on a form
|
||||
// whose link can't be replayed.
|
||||
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
|
||||
redirect(
|
||||
`/update-password?error=${encodeURIComponent(CAPTCHA_ERROR)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.resetPassword({
|
||||
body: { newPassword: password, token },
|
||||
headers: await headers(),
|
||||
headers: h,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Could not update password"
|
||||
|
||||
@@ -1,4 +1,66 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
import { verifyTurnstile } from "@/lib/turnstile"
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth)
|
||||
const handlers = toNextJsHandler(auth)
|
||||
|
||||
// The auth pages post to server actions, which call `auth.api.*` in-process and
|
||||
// run their own verifyTurnstile() check. This route is the *other* door into the
|
||||
// same endpoints — a direct HTTP POST — and without this gate it accepts
|
||||
// unlimited credential guesses and email sends with no bot protection at all.
|
||||
//
|
||||
// Only credential-bearing / email-triggering POSTs are gated. GET is untouched
|
||||
// (OAuth callbacks, verify-email links, get-session), and `/sign-in/social` is
|
||||
// left open because it only starts a redirect to the provider.
|
||||
const CAPTCHA_PROTECTED = new Set([
|
||||
"/sign-in/email",
|
||||
"/sign-up/email",
|
||||
"/request-password-reset",
|
||||
"/reset-password",
|
||||
"/send-verification-email",
|
||||
])
|
||||
|
||||
/**
|
||||
* Turnstile token from a header (preferred — leaves the body stream untouched)
|
||||
* or, for clients that submit it inline, from a cloned JSON body.
|
||||
*/
|
||||
async function captchaToken(request: Request): Promise<string | null> {
|
||||
const header =
|
||||
request.headers.get("x-captcha-response") ??
|
||||
request.headers.get("cf-turnstile-response")
|
||||
if (header) return header
|
||||
|
||||
try {
|
||||
const body = (await request.clone().json()) as Record<string, unknown>
|
||||
const inline = body?.["cf-turnstile-response"] ?? body?.captchaToken
|
||||
return typeof inline === "string" ? inline : null
|
||||
} catch {
|
||||
// Not JSON, or no body — treated as a missing token, which fails closed.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handlers.GET
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const path = new URL(request.url).pathname.replace(/^\/api\/auth/, "")
|
||||
|
||||
if (CAPTCHA_PROTECTED.has(path)) {
|
||||
const ok = await verifyTurnstile(
|
||||
await captchaToken(request),
|
||||
request.headers.get("x-forwarded-for")
|
||||
)
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Verification challenge required.",
|
||||
code: "CAPTCHA_VERIFICATION_FAILED",
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return handlers.POST(request)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user