Browse Source

feat(auth): in-memory captcha store with tests

Made-with: Cursor
main
npmrun 8 hours ago
parent
commit
4ce1002f68
  1. 31
      server/service/captcha/store.test.ts
  2. 55
      server/service/captcha/store.ts

31
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);
});
});

55
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<string, Entry>();
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;
}
Loading…
Cancel
Save