New Captcha Package added
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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) {
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user