186 lines
5.9 KiB
TypeScript
186 lines
5.9 KiB
TypeScript
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);
|
|
}
|