New Captcha Package added
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user