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/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
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: 安装依赖
在项目根目录执行:
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.ts 的 API_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 含 captchaId 与 imageSvg,且最外层为项目统一的 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;增加 captchaIdRef、captchaImageSrc 两个 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.ts 中 fetchData 已对 R.success 做 unwrapApiBody,故 res 即为 { captchaId, imageSvg }(与现有 /api/auth/login 用法一致)。
在 onSubmit 的 body 中增加:
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 逻辑、captchaIdRef、onMounted、onSubmit 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/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:
- Subagent-Driven(推荐) — 每个 Task 派生子代理并在 Task 之间做简短审查,迭代快。
- Inline Execution — 在本会话中按 Task 顺序实现,在关键 Task(如 Task 6–7)后人工检查点。
你更倾向哪一种?