164 lines
6.1 KiB
TypeScript
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);
|
|
}
|