2 changed files with 86 additions and 0 deletions
@ -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); |
|||
}); |
|||
}); |
|||
@ -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…
Reference in new issue