diff --git a/docs/superpowers/plans/2026-08-08-login-captcha-session-redirect.md b/docs/superpowers/plans/2026-08-08-login-captcha-session-redirect.md new file mode 100644 index 0000000..7b64983 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-login-captcha-session-redirect.md @@ -0,0 +1,873 @@ +# Login Captcha + Session-Expiry Redirect Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 会话过期(API 401)时自动跳转登录页;同一 IP 登录失败 3 次后要求数学算式验证码。 + +**Architecture:** 前端通过全局 fetch 拦截器捕获 401 并跳转 `/login?next=...`;验证码为无状态 HMAC 签名令牌(无需服务端存储);失败计数为按 IP 的内存 Map。 + +**Tech Stack:** Next.js App Router, React, Vitest, node:crypto (HMAC-SHA256)。 + +## Global Constraints + +- 测试命令:`npx vitest run `(package.json `"test": "vitest run"`)。 +- 服务端测试文件需 `vi.mock("server-only", () => ({}));`(参照 `src/app/api/auth/login/route.test.ts`)。 +- 认证环境变量参照现有测试:`WEBUI_AUTH_ENABLED=true`、`WEBUI_AUTH_SECRET`(≥32 字符)、`WEBUI_AUTH_USERNAME`、`WEBUI_AUTH_PASSWORD`、`EVOSCIENTIST_DATA_DIR`(临时目录)。 +- 登录失败阈值:3 次;验证码有效期:5 分钟;失败计数条目 TTL:15 分钟。 +- 设计文档:`docs/superpowers/specs/2026-08-08-login-captcha-session-redirect-design.md`。 + +--- + +### Task 1: 401 全局 fetch 拦截跳转 + +**Files:** +- Create: `src/lib/authRedirect.ts` +- Test: `src/lib/authRedirect.test.ts` +- Create: `src/app/components/AuthRedirectInstaller.tsx` +- Modify: `src/app/layout.tsx`(在 ThemeProvider 内挂载 installer) + +**Interfaces:** +- Produces: + - `shouldRedirectFor(url: string, status: number, pathname: string): boolean` + - `installAuthRedirect(): () => void`(返回卸载函数) + - `` 客户端组件(无 props,渲染 null) + +- [ ] **Step 1: Write the failing test** + +`src/lib/authRedirect.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { shouldRedirectFor } from "./authRedirect"; + +describe("shouldRedirectFor", () => { + it("redirects on 401 for same-origin API paths", () => { + expect(shouldRedirectFor("/api/conversations", 401, "/")).toBe(true); + expect( + shouldRedirectFor("http://localhost/api/usage/summary", 401, "/chat") + ).toBe(true); + }); + + it("ignores non-401 statuses", () => { + expect(shouldRedirectFor("/api/conversations", 200, "/")).toBe(false); + expect(shouldRedirectFor("/api/conversations", 500, "/")).toBe(false); + }); + + it("ignores non-API paths", () => { + expect(shouldRedirectFor("/login", 401, "/")).toBe(false); + expect(shouldRedirectFor("/favicon.ico", 401, "/")).toBe(false); + }); + + it("ignores the login endpoint itself", () => { + expect(shouldRedirectFor("/api/auth/login", 401, "/login")).toBe(false); + expect(shouldRedirectFor("/api/auth/login", 401, "/")).toBe(false); + }); + + it("ignores 401 while already on the login page", () => { + expect(shouldRedirectFor("/api/auth/me", 401, "/login")).toBe(false); + }); + + it("ignores unparseable urls", () => { + expect(shouldRedirectFor("http://[", 401, "/")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/authRedirect.test.ts` +Expected: FAIL — `Cannot find module './authRedirect'` + +- [ ] **Step 3: Write minimal implementation** + +`src/lib/authRedirect.ts`: + +```ts +export function shouldRedirectFor( + url: string, + status: number, + pathname: string +): boolean { + if (status !== 401) return false; + if (pathname.startsWith("/login")) return false; + let path: string; + try { + path = new URL(url, "http://localhost").pathname; + } catch { + return false; + } + if (!path.startsWith("/api/")) return false; + if (path === "/api/auth/login") return false; + return true; +} + +// Wraps window.fetch so any same-origin API 401 sends the browser to the +// login page, preserving the current location as ?next. Returns a restore +// function. Middleware only covers document navigations; fetch calls +// (health checks, chat, polling) would otherwise fail silently as "offline". +export function installAuthRedirect(): () => void { + const original = window.fetch; + const wrapped: typeof window.fetch = async (input, init) => { + const response = await original(input, init); + try { + const url = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : input.toString(); + const resolved = new URL(url, window.location.origin); + if ( + resolved.origin === window.location.origin && + shouldRedirectFor(url, response.status, window.location.pathname) + ) { + const next = encodeURIComponent( + window.location.pathname + window.location.search + ); + window.location.replace(`/login?next=${next}`); + } + } catch { + // Redirect failures must not break the caller's response handling. + } + return response; + }; + window.fetch = wrapped; + return () => { + window.fetch = original; + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/authRedirect.test.ts` +Expected: PASS (6 tests) + +- [ ] **Step 5: Mount the installer in the root layout** + +Create `src/app/components/AuthRedirectInstaller.tsx`: + +```tsx +"use client"; + +import { useEffect } from "react"; +import { installAuthRedirect } from "@/lib/authRedirect"; + +export function AuthRedirectInstaller() { + useEffect(() => installAuthRedirect(), []); + return null; +} +``` + +In `src/app/layout.tsx`, add the import and render inside `ThemeProvider`: + +```tsx +import { AuthRedirectInstaller } from "./components/AuthRedirectInstaller"; +// ... + + + {children} + + +``` + +- [ ] **Step 6: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/authRedirect.ts src/lib/authRedirect.test.ts src/app/components/AuthRedirectInstaller.tsx src/app/layout.tsx +git commit -m "feat(webui): redirect to /login on any API 401 response" +``` + +--- + +### Task 2: 数学算式验证码库 + +**Files:** +- Create: `src/lib/server/captcha.ts` +- Test: `src/lib/server/captcha.test.ts` + +**Interfaces:** +- Consumes: `authSecret()` from `@/lib/auth`(返回 `string | null`) +- Produces: + - `createCaptcha(): { id: string; question: string }` — `question` 形如 `"3 + 7 = ?"` + - `verifyCaptcha(id: unknown, answer: unknown): boolean` + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/captcha.test.ts`: + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; + +process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters"; + +const { createCaptcha, verifyCaptcha } = await import("./captcha"); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("captcha", () => { + it("round-trips: correct answer verifies", () => { + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?$/); + expect(match).not.toBeNull(); + const answer = String(Number(match![1]) + Number(match![2])); + expect(verifyCaptcha(id, answer)).toBe(true); + }); + + it("rejects a wrong answer", () => { + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?/)!; + const wrong = String(Number(match[1]) + Number(match[2]) + 1); + expect(verifyCaptcha(id, wrong)).toBe(false); + }); + + it("rejects a tampered payload", () => { + const { id } = createCaptcha(); + const [payload] = id.split("."); + const forged = `${payload}.${"a".repeat(43)}`; + expect(verifyCaptcha(forged, "2")).toBe(false); + }); + + it("rejects an expired captcha", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-08T00:00:00Z")); + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?/)!; + const answer = String(Number(match[1]) + Number(match[2])); + vi.setSystemTime(new Date("2026-08-08T00:06:00Z")); + expect(verifyCaptcha(id, answer)).toBe(false); + }); + + it("rejects malformed inputs", () => { + expect(verifyCaptcha(undefined, "2")).toBe(false); + expect(verifyCaptcha("not-a-token", "2")).toBe(false); + expect(verifyCaptcha(123, "2")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/captcha.test.ts` +Expected: FAIL — `Cannot find module './captcha'` + +- [ ] **Step 3: Write minimal implementation** + +`src/lib/server/captcha.ts`: + +```ts +import { createHmac, randomInt, timingSafeEqual } from "node:crypto"; +import { authSecret } from "@/lib/auth"; + +const CAPTCHA_TTL_MS = 5 * 60 * 1000; + +interface CaptchaPayload { + a: number; + b: number; + exp: number; +} + +function sign(encoded: string, secret: string): string { + return createHmac("sha256", secret).update(encoded).digest("base64url"); +} + +export function createCaptcha(): { id: string; question: string } { + const secret = authSecret(); + if (!secret) throw new Error("WEBUI_AUTH_SECRET is not configured."); + const a = randomInt(1, 10); + const b = randomInt(1, 10); + const payload: CaptchaPayload = { a, b, exp: Date.now() + CAPTCHA_TTL_MS }; + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return { + id: `${encoded}.${sign(encoded, secret)}`, + question: `${a} + ${b} = ?`, + }; +} + +export function verifyCaptcha(id: unknown, answer: unknown): boolean { + const secret = authSecret(); + if (!secret || typeof id !== "string" || typeof answer !== "string") { + return false; + } + const [encoded, signature] = id.split("."); + if (!encoded || !signature) return false; + const expected = Buffer.from(sign(encoded, secret)); + const provided = Buffer.from(signature); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return false; + } + let payload: Partial; + try { + payload = JSON.parse( + Buffer.from(encoded, "base64url").toString("utf8") + ) as Partial; + } catch { + return false; + } + if ( + typeof payload.a !== "number" || + typeof payload.b !== "number" || + typeof payload.exp !== "number" || + payload.exp < Date.now() + ) { + return false; + } + return answer.trim() === String(payload.a + payload.b); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/captcha.test.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/captcha.ts src/lib/server/captcha.test.ts +git commit -m "feat(webui): stateless signed math-captcha tokens" +``` + +--- + +### Task 3: 按 IP 的登录失败计数 + +**Files:** +- Create: `src/lib/server/loginFailures.ts` +- Test: `src/lib/server/loginFailures.test.ts` + +**Interfaces:** +- Produces: + - `FAILURE_THRESHOLD = 3`(导出常量) + - `recordFailure(ip: string): void` + - `shouldRequireCaptcha(ip: string): boolean` + - `clearFailures(ip: string): void` + - `clientIp(request: NextRequest): string` + - `clearAllFailuresForTests(): void` + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/loginFailures.test.ts`: + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { + clearAllFailuresForTests, + clearFailures, + clientIp, + FAILURE_THRESHOLD, + recordFailure, + shouldRequireCaptcha, +} from "./loginFailures"; + +afterEach(() => { + vi.useRealTimers(); + clearAllFailuresForTests(); +}); + +describe("loginFailures", () => { + it("requires captcha only after FAILURE_THRESHOLD failures", () => { + const ip = "1.2.3.4"; + for (let i = 0; i < FAILURE_THRESHOLD - 1; i++) recordFailure(ip); + expect(shouldRequireCaptcha(ip)).toBe(false); + recordFailure(ip); + expect(shouldRequireCaptcha(ip)).toBe(true); + }); + + it("tracks IPs independently", () => { + for (let i = 0; i < FAILURE_THRESHOLD; i++) recordFailure("1.1.1.1"); + expect(shouldRequireCaptcha("1.1.1.1")).toBe(true); + expect(shouldRequireCaptcha("2.2.2.2")).toBe(false); + }); + + it("clearFailures resets the counter", () => { + const ip = "3.3.3.3"; + for (let i = 0; i < FAILURE_THRESHOLD; i++) recordFailure(ip); + clearFailures(ip); + expect(shouldRequireCaptcha(ip)).toBe(false); + }); + + it("expires entries after 15 minutes idle", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-08T00:00:00Z")); + const ip = "4.4.4.4"; + for (let i = 0; i < FAILURE_THRESHOLD; i++) recordFailure(ip); + expect(shouldRequireCaptcha(ip)).toBe(true); + vi.setSystemTime(new Date("2026-08-08T00:16:00Z")); + expect(shouldRequireCaptcha(ip)).toBe(false); + }); + + it("clientIp reads the first x-forwarded-for hop and falls back to unknown", () => { + const req = new NextRequest("http://localhost/api/auth/login", { + headers: { "x-forwarded-for": "9.9.9.9, 10.0.0.1" }, + }); + expect(clientIp(req)).toBe("9.9.9.9"); + const bare = new NextRequest("http://localhost/api/auth/login"); + expect(clientIp(bare)).toBe("unknown"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/loginFailures.test.ts` +Expected: FAIL — `Cannot find module './loginFailures'` + +- [ ] **Step 3: Write minimal implementation** + +`src/lib/server/loginFailures.ts`: + +```ts +import type { NextRequest } from "next/server"; + +export const FAILURE_THRESHOLD = 3; +const ENTRY_TTL_MS = 15 * 60 * 1000; + +interface Entry { + count: number; + touchedAt: number; +} + +// In-memory, per-process: counts reset on restart, which is an accepted +// trade-off (single-instance deployment). +const entries = new Map(); + +function prune(): void { + const now = Date.now(); + for (const [ip, entry] of entries) { + if (now - entry.touchedAt > ENTRY_TTL_MS) entries.delete(ip); + } +} + +export function recordFailure(ip: string): void { + prune(); + const existing = entries.get(ip); + entries.set(ip, { count: (existing?.count ?? 0) + 1, touchedAt: Date.now() }); +} + +export function shouldRequireCaptcha(ip: string): boolean { + const entry = entries.get(ip); + if (!entry) return false; + if (Date.now() - entry.touchedAt > ENTRY_TTL_MS) { + entries.delete(ip); + return false; + } + return entry.count >= FAILURE_THRESHOLD; +} + +export function clearFailures(ip: string): void { + entries.delete(ip); +} + +export function clientIp(request: NextRequest): string { + const forwarded = request.headers.get("x-forwarded-for"); + const first = forwarded?.split(",")[0]?.trim(); + return first || "unknown"; +} + +export function clearAllFailuresForTests(): void { + entries.clear(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/loginFailures.test.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/loginFailures.ts src/lib/server/loginFailures.test.ts +git commit -m "feat(webui): per-IP login failure counter" +``` + +--- + +### Task 4: 验证码下发接口 + +**Files:** +- Create: `src/app/api/auth/captcha/route.ts` +- Modify: `src/proxy.ts:75-87`(`isPublicPath` 增加 `/api/auth/captcha`) + +**Interfaces:** +- Consumes: `createCaptcha()` from `@/lib/server/captcha`(Task 2);`isAuthenticationEnabled()` from `@/lib/auth` +- Produces: `GET /api/auth/captcha` → `200 { id: string; question: string }`,`Cache-Control: no-store`;未启用认证时 404 + +- [ ] **Step 1: Create the route** + +`src/app/api/auth/captcha/route.ts`: + +```ts +import { NextResponse } from "next/server"; +import { isAuthenticationEnabled } from "@/lib/auth"; +import { createCaptcha } from "@/lib/server/captcha"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + if (!isAuthenticationEnabled()) { + return NextResponse.json( + { error: "WebUI authentication is disabled." }, + { status: 404, headers: { "Cache-Control": "no-store" } } + ); + } + const { id, question } = createCaptcha(); + return NextResponse.json( + { id, question }, + { headers: { "Cache-Control": "no-store" } } + ); +} +``` + +- [ ] **Step 2: Allow unauthenticated access in the middleware** + +In `src/proxy.ts`, add to `isPublicPath`: + +```ts +pathname === "/api/auth/captcha" || +``` + +放在 `pathname === "/api/auth/login" ||` 之后。 + +- [ ] **Step 3: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/auth/captcha/route.ts src/proxy.ts +git commit -m "feat(webui): GET /api/auth/captcha issues math captchas" +``` + +--- + +### Task 5: 登录路由集成验证码与失败计数 + +**Files:** +- Modify: `src/app/api/auth/login/route.ts` +- Test: `src/app/api/auth/login/route.test.ts`(追加 describe 块) + +**Interfaces:** +- Consumes: Task 2 `verifyCaptcha`;Task 3 `clientIp`、`recordFailure`、`shouldRequireCaptcha`、`clearFailures`、`clearAllFailuresForTests` +- Produces: 登录响应 JSON 增加可选字段 `requiresCaptcha: boolean`(401 与验证码相关 400 时携带) + +- [ ] **Step 1: Write the failing test** + +在 `src/app/api/auth/login/route.test.ts` 顶部 import 区追加: + +```ts +const { clearAllFailuresForTests } = await import("@/lib/server/loginFailures"); +const { createCaptcha } = await import("@/lib/server/captcha"); +``` + +文件末尾追加: + +```ts +describe("POST /api/auth/login captcha", () => { + const IP = "203.0.113.10"; + + function ipLoginRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": IP, + }, + body: JSON.stringify(body), + }); + } + + async function failLogins(times: number) { + for (let i = 0; i < times; i++) { + const res = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + expect(res.status).toBe(401); + } + } + + it("does not require a captcha before the failure threshold", async () => { + clearAllFailuresForTests(); + const res = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.requiresCaptcha).toBe(false); + clearAllFailuresForTests(); + }); + + it("flags requiresCaptcha once the threshold is reached", async () => { + clearAllFailuresForTests(); + await failLogins(2); + const res = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.requiresCaptcha).toBe(true); + clearAllFailuresForTests(); + }); + + it("rejects login without a captcha once required", async () => { + clearAllFailuresForTests(); + await failLogins(3); + const res = await POST( + ipLoginRequest({ username: "admin", password: "bootstrap-password" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.requiresCaptcha).toBe(true); + clearAllFailuresForTests(); + }); + + it("rejects a wrong captcha answer", async () => { + clearAllFailuresForTests(); + await failLogins(3); + const { id } = createCaptcha(); + const res = await POST( + ipLoginRequest({ + username: "admin", + password: "bootstrap-password", + captchaId: id, + captchaAnswer: "0", + }) + ); + expect(res.status).toBe(400); + clearAllFailuresForTests(); + }); + + it("logs in with a correct captcha and clears the counter", async () => { + clearAllFailuresForTests(); + await failLogins(3); + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?/)!; + const res = await POST( + ipLoginRequest({ + username: "admin", + password: "bootstrap-password", + captchaId: id, + captchaAnswer: String(Number(match[1]) + Number(match[2])), + }) + ); + expect(res.status).toBe(200); + // Counter cleared: a fresh failure starts from zero again. + const again = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + const body = await again.json(); + expect(body.requiresCaptcha).toBe(false); + clearAllFailuresForTests(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/auth/login/route.test.ts` +Expected: FAIL — 新用例中 `requiresCaptcha` 断言失败(响应无此字段) + +- [ ] **Step 3: Implement the login route changes** + +在 `src/app/api/auth/login/route.ts` 顶部 import 追加: + +```ts +import { verifyCaptcha } from "@/lib/server/captcha"; +import { + clearFailures, + clientIp, + recordFailure, + shouldRequireCaptcha, +} from "@/lib/server/loginFailures"; +``` + +请求体类型与解析追加(在 `rememberMe?: unknown;` 之后): + +```ts + captchaId?: unknown; + captchaAnswer?: unknown; +``` + +在 `try {` 之前插入验证码门槛逻辑: + +```ts + const ip = clientIp(request); + if (shouldRequireCaptcha(ip)) { + if (!verifyCaptcha(body?.captchaId, body?.captchaAnswer)) { + recordFailure(ip); + return NextResponse.json( + { error: "Incorrect verification code.", requiresCaptcha: true }, + { status: 400, headers: NO_STORE } + ); + } + } +``` + +修改凭据失败分支(`if (!user)`): + +```ts + const user = verifyCredentials(username, password); + if (!user) { + recordFailure(ip); + return NextResponse.json( + { + error: "Invalid username or password.", + requiresCaptcha: shouldRequireCaptcha(ip), + }, + { status: 401, headers: NO_STORE } + ); + } + clearFailures(ip); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/app/api/auth/login/route.test.ts` +Expected: PASS(原有 3 个 + 新增 5 个) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/auth/login/route.ts src/app/api/auth/login/route.test.ts +git commit -m "feat(webui): require math captcha after 3 failed logins per IP" +``` + +--- + +### Task 6: 登录页验证码 UI + +**Files:** +- Modify: `src/app/login/page.tsx` + +**Interfaces:** +- Consumes: `GET /api/auth/captcha`(Task 4);登录响应 `requiresCaptcha`(Task 5) +- Produces: 无导出(页面组件) + +- [ ] **Step 1: Add captcha state and fetch helper** + +在组件内既有 state 之后追加: + +```tsx + const [captchaRequired, setCaptchaRequired] = useState(false); + const [captchaId, setCaptchaId] = useState(""); + const [captchaQuestion, setCaptchaQuestion] = useState(""); + const [captchaAnswer, setCaptchaAnswer] = useState(""); +``` + +组件内追加刷新函数: + +```tsx + const refreshCaptcha = async () => { + setCaptchaAnswer(""); + const response = await fetch("/api/auth/captcha", { cache: "no-store" }); + if (!response.ok) return; + const body = (await response.json()) as { id?: string; question?: string }; + if (body.id && body.question) { + setCaptchaId(body.id); + setCaptchaQuestion(body.question); + } + }; +``` + +- [ ] **Step 2: Wire into the submit flow** + +响应类型追加字段: + +```ts + const body = (await response.json().catch(() => ({}))) as { + error?: string; + redirectTo?: string; + requiresCaptcha?: boolean; + }; +``` + +在错误处理前追加验证码门槛处理(`if (!response.ok) throw ...` 替换为): + +```ts + if (!response.ok) { + if (body.requiresCaptcha) { + setCaptchaRequired(true); + await refreshCaptcha(); + } + throw new Error(body.error || "Unable to sign in."); + } +``` + +请求体追加(`body: JSON.stringify({...})`): + +```ts + body: JSON.stringify({ + username, + password, + next: returnPath(), + rememberMe, + ...(captchaRequired ? { captchaId, captchaAnswer } : {}), + }), +``` + +- [ ] **Step 3: Render the captcha block** + +在 remember-me `` 之后追加: + +```tsx + {captchaRequired && ( + + )} +``` + +- [ ] **Step 4: Manual verification** + +Run: `npm run dev`,用错误密码连续登录 3 次(同一浏览器),确认第 3 次失败后页面出现算式验证码区块、"New question" 可换题、错误答案报 "Incorrect verification code."、正确答案+正确密码登录成功。 + +- [ ] **Step 5: Commit** + +```bash +git add src/app/login/page.tsx +git commit -m "feat(webui): captcha input on login page after repeated failures" +``` + +--- + +### Task 7: 全量回归 + +- [ ] **Step 1: Run the full test suite** + +Run: `npm test` +Expected: 全部通过 + +- [ ] **Step 2: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors