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 && }