diff --git a/docs/superpowers/plans/2026-04-19-auth-captcha.md b/docs/superpowers/plans/2026-04-19-auth-captcha.md new file mode 100644 index 0000000..d4c90e8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-auth-captcha.md @@ -0,0 +1,609 @@ +# 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)后人工检查点。 + +你更倾向哪一种?