From 4ce1002f683ebc3944201c57d0a02e9806aa4833 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 19 Apr 2026 00:46:38 +0800 Subject: [PATCH] feat(auth): in-memory captcha store with tests Made-with: Cursor --- server/service/captcha/store.test.ts | 31 ++++++++++++++++++++ server/service/captcha/store.ts | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 server/service/captcha/store.test.ts create mode 100644 server/service/captcha/store.ts diff --git a/server/service/captcha/store.test.ts b/server/service/captcha/store.test.ts new file mode 100644 index 0000000..4752bbf --- /dev/null +++ b/server/service/captcha/store.test.ts @@ -0,0 +1,31 @@ +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); + }); +}); diff --git a/server/service/captcha/store.ts b/server/service/captcha/store.ts new file mode 100644 index 0000000..18975f9 --- /dev/null +++ b/server/service/captcha/store.ts @@ -0,0 +1,55 @@ +import { randomUUID, 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` 须为规范形式(小写、无首尾空格) */ +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; +}