Files
WRNexusJS/packages/captcha/src/stores/redis.ts
T
2026-07-27 12:42:18 +05:30

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);
}