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>
74 lines
2.5 KiB
TypeScript
74 lines
2.5 KiB
TypeScript
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,
|
|
}: {
|
|
searchParams: Promise<{ error?: string; token?: string }>
|
|
}) {
|
|
const params = await searchParams
|
|
const error = params.error
|
|
const token = params.token ?? ""
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="mb-8 flex flex-col items-center">
|
|
<Logo size="lg" />
|
|
<h1 className="mt-6 text-2xl font-bold text-white">Set new password</h1>
|
|
<p className="mt-2 text-sm text-white/60">Choose a strong password</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
|
|
{error && (
|
|
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
|
{decodeURIComponent(error)}
|
|
</div>
|
|
)}
|
|
|
|
<form action={updatePassword} className="space-y-4">
|
|
<input type="hidden" name="token" value={token} />
|
|
<div>
|
|
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-white/70">
|
|
New password
|
|
</label>
|
|
<input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
required
|
|
minLength={8}
|
|
placeholder="Min 8 characters"
|
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
|
/>
|
|
</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]"
|
|
>
|
|
Update password
|
|
</button>
|
|
</form>
|
|
|
|
<p className="mt-6 text-center text-sm text-white/40">
|
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300">
|
|
Back to sign in
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|