# Auth Captcha 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:** 为 `POST /api/auth/login` 与 `POST /api/auth/register` 增加自建 SVG 图形验证码与基于内存的 IP 限流;新增 `GET /api/auth/captcha`;登录/注册页展示验证码与换一张。 **Architecture:** 使用 `svg-captcha` 生成 SVG 与答案,答案经规范化后存入进程内 `Map`(TTL 默认 3 分钟,校验成功或失败均删除 challenge)。限流复用现有 `assertUnderRateLimit`(`server/utils/simple-rate-limit.ts`)。客户端 IP 统一使用 `getRequestIP(event, { xForwardedFor: true }) ?? "unknown"`,与评论、管理接口一致。 **Tech Stack:** Nuxt 4 / Nitro、h3、`svg-captcha`、Bun `bun:test`、现有 `R.success` / `defineWrappedResponseHandler`。 **规格依据:** [2026-04-19-auth-captcha-design.md](../specs/2026-04-19-auth-captcha-design.md) --- ## File Structure Map | 路径 | 动作 | 职责 | |------|------|------| | `package.json` | Modify | 增加依赖 `svg-captcha` | | `server/service/captcha/store.ts` | Create | 内存 challenge 存储、`captchaCreate`、`captchaConsume` | | `server/service/captcha/store.test.ts` | Create | store 单元测试 | | `server/service/captcha/challenge.ts` | Create | 封装 `svg-captcha` + 调用 `captchaCreate`,导出 `createCaptchaChallenge()` | | `server/service/captcha/challenge.test.ts` | Create | 可选:对 `createCaptchaChallenge` 做轻量断言(含 `captchaId` 与非空 `imageSvg`) | | `server/service/captcha/validate-body.ts` | Create | `assertLoginRegisterCaptchaFieldsPresent`(缺少字段时 400) | | `server/api/auth/captcha.get.ts` | Create | 签发验证码 JSON + `Cache-Control: no-store` + 限流 | | `server/api/auth/login.post.ts` | Modify | 限流 → 读 body → 校验字段 → `captchaConsume` → `loginUser` | | `server/api/auth/register.post.ts` | Modify | 限流 → 读 body → 校验字段 → `captchaConsume` → `allowRegister` → `registerUser` | | `server/utils/auth-api-routes.ts` | Modify | allowlist 增加 `GET /api/auth/captcha` | | `app/pages/login/index.vue` | Modify | 拉取/刷新验证码、表单项、`img` data URL | | `app/pages/register/index.vue` | Modify | 同上 | --- ### Task 1: 添加依赖 `svg-captcha` **Files:** - Modify: `package.json`(`dependencies`) - [ ] **Step 1: 安装依赖** 在项目根目录执行: ```bash bun add svg-captcha ``` 预期:`package.json` 出现 `"svg-captcha"` 条目且无安装错误。 - [ ] **Step 2: 确认类型** 运行: ```bash bunx tsc --noEmit -p server 2>/dev/null || npx nuxt prepare ``` 若 Nuxt 工程无独立 `server` tsconfig,可仅运行 `bun run postinstall`(即 `nuxt prepare`)。预期:无与 `svg-captcha` 导入相关的报错(该包自带类型声明)。 - [ ] **Step 3: Commit** ```bash git add package.json bun.lock git commit -m "chore: add svg-captcha for auth captcha" ``` --- ### Task 2: 实现验证码内存存储与单元测试 **Files:** - Create: `server/service/captcha/store.ts` - Create: `server/service/captcha/store.test.ts` - [ ] **Step 1: 编写会失败的测试(行为约定)** 创建 `server/service/captcha/store.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { captchaConsume, captchaCreate } from "./store"; describe("captcha store", () => { test("consume succeeds once then fails", () => { const { captchaId } = captchaCreate("ab12"); expect(captchaConsume(captchaId, "ab12")).toBe(true); expect(captchaConsume(captchaId, "ab12")).toBe(false); }); test("wrong answer invalidates challenge", () => { const { captchaId } = captchaCreate("ab12"); expect(captchaConsume(captchaId, "xxxx")).toBe(false); expect(captchaConsume(captchaId, "ab12")).toBe(false); }); test("trim + lowercase on user input", () => { const { captchaId } = captchaCreate("ab12"); expect(captchaConsume(captchaId, " AB12 ")).toBe(true); }); test("length mismatch fails without throwing", () => { const { captchaId } = captchaCreate("ab12"); expect(captchaConsume(captchaId, "x")).toBe(false); }); test("expired challenge fails", () => { const { captchaId } = captchaCreate("ab12", { ttlMs: -1000 }); expect(captchaConsume(captchaId, "ab12")).toBe(false); }); }); ``` - [ ] **Step 2: 运行测试确认失败** ```bash bun test server/service/captcha/store.test.ts ``` 预期:导入/函数未定义类失败。 - [ ] **Step 3: 实现 `store.ts`** 创建 `server/service/captcha/store.ts`: ```typescript import { randomUUID } from "node:crypto"; import { timingSafeEqual } from "node:crypto"; const DEFAULT_TTL_MS = 180_000; type Entry = { answerBuf: Buffer; expiresAt: number }; const store = new Map(); function sweepExpired(now: number) { for (const [id, e] of store) { if (now > e.expiresAt) { store.delete(id); } } } /** `answerNormalized` 必须已是规范形式(小写、无首尾空格),与 svg-captcha 生成结果一致 */ export function captchaCreate( answerNormalized: string, options?: { ttlMs?: number }, ): { captchaId: string } { const now = Date.now(); sweepExpired(now); const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; const captchaId = randomUUID(); store.set(captchaId, { answerBuf: Buffer.from(answerNormalized, "utf8"), expiresAt: now + ttlMs, }); return { captchaId }; } /** 成功或失败均删除 challenge(含答案错误、过期),与规格一致 */ export function captchaConsume(captchaId: unknown, rawAnswer: unknown): boolean { if (typeof captchaId !== "string" || typeof rawAnswer !== "string") { return false; } const now = Date.now(); sweepExpired(now); const entry = store.get(captchaId); if (!entry || now > entry.expiresAt) { if (entry) { store.delete(captchaId); } return false; } const guessBuf = Buffer.from(rawAnswer.trim().toLowerCase(), "utf8"); if (guessBuf.length !== entry.answerBuf.length) { store.delete(captchaId); return false; } const ok = timingSafeEqual(guessBuf, entry.answerBuf); store.delete(captchaId); return ok; } ``` - [ ] **Step 4: 运行测试确认通过** ```bash bun test server/service/captcha/store.test.ts ``` 预期:全部 PASS。 - [ ] **Step 5: Commit** ```bash git add server/service/captcha/store.ts server/service/captcha/store.test.ts git commit -m "feat(auth): in-memory captcha store with tests" ``` --- ### Task 3: 封装 `createCaptchaChallenge` **Files:** - Create: `server/service/captcha/challenge.ts` - Create: `server/service/captcha/challenge.test.ts` - [ ] **Step 1: 编写测试** 创建 `server/service/captcha/challenge.test.ts`(不解析 SVG 文本,避免随 `svg-captcha` 版本脆断;答案消费路径由 `store.test.ts` 覆盖): ```typescript import { describe, expect, test } from "bun:test"; import { createCaptchaChallenge } from "./challenge"; describe("createCaptchaChallenge", () => { test("returns captchaId and non-empty svg", () => { const { captchaId, imageSvg } = createCaptchaChallenge(); expect(captchaId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); expect(imageSvg.length).toBeGreaterThan(50); expect(imageSvg.includes("svg")).toBe(true); }); }); ``` - [ ] **Step 2: 运行测试确认失败** ```bash bun test server/service/captcha/challenge.test.ts ``` 预期:导入失败。 - [ ] **Step 3: 实现 `challenge.ts`** 创建 `server/service/captcha/challenge.ts`: ```typescript import svgCaptcha from "svg-captcha"; import { captchaCreate } from "./store"; /** 与规格一致:易混淆字符已剔除,长度 5 */ const CHAR_PRESET = "abcdefghjkmnpqrstuvwxyz23456789"; export function createCaptchaChallenge(): { captchaId: string; imageSvg: string } { const { data: imageSvg, text } = svgCaptcha.create({ size: 5, noise: 2, color: true, charPreset: CHAR_PRESET, background: "#f4f4f5", }); const answerNormalized = text.toLowerCase(); const { captchaId } = captchaCreate(answerNormalized); return { captchaId, imageSvg }; } ``` - [ ] **Step 4: 运行测试** ```bash bun test server/service/captcha/challenge.test.ts ``` 预期:PASS。 - [ ] **Step 5: Commit** ```bash git add server/service/captcha/challenge.ts server/service/captcha/challenge.test.ts git commit -m "feat(auth): svg captcha challenge factory" ``` --- ### Task 4: 请求体验证辅助函数 **Files:** - Create: `server/service/captcha/validate-body.ts` - [ ] **Step 1: 实现** 创建 `server/service/captcha/validate-body.ts`: ```typescript export type AuthCredentialsAndCaptcha = { username: string; password: string; captchaId: string; captchaAnswer: string; }; export function assertLoginRegisterCaptchaFieldsPresent( body: unknown, ): asserts body is AuthCredentialsAndCaptcha { if (typeof body !== "object" || body === null) { throw createError({ statusCode: 400, statusMessage: "无效请求" }); } const b = body as Record; if (typeof b.username !== "string" || typeof b.password !== "string") { throw createError({ statusCode: 400, statusMessage: "无效请求" }); } if (typeof b.captchaId !== "string" || b.captchaId.trim() === "") { throw createError({ statusCode: 400, statusMessage: "请完成验证码" }); } if (typeof b.captchaAnswer !== "string" || b.captchaAnswer.trim() === "") { throw createError({ statusCode: 400, statusMessage: "请完成验证码" }); } } ``` - [ ] **Step 2: Commit** ```bash git add server/service/captcha/validate-body.ts git commit -m "feat(auth): validate captcha fields on login/register body" ``` --- ### Task 5: `GET /api/auth/captcha` **Files:** - Create: `server/api/auth/captcha.get.ts` - Modify: `server/utils/auth-api-routes.ts` - [ ] **Step 1: 实现 handler** 创建 `server/api/auth/captcha.get.ts`: ```typescript import { getRequestIP } from "h3"; import { createCaptchaChallenge } from "#server/service/captcha/challenge"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; export default defineWrappedResponseHandler(async (event) => { setResponseHeader(event, "cache-control", "no-store"); const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; assertUnderRateLimit(`auth-captcha:${ip}`, 60, 60_000); const { captchaId, imageSvg } = createCaptchaChallenge(); return R.success({ captchaId, imageSvg }); }); ``` - [ ] **Step 2: 更新 allowlist** 在 `server/utils/auth-api-routes.ts` 的 `API_ALLOWLIST` 中增加一行(保持字母顺序可选): ```typescript { path: "/api/auth/captcha", methods: ["GET"] }, ``` - [ ] **Step 3: 手工验证(开发服务器)** 运行 `bun run dev`,请求: ```bash curl -sI "http://localhost:3000/api/auth/captcha" | tr -d '\r' | grep -i cache-control ``` 预期:响应头含 `cache-control: no-store`(大小写不敏感)。再: ```bash curl -s "http://localhost:3000/api/auth/captcha" ``` 预期:JSON 含 `captchaId` 与 `imageSvg`,且最外层为项目统一的 `R.success` 包装结构(与现有 API 一致)。 - [ ] **Step 4: Commit** ```bash git add server/api/auth/captcha.get.ts server/utils/auth-api-routes.ts git commit -m "feat(auth): GET /api/auth/captcha endpoint" ``` --- ### Task 6: 登录与注册 API 接入验证码 **Files:** - Modify: `server/api/auth/login.post.ts` - Modify: `server/api/auth/register.post.ts` - [ ] **Step 1: 修改 `login.post.ts` 完整替换为** ```typescript import { getRequestIP } from "h3"; import { loginUser } from "#server/service/auth"; import { toPublicAuthError } from "#server/service/auth/errors"; import { setSessionCookie } from "#server/service/auth/cookie"; import { captchaConsume } from "#server/service/captcha/store"; import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; export default defineWrappedResponseHandler(async (event) => { const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; assertUnderRateLimit(`auth-login:${ip}`, 30, 60_000); const body = await readBody(event); assertLoginRegisterCaptchaFieldsPresent(body); if (!captchaConsume(body.captchaId, body.captchaAnswer)) { throw createError({ statusCode: 400, statusMessage: "验证码错误或已过期,请重试", }); } try { const result = await loginUser({ username: body.username, password: body.password, }); setSessionCookie(event, result.sessionId); return R.success({ user: result.user, }); } catch (err) { throw toPublicAuthError(err); } }); ``` - [ ] **Step 2: 修改 `register.post.ts` 完整替换为** ```typescript import { getRequestIP } from "h3"; import { registerUser } from "#server/service/auth"; import { toPublicAuthError } from "#server/service/auth/errors"; import { captchaConsume } from "#server/service/captcha/store"; import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; export default defineWrappedResponseHandler(async (event) => { const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; assertUnderRateLimit(`auth-register:${ip}`, 20, 60_000); const body = await readBody(event); assertLoginRegisterCaptchaFieldsPresent(body); if (!captchaConsume(body.captchaId, body.captchaAnswer)) { throw createError({ statusCode: 400, statusMessage: "验证码错误或已过期,请重试", }); } const allowRegister = await event.context.config.getGlobal("allowRegister"); if (!allowRegister) { throw createError({ statusCode: 403, statusMessage: "当前已关闭注册", }); } try { const user = await registerUser({ username: body.username, password: body.password, }); return R.success({ user, }); } catch (err) { throw toPublicAuthError(err); } }); ``` - [ ] **Step 3: 运行单元测试回归** ```bash bun test server/service/auth/index.test.ts server/service/captcha/ ``` 预期:全部 PASS(auth 测试不经过 HTTP,行为不变)。 - [ ] **Step 4: Commit** ```bash git add server/api/auth/login.post.ts server/api/auth/register.post.ts git commit -m "feat(auth): require captcha on login and register" ``` --- ### Task 7: 前端登录与注册页 **Files:** - Modify: `app/pages/login/index.vue` - Modify: `app/pages/register/index.vue` - [ ] **Step 1: 登录页 — script 增量** 在 `LoginFormState` 中增加 `captchaAnswer: string`;增加 `captchaIdRef`、`captchaImageSrc` 两个 `ref('')`;增加方法: ```typescript const captchaIdRef = ref('') const captchaImageSrc = ref('') async function refreshCaptcha() { state.captchaAnswer = '' captchaImageSrc.value = '' const res = await fetchData<{ captchaId: string; imageSvg: string }>('/api/auth/captcha', { method: 'GET', notify: false, }) captchaIdRef.value = res.captchaId captchaImageSrc.value = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(res.imageSvg)}` } onMounted(() => { void refreshCaptcha() }) ``` 说明:`app/composables/useClientApi.ts` 中 `fetchData` 已对 `R.success` 做 `unwrapApiBody`,故 `res` 即为 `{ captchaId, imageSvg }`(与现有 `/api/auth/login` 用法一致)。 在 `onSubmit` 的 `body` 中增加: ```typescript captchaId: captchaIdRef.value, captchaAnswer: state.captchaAnswer, ``` 在 `catch` 与成功跳转前的失败路径:调用 `await refreshCaptcha()`(成功登录无需刷新)。建议在 `catch` 内与 `finally` 中根据是否报错刷新:至少在 `catch` 末尾 `void refreshCaptcha()`。 在 `validate` 中为 `captchaAnswer` 增加非空校验。 - [ ] **Step 2: 登录页 — template** 在密码字段与提交按钮之间增加: ```vue
验证码 换一张
``` - [ ] **Step 3: 注册页** 对 `register/index.vue` 做与登录页对称的修改(同一 `refreshCaptcha` 逻辑、`captchaIdRef`、`onMounted`、`onSubmit` body、`catch` 刷新、`validate`)。 - [ ] **Step 4: 手工验收** 启动 `bun run dev`:打开 `/login`、`/register`,确认验证码显示、换一张、错误验证码提示、正确验证码后登录/注册仍可用。 - [ ] **Step 5: Commit** ```bash git add app/pages/login/index.vue app/pages/register/index.vue git commit -m "feat(auth): captcha UI on login and register pages" ``` --- ## Plan Self-Review **1. Spec coverage** | 规格章节 | 对应 Task | |----------|-----------| | 内存 challenge、TTL、一次性消费 | Task 2 | | SVG 图形、`imageSvg` + data URL | Task 3、7 | | `GET /api/auth/captcha`、`no-store`、限流 | Task 5 | | 登录/注册 body 字段与顺序(先限流再验证码再业务) | Task 6 | | 注册关闭时先验证码再 403 | Task 6 `register.post.ts` | | allowlist | Task 5 | | 错误码 400/429/403 文案 | Task 4–6 | | IP 策略 | 全文使用 `getRequestIP` 与现有限流工具 | **2. Placeholder scan** 无 TBD/TODO;限流数字为明确常量,可在实现时抽到单文件但非必须。 **3. Type consistency** `AuthCredentialsAndCaptcha`、`readBody` 与 `loginUser`/`registerUser` 入参字段一致;验证码字段名全程 `captchaId` / `captchaAnswer`。 --- **Plan complete and saved to `docs/superpowers/plans/2026-04-19-auth-captcha.md`. Two execution options:** 1. **Subagent-Driven(推荐)** — 每个 Task 派生子代理并在 Task 之间做简短审查,迭代快。 2. **Inline Execution** — 在本会话中按 Task 顺序实现,在关键 Task(如 Task 6–7)后人工检查点。 你更倾向哪一种?