New Captcha Package added
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
|
||||
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
|
||||
return structuredClone(record);
|
||||
}
|
||||
|
||||
function cloneToken(record: CaptchaResponseTokenRecord): CaptchaResponseTokenRecord {
|
||||
return structuredClone(record);
|
||||
}
|
||||
|
||||
export interface MemoryCaptchaStoreOptions {
|
||||
maxChallenges?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export class MemoryCaptchaStore implements CaptchaStore {
|
||||
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
|
||||
private readonly tokens = new Map<string, CaptchaResponseTokenRecord>();
|
||||
private readonly maxChallenges: number;
|
||||
private readonly maxTokens: number;
|
||||
|
||||
constructor(options: MemoryCaptchaStoreOptions = {}) {
|
||||
this.maxChallenges = options.maxChallenges ?? 25_000;
|
||||
this.maxTokens = options.maxTokens ?? 50_000;
|
||||
if (!Number.isInteger(this.maxChallenges) || this.maxChallenges < 1) {
|
||||
throw new RangeError("maxChallenges must be a positive integer");
|
||||
}
|
||||
if (!Number.isInteger(this.maxTokens) || this.maxTokens < 1) {
|
||||
throw new RangeError("maxTokens must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
||||
this.evict(this.challenges, this.maxChallenges, record.createdAt);
|
||||
this.challenges.set(record.id, cloneChallenge(record));
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const record = this.challenges.get(id);
|
||||
return record ? cloneChallenge(record) : undefined;
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const record = this.challenges.get(id);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.attempts += 1;
|
||||
return cloneChallenge(record);
|
||||
}
|
||||
|
||||
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const record = this.challenges.get(id);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.consumedAt = now;
|
||||
return cloneChallenge(record);
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
this.challenges.delete(id);
|
||||
}
|
||||
|
||||
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
||||
this.evict(this.tokens, this.maxTokens, record.createdAt);
|
||||
this.tokens.set(record.tokenHash, cloneToken(record));
|
||||
}
|
||||
|
||||
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const record = this.tokens.get(tokenHash);
|
||||
return record ? cloneToken(record) : undefined;
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const record = this.tokens.get(tokenHash);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.consumedAt = now;
|
||||
return cloneToken(record);
|
||||
}
|
||||
|
||||
async deleteToken(tokenHash: string): Promise<void> {
|
||||
this.tokens.delete(tokenHash);
|
||||
}
|
||||
|
||||
async gc(now: number): Promise<void> {
|
||||
for (const [id, record] of this.challenges) {
|
||||
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
|
||||
this.challenges.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [hash, record] of this.tokens) {
|
||||
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
|
||||
this.tokens.delete(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
|
||||
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
|
||||
while (map.size >= max) map.delete(map.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryCaptchaStore(options?: MemoryCaptchaStoreOptions): MemoryCaptchaStore {
|
||||
return new MemoryCaptchaStore(options);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface RedisCaptchaClient {
|
||||
get(key: string): Promise<string | null> | string | null;
|
||||
set(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: { px?: number; nx?: boolean },
|
||||
): Promise<unknown> | unknown;
|
||||
del(key: string): Promise<number> | number;
|
||||
eval?(
|
||||
script: string,
|
||||
options: { keys: string[]; arguments: string[] },
|
||||
): Promise<unknown> | unknown;
|
||||
scanIterator?(options?: { match?: string; count?: number }): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
export interface RedisCaptchaStoreOptions {
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
const MUTATE_CHALLENGE = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return nil end
|
||||
local value = cjson.decode(raw)
|
||||
local now = tonumber(ARGV[1])
|
||||
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
|
||||
if ARGV[2] == 'attempt' then
|
||||
value.attempts = tonumber(value.attempts) + 1
|
||||
else
|
||||
value.consumedAt = now
|
||||
end
|
||||
local encoded = cjson.encode(value)
|
||||
local ttl = math.max(1, tonumber(value.expiresAt) - now)
|
||||
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
|
||||
return encoded
|
||||
`;
|
||||
|
||||
const CONSUME_TOKEN = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return nil end
|
||||
local value = cjson.decode(raw)
|
||||
local now = tonumber(ARGV[1])
|
||||
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
|
||||
value.consumedAt = now
|
||||
local encoded = cjson.encode(value)
|
||||
local ttl = math.max(1, tonumber(value.expiresAt) - now)
|
||||
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
|
||||
return encoded
|
||||
`;
|
||||
|
||||
export class RedisCaptchaStore implements CaptchaStore {
|
||||
private readonly prefix: string;
|
||||
private readonly locks = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisCaptchaClient,
|
||||
options: RedisCaptchaStoreOptions = {},
|
||||
) {
|
||||
this.prefix = options.prefix ?? "wrn:captcha:";
|
||||
}
|
||||
|
||||
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
||||
const ttl = Math.max(1, record.expiresAt - Date.now());
|
||||
await this.redis.set(this.challengeKey(record.id), JSON.stringify(record), { px: ttl });
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return this.read<CaptchaChallengeRecord>(this.challengeKey(id));
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return this.mutateChallenge(id, now, "attempt");
|
||||
}
|
||||
|
||||
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return this.mutateChallenge(id, now, "consume");
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
await this.redis.del(this.challengeKey(id));
|
||||
}
|
||||
|
||||
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
||||
const ttl = Math.max(1, record.expiresAt - Date.now());
|
||||
await this.redis.set(this.tokenKey(record.tokenHash), JSON.stringify(record), { px: ttl });
|
||||
}
|
||||
|
||||
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const key = this.tokenKey(tokenHash);
|
||||
if (this.redis.eval) {
|
||||
const raw = await this.redis.eval(CONSUME_TOKEN, {
|
||||
keys: [key],
|
||||
arguments: [String(now)],
|
||||
});
|
||||
return typeof raw === "string" ? (JSON.parse(raw) as CaptchaResponseTokenRecord) : undefined;
|
||||
}
|
||||
return this.withLock(key, async () => {
|
||||
const current = await this.read<CaptchaResponseTokenRecord>(key);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteToken(tokenHash: string): Promise<void> {
|
||||
await this.redis.del(this.tokenKey(tokenHash));
|
||||
}
|
||||
|
||||
async gc(): Promise<void> {
|
||||
// Redis TTLs are authoritative, so no explicit sweep is required.
|
||||
}
|
||||
|
||||
private async mutateChallenge(
|
||||
id: string,
|
||||
now: number,
|
||||
operation: "attempt" | "consume",
|
||||
): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const key = this.challengeKey(id);
|
||||
if (this.redis.eval) {
|
||||
const raw = await this.redis.eval(MUTATE_CHALLENGE, {
|
||||
keys: [key],
|
||||
arguments: [String(now), operation],
|
||||
});
|
||||
return typeof raw === "string" ? (JSON.parse(raw) as CaptchaChallengeRecord) : undefined;
|
||||
}
|
||||
return this.withLock(key, async () => {
|
||||
const current = await this.read<CaptchaChallengeRecord>(key);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
if (operation === "attempt") current.attempts += 1;
|
||||
else current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
private async read<T>(key: string): Promise<T | undefined> {
|
||||
const value = await this.redis.get(key);
|
||||
return value ? (JSON.parse(value) as T) : undefined;
|
||||
}
|
||||
|
||||
private challengeKey(id: string): string {
|
||||
return `${this.prefix}challenge:${id}`;
|
||||
}
|
||||
|
||||
private tokenKey(hash: string): string {
|
||||
return `${this.prefix}token:${hash}`;
|
||||
}
|
||||
|
||||
private async withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.locks.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const chain = previous.then(() => current);
|
||||
this.locks.set(key, chain);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (this.locks.get(key) === chain) this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRedisCaptchaStore(
|
||||
redis: RedisCaptchaClient,
|
||||
options?: RedisCaptchaStoreOptions,
|
||||
): RedisCaptchaStore {
|
||||
return new RedisCaptchaStore(redis, options);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface SqliteStatementLike {
|
||||
run(...params: unknown[]): unknown;
|
||||
get(...params: unknown[]): Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface SqliteDatabaseLike {
|
||||
exec(sql: string): unknown;
|
||||
prepare(sql: string): SqliteStatementLike;
|
||||
}
|
||||
|
||||
export interface SqliteCaptchaStoreOptions {
|
||||
challengeTable?: string;
|
||||
tokenTable?: string;
|
||||
initialize?: boolean;
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string): string {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseChallenge(row: Record<string, unknown> | undefined): CaptchaChallengeRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
|
||||
}
|
||||
|
||||
function parseToken(row: Record<string, unknown> | undefined): CaptchaResponseTokenRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
|
||||
}
|
||||
|
||||
export class SqliteCaptchaStore implements CaptchaStore {
|
||||
private readonly challengeTable: string;
|
||||
private readonly tokenTable: string;
|
||||
|
||||
constructor(
|
||||
private readonly db: SqliteDatabaseLike,
|
||||
options: SqliteCaptchaStoreOptions = {},
|
||||
) {
|
||||
this.challengeTable = safeIdentifier(options.challengeTable ?? "wrn_captcha_challenges");
|
||||
this.tokenTable = safeIdentifier(options.tokenTable ?? "wrn_captcha_tokens");
|
||||
if (options.initialize ?? true) this.initialize();
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.challengeTable} (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
consumed_at INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ${this.challengeTable}_expires_idx
|
||||
ON ${this.challengeTable}(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS ${this.tokenTable} (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
consumed_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ${this.tokenTable}_expires_idx
|
||||
ON ${this.tokenTable}(expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ${this.challengeTable}
|
||||
(id, payload, expires_at, consumed_at, attempts)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return parseChallenge(
|
||||
this.db.prepare(`SELECT payload FROM ${this.challengeTable} WHERE id = ?`).get(id),
|
||||
);
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const current = await this.getChallenge(id);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.attempts += 1;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ${this.challengeTable}
|
||||
SET payload = ?, attempts = ?
|
||||
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
|
||||
)
|
||||
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const current = await this.getChallenge(id);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ${this.challengeTable}
|
||||
SET payload = ?, consumed_at = ?
|
||||
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL`,
|
||||
)
|
||||
.run(JSON.stringify(current), now, id, now) as { changes?: number };
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM ${this.challengeTable} WHERE id = ?`).run(id);
|
||||
}
|
||||
|
||||
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ${this.tokenTable}
|
||||
(token_hash, payload, expires_at, consumed_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(record.tokenHash, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null);
|
||||
}
|
||||
|
||||
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
return parseToken(
|
||||
this.db.prepare(`SELECT payload FROM ${this.tokenTable} WHERE token_hash = ?`).get(tokenHash),
|
||||
);
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const current = await this.getToken(tokenHash);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ${this.tokenTable}
|
||||
SET payload = ?, consumed_at = ?
|
||||
WHERE token_hash = ? AND expires_at > ? AND consumed_at IS NULL`,
|
||||
)
|
||||
.run(JSON.stringify(current), now, tokenHash, now) as { changes?: number };
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
async deleteToken(tokenHash: string): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM ${this.tokenTable} WHERE token_hash = ?`).run(tokenHash);
|
||||
}
|
||||
|
||||
async gc(now: number): Promise<void> {
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${this.challengeTable} WHERE expires_at <= ? OR consumed_at <= ?`)
|
||||
.run(now, now - 60_000);
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${this.tokenTable} WHERE expires_at <= ? OR consumed_at <= ?`)
|
||||
.run(now, now - 60_000);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSqliteCaptchaStore(
|
||||
db: SqliteDatabaseLike,
|
||||
options?: SqliteCaptchaStoreOptions,
|
||||
): SqliteCaptchaStore {
|
||||
return new SqliteCaptchaStore(db, options);
|
||||
}
|
||||
Reference in New Issue
Block a user