From cea3acac4a71d6bf3c4dfb24a34fc0db3148763b Mon Sep 17 00:00:00 2001 From: thanabartbb <326990420+thanabartbb@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:15:07 +0700 Subject: [PATCH] feat(auth): Cloudflare Turnstile on password login/register with server-side Siteverify --- .dev.vars.example | 4 ++ docs/reference/config-env.md | 3 ++ src/app/api/auth/password/route.ts | 8 +++ src/app/login/page.tsx | 5 +- src/components/login-form.tsx | 16 +++++- src/components/turnstile-widget.tsx | 80 +++++++++++++++++++++++++++++ src/lib/auth/turnstile.ts | 73 ++++++++++++++++++++++++++ src/lib/i18n.ts | 2 + src/lib/security-headers.ts | 5 +- tests/production.test.ts | 2 +- tests/turnstile.test.ts | 48 +++++++++++++++++ wrangler.jsonc | 3 ++ 12 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 src/components/turnstile-widget.tsx create mode 100644 src/lib/auth/turnstile.ts create mode 100644 tests/turnstile.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index 380c311..67aeb11 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -20,3 +20,7 @@ CURSOR_APP_ID= # Vercel AI Gateway (model picker on /chat). AI_GATEWAY_API_KEY= # AI_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1 +# Cloudflare Turnstile (/login). Local dev uses Cloudflare's documented test keys (always pass): +TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA +# TURNSTILE_HOSTNAMES=devstack.bid diff --git a/docs/reference/config-env.md b/docs/reference/config-env.md index ea0afd1..62ee62d 100644 --- a/docs/reference/config-env.md +++ b/docs/reference/config-env.md @@ -22,6 +22,9 @@ | `OPENAI_FALLBACK` | var | `true` / `false` | `false` | `false` | ให้ planner ถอยไปใช้ OpenAI key ของเซิร์ฟเวอร์เมื่อ HF ล้มหรือไม่ (ปิดไว้เพื่อสงวนเครดิตให้ Orchestrator) | | `GITHUB_CLIENT_ID` | **secret** | `^[A-Za-z0-9.]{8,80}$` | ไม่มี → ปุ่ม GitHub พากลับ `/login?auth=missing&provider=github` | `wrangler secret put` (ไม่ใส่ใน `vars`) | OAuth App id สำหรับเข้าสู่ระบบ GitHub | | `GITHUB_CLIENT_SECRET` | **secret** | `^[\w-]{16,200}$` | ไม่มี → เข้าสู่ระบบ GitHub ปิด | `wrangler secret put` (stdin) | OAuth App secret ใช้แลก code ครั้งเดียว ไม่ถูกเก็บใน D1 | +| `TURNSTILE_SECRET_KEY` | **secret** | ข้อความ (Turnstile secret) | ไม่มี → ปิดการตรวจ Turnstile บน /login (ฟอร์มยังใช้ได้) | `wrangler secret put` (stdin) | ใช้เรียก Siteverify สำหรับฟอร์มรหัสผ่าน (login/register); dev ใช้ test secret `1x0000000000000000000000000000000AA` | +| `TURNSTILE_SITE_KEY` | var (public) | `0x…` | ไม่มี → ไม่แสดง widget | `wrangler.jsonc` vars | site key สาธารณะของ widget; dev ใช้ `1x00000000000000000000AA` | +| `TURNSTILE_HOSTNAMES` | var | รายการคั่นด้วย `,` | ว่าง + secret จริง → ปฏิเสธทุกคำขอ | `wrangler.jsonc` vars | hostname ที่ Siteverify ต้องตอบกลับ (prod: `devstack.bid`); ไม่ใส่ localhost ใน prod | | `GOOGLE_CLIENT_ID` | **secret** | `^[A-Za-z0-9][A-Za-z0-9._-]{7,180}$` | ไม่มี → ปุ่ม Google พากลับ `/login?auth=missing&provider=google` | `wrangler secret put` (stdin) | OAuth client id callback `{origin}/api/auth/google/callback` | | `GOOGLE_CLIENT_SECRET` | **secret** | `^[\w-]{16,200}$` | ไม่มี → เข้าสู่ระบบ Google ปิด | `wrangler secret put` (stdin) | ใช้แลก code ครั้งเดียว ไม่ถูกเก็บใน D1 | | `CURSOR_APP_ID` | **secret** | `^app_[A-Za-z0-9]{6,80}$` | ไม่มี → ปุ่ม Cursor พากลับ `/login?auth=missing&provider=cursor` | `wrangler secret put` (stdin) | รหัสแอป Origin ที่เป็นสาธารณะ ไม่เก็บ private key ตรวจใบเสร็จจาก JWKS ของ Origin บน production ตั้งแล้วที่เวอร์ชัน `ffbd53ae-957b-4b48-b411-3a882f14f8ae` (`app_01m4xk8788f7s941zg4cpjxcer`) | diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 4755525..1a9a11a 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -10,6 +10,7 @@ import { chatRuntime, json } from "@/lib/chat/runtime"; import { findPasswordAccount, savePasswordAccount } from "@/lib/chat/store"; import { clientIp } from "@/lib/server/http"; import { checkRateLimit } from "@/lib/server/rate-limit"; +import { verifyTurnstile } from "@/lib/auth/turnstile"; function back(origin: string, secure: boolean, code: string, next: string) { const headers = new Headers({ @@ -54,6 +55,13 @@ export async function POST(req: Request) { const password = acceptPassword(typeof passwordField === "string" ? passwordField : ""); if (!email) return back(url.origin, secure, "invalid", dest); if (!password) return back(url.origin, secure, "weak", dest); + // Bot check after the cheap input checks (no Siteverify call for malformed posts) and before the + // per-account limiter, so bots cannot burn a real user's per-email attempts. + const human = await verifyTurnstile(form.get("cf-turnstile-response"), req.headers.get("cf-connecting-ip")); + if (!human.ok) { + console.warn(JSON.stringify({ at: "api/auth/password", event: "turnstile-failed", reason: human.reason })); + return back(url.origin, secure, "captcha", dest); + } // Per-account throttle on top of the per-IP burst, so one email cannot be guessed from many IPs. const perAccount = await checkRateLimit("NVX_CHAT_LIMITER", `pwacct:${await emailKey(email)}`, 5); if (!perAccount.ok) return back(url.origin, secure, "error", dest); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2fc54dd..c675bdd 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -3,13 +3,14 @@ import type { Metadata } from "next"; import { LoginForm } from "@/components/login-form"; import { safeNext } from "@/lib/auth/github-policy"; import { isAuthProvider } from "@/lib/auth/oauth-flow"; +import { turnstileSiteKey } from "@/lib/auth/turnstile"; export const metadata: Metadata = { title: "Sign in", description: "Sign in to DevStack with Cursor, Google, GitHub, or a DevStack password so chat history stays on your account.", }; -const NOTICES = new Set(["missing", "error", "denied", "invalid", "taken", "weak"]); +const NOTICES = new Set(["missing", "error", "denied", "invalid", "taken", "weak", "captcha"]); type LoginQuery = { next?: string | string[]; auth?: string | string[]; provider?: string | string[] }; @@ -26,5 +27,5 @@ async function LoginFromQuery({ searchParams }: { searchParams: Promise; + return ; } diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 25cb53a..96e97d7 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -4,6 +4,10 @@ import { useEffect, useState } from "react"; import { buttonClasses, Field, Input, Spinner } from "@/components/ui"; import type { DictKey } from "@/lib/i18n"; import { useLang } from "./prefs"; +import { TurnstileWidget } from "./turnstile-widget"; + +/** Must match TURNSTILE_ACTION in src/lib/auth/turnstile.ts (checked by Siteverify). */ +export const TURNSTILE_ACTION = "password_auth"; const PROVIDERS = [ { id: "cursor", href: "cursor", label: "auth.withCursor" }, @@ -21,6 +25,7 @@ function notice(auth: string, provider: string): { title: DictKey; body: DictKey if (auth === "denied") return { title: "auth.deniedTitle", body: "auth.denied" }; if (auth === "invalid") return { title: "auth.invalidTitle", body: "auth.invalid" }; if (auth === "taken") return { title: "auth.takenTitle", body: "auth.taken" }; + if (auth === "captcha") return { title: "auth.captchaTitle", body: "auth.captcha" }; if (auth === "weak") return { title: "auth.weakTitle", body: "auth.weak" }; return null; } @@ -36,8 +41,9 @@ export function loginFormProblem(email: string, password: string): { field: "ema } /** Four sign-in methods: Cursor, Google, GitHub, and a DevStack email account. */ -export function LoginForm({ next, auth, provider }: { next: string; auth: string; provider: string }) { - const { t } = useLang(); +export function LoginForm({ next, auth, provider, turnstileSiteKey = "" }: { next: string; auth: string; provider: string; turnstileSiteKey?: string }) { + const { t, lang } = useLang(); + const [captcha, setCaptcha] = useState(null); const alert = notice(auth, provider); const [pending, setPending] = useState(null); const [problem, setProblem] = useState(null); @@ -92,6 +98,11 @@ export function LoginForm({ next, auth, provider }: { next: string; auth: string form.querySelector(`input[name="${issue.field}"]`)?.focus(); return; } + if (turnstileSiteKey && !captcha) { + e.preventDefault(); + setProblem("auth.captcha"); + return; + } setProblem(null); const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null; setPending(submitter?.value ?? "login"); @@ -112,6 +123,7 @@ export function LoginForm({ next, auth, provider }: { next: string; auth: string {(props) => } + {turnstileSiteKey && }