Files
WRNexusJS/packages/captcha/src/middleware.ts
2026-07-29 12:51:10 +05:30

164 lines
6.1 KiB
TypeScript

import type { Context } from "@wrnexus/core";
import { shouldRequireCaptcha, validCaptchaGrant, type CaptchaSessionGrant } from "./policy.ts";
import { selfHostedProvider } from "./providers/self-hosted.ts";
import type {
CaptchaGuardOptions,
CaptchaPageGateOptions,
CaptchaProvider,
CaptchaVerificationResult,
} from "./types.ts";
const DEFAULT_FIELD = "wrn-captcha-response";
function resolveAction(value: CaptchaGuardOptions["action"], ctx: Context): string {
return typeof value === "function" ? value(ctx) : value;
}
async function bodyValue(request: Request, field: string): Promise<string | undefined> {
const header = request.headers.get("x-wrn-captcha-token");
if (header) return header;
if (request.method === "GET" || request.method === "HEAD") return undefined;
const clone = request.clone();
const contentType = clone.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
const body = (await clone.json()) as Record<string, unknown>;
const value = body[field] ?? body.captchaToken ?? body.responseToken;
return value === undefined ? undefined : String(value);
}
if (contentType.includes("form")) {
const form = await clone.formData();
const value = form.get(field);
return typeof value === "string" ? value : undefined;
}
} catch {
return undefined;
}
return undefined;
}
function providerFor(options: CaptchaGuardOptions): CaptchaProvider {
if (options.provider) return options.provider;
if (options.engine) return selfHostedProvider(options.engine);
throw new TypeError("captchaGuard requires provider or engine");
}
async function verifyRequest(
ctx: Context,
options: CaptchaGuardOptions,
): Promise<CaptchaVerificationResult> {
const provider = providerFor(options);
const action = resolveAction(options.action, ctx);
const field = options.responseField ?? provider.client.responseField ?? DEFAULT_FIELD;
const token = await bodyValue(ctx.req, field);
return provider.verify({
action,
providerToken: token,
responseToken: token,
hostname: options.bindHostname === false ? undefined : ctx.url.hostname,
sessionId: options.bindSession === false ? undefined : ctx.session.id(),
ip: options.bindIp ? ctx.ip : undefined,
});
}
function defaultFailure(options: CaptchaGuardOptions, result: CaptchaVerificationResult): Response {
return new Response(options.failureMessage ?? result.message ?? "CAPTCHA verification failed", {
status: options.failureStatus ?? 403,
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
"x-wrn-captcha-error": result.code ?? "verification-failed",
},
});
}
export function captchaGuard(options: CaptchaGuardOptions) {
const verifiedForMs = options.verifiedForMs ?? 0;
if (!Number.isFinite(verifiedForMs) || verifiedForMs < 0) {
throw new TypeError("captchaGuard verifiedForMs must be a non-negative finite duration");
}
const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants";
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
const action = resolveAction(options.action, ctx);
const now = Date.now();
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
const grant = verifiedForMs > 0 ? validCaptchaGrant(grants, action, now) : undefined;
if (grant) {
ctx.locals.captcha = {
success: true,
provider: grant.provider,
action,
};
ctx.locals.captchaVerified = true;
return next();
}
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
if (!result.success)
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
if (verifiedForMs > 0) {
const fresh: CaptchaSessionGrant = {
action,
provider: result.provider,
expiresAt: now + verifiedForMs,
};
ctx.session.set(sessionKey, [
...grants.filter((item) => item.expiresAt > now && item.action !== action),
fresh,
]);
}
return next();
};
}
export function captchaPageGate(options: CaptchaPageGateOptions) {
const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants";
const returnToParam = options.returnToParam ?? "returnTo";
const challengePath = options.challengePath ?? "/captcha";
const policy = options.policy ?? { mode: "session" as const };
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
const action = resolveAction(options.action, ctx);
const now = Date.now();
const routeGroup = policy.routeGroups?.find((group) => ctx.url.pathname.startsWith(group));
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
const signals = (await options.signals?.(ctx)) ?? {};
const decision = shouldRequireCaptcha(action, policy, signals);
ctx.locals.captchaRisk = decision;
if (!decision.challenge) return next();
const token = await bodyValue(ctx.req, options.responseField ?? DEFAULT_FIELD);
if (token) {
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
if (result.success) {
const grant: CaptchaSessionGrant = {
action,
routeGroup,
provider: result.provider,
expiresAt: now + (policy.verifiedForMs ?? 15 * 60_000),
};
ctx.session.set(sessionKey, [...grants.filter((item) => item.expiresAt > now), grant]);
return next();
}
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") {
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
}
}
const redirect = new URL(challengePath, ctx.url);
redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`);
redirect.searchParams.set("action", action);
return Response.redirect(redirect, 302);
};
}
export function clearCaptchaGrants(ctx: Context, sessionKey = "wrnexus.captcha.grants"): void {
ctx.session.delete(sessionKey);
}