You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

18 KiB

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/loginPOST /api/auth/register 增加自建 SVG 图形验证码与基于内存的 IP 限流;新增 GET /api/auth/captcha;登录/注册页展示验证码与换一张。

Architecture: 使用 svg-captcha 生成 SVG 与答案,答案经规范化后存入进程内 Map(TTL 默认 3 分钟,校验成功或失败均删除 challenge)。限流复用现有 assertUnderRateLimitserver/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


File Structure Map

路径 动作 职责
package.json Modify 增加依赖 svg-captcha
server/service/captcha/store.ts Create 内存 challenge 存储、captchaCreatecaptchaConsume
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 → 校验字段 → captchaConsumeloginUser
server/api/auth/register.post.ts Modify 限流 → 读 body → 校验字段 → captchaConsumeallowRegisterregisterUser
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.jsondependencies

  • Step 1: 安装依赖

在项目根目录执行:

bun add svg-captcha

预期:package.json 出现 "svg-captcha" 条目且无安装错误。

  • Step 2: 确认类型

运行:

bunx tsc --noEmit -p server 2>/dev/null || npx nuxt prepare

若 Nuxt 工程无独立 server tsconfig,可仅运行 bun run postinstall(即 nuxt prepare)。预期:无与 svg-captcha 导入相关的报错(该包自带类型声明)。

  • Step 3: Commit
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

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: 运行测试确认失败
bun test server/service/captcha/store.test.ts

预期:导入/函数未定义类失败。

  • Step 3: 实现 store.ts

创建 server/service/captcha/store.ts

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<string, Entry>();

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: 运行测试确认通过
bun test server/service/captcha/store.test.ts

预期:全部 PASS。

  • Step 5: Commit
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 覆盖):

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: 运行测试确认失败
bun test server/service/captcha/challenge.test.ts

预期:导入失败。

  • Step 3: 实现 challenge.ts

创建 server/service/captcha/challenge.ts

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: 运行测试
bun test server/service/captcha/challenge.test.ts

预期:PASS。

  • Step 5: Commit
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

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<string, unknown>;
  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
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

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.tsAPI_ALLOWLIST 中增加一行(保持字母顺序可选):

{ path: "/api/auth/captcha", methods: ["GET"] },
  • Step 3: 手工验证(开发服务器)

运行 bun run dev,请求:

curl -sI "http://localhost:3000/api/auth/captcha" | tr -d '\r' | grep -i cache-control

预期:响应头含 cache-control: no-store(大小写不敏感)。再:

curl -s "http://localhost:3000/api/auth/captcha"

预期:JSON 含 captchaIdimageSvg,且最外层为项目统一的 R.success 包装结构(与现有 API 一致)。

  • Step 4: Commit
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 完整替换为

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 完整替换为
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: 运行单元测试回归
bun test server/service/auth/index.test.ts server/service/captcha/

预期:全部 PASS(auth 测试不经过 HTTP,行为不变)。

  • Step 4: Commit
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;增加 captchaIdRefcaptchaImageSrc 两个 ref('');增加方法:

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.tsfetchData 已对 R.successunwrapApiBody,故 res 即为 { captchaId, imageSvg }(与现有 /api/auth/login 用法一致)。

onSubmitbody 中增加:

captchaId: captchaIdRef.value,
captchaAnswer: state.captchaAnswer,

catch 与成功跳转前的失败路径:调用 await refreshCaptcha()(成功登录无需刷新)。建议在 catch 内与 finally 中根据是否报错刷新:至少在 catch 末尾 void refreshCaptcha()

validate 中为 captchaAnswer 增加非空校验。

  • Step 2: 登录页 — template

在密码字段与提交按钮之间增加:

<UFormField label="验证码" name="captchaAnswer" required>
  <div class="flex gap-2 items-center">
    <img
      v-if="captchaImageSrc"
      :src="captchaImageSrc"
      alt="验证码"
      class="h-10 rounded border border-default bg-elevated"
    />
    <UInput
      v-model="state.captchaAnswer"
      placeholder="请输入图中字符"
      class="flex-1 min-w-0"
      autocomplete="off"
    />
    <UButton type="button" color="neutral" variant="outline" @click="refreshCaptcha">
      换一张
    </UButton>
  </div>
</UFormField>
  • Step 3: 注册页

register/index.vue 做与登录页对称的修改(同一 refreshCaptcha 逻辑、captchaIdRefonMountedonSubmit body、catch 刷新、validate)。

  • Step 4: 手工验收

启动 bun run dev:打开 /login/register,确认验证码显示、换一张、错误验证码提示、正确验证码后登录/注册仍可用。

  • Step 5: Commit
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/captchano-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

AuthCredentialsAndCaptchareadBodyloginUser/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)后人工检查点。

你更倾向哪一种?