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