New Captcha Package added
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import type { CaptchaProvider } from "../types.ts";
|
||||
|
||||
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
|
||||
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
|
||||
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
|
||||
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
|
||||
return provider;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
|
||||
|
||||
export class HcaptchaProvider extends SiteverifyCaptchaProvider {
|
||||
constructor(options: SiteverifyProviderOptions) {
|
||||
super(
|
||||
{
|
||||
name: "hcaptcha",
|
||||
endpoint: "https://api.hcaptcha.com/siteverify",
|
||||
client: {
|
||||
responseField: "h-captcha-response",
|
||||
scriptUrl: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
||||
widgetClass: "h-captcha",
|
||||
},
|
||||
sendSiteKey: true,
|
||||
scoreDirection: "higher-is-risk",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hcaptchaProvider(options: SiteverifyProviderOptions): HcaptchaProvider {
|
||||
return new HcaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./self-hosted.ts";
|
||||
export * from "./siteverify.ts";
|
||||
export * from "./turnstile.ts";
|
||||
export * from "./recaptcha.ts";
|
||||
export * from "./hcaptcha.ts";
|
||||
export * from "./managed.ts";
|
||||
export * from "./custom.ts";
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
CaptchaChallenge,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
CreateCaptchaOptions,
|
||||
VerifyCaptchaInput,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface ManagedCaptchaProviderOptions {
|
||||
baseUrl: string;
|
||||
siteKey: string;
|
||||
secretKey: string;
|
||||
timeoutMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export class ManagedCaptchaProvider implements CaptchaProvider {
|
||||
readonly name = "wrnexus-managed" as const;
|
||||
readonly client;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly options: ManagedCaptchaProviderOptions) {
|
||||
if (!options.baseUrl) throw new TypeError("Managed CAPTCHA baseUrl is required");
|
||||
if (!options.siteKey) throw new TypeError("Managed CAPTCHA siteKey is required");
|
||||
if (!options.secretKey) throw new TypeError("Managed CAPTCHA secretKey is required");
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
||||
this.client = {
|
||||
responseField: "wrn-captcha-response",
|
||||
siteKey: options.siteKey,
|
||||
managedCreateUrl: `${this.baseUrl}/v1/challenges`,
|
||||
};
|
||||
}
|
||||
|
||||
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
return this.request<CaptchaChallenge>("/v1/challenges", {
|
||||
siteKey: this.options.siteKey,
|
||||
...options,
|
||||
}, false);
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
return this.request<CaptchaVerificationResult>("/v1/verify", input, true);
|
||||
}
|
||||
|
||||
private async request<T>(path: string, payload: unknown, secret: boolean): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
|
||||
try {
|
||||
const response = await (this.options.fetch ?? fetch)(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
...(secret ? { authorization: `Bearer ${this.options.secretKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Managed CAPTCHA returned HTTP ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
|
||||
return new ManagedCaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
|
||||
|
||||
export class RecaptchaProvider extends SiteverifyCaptchaProvider {
|
||||
constructor(options: SiteverifyProviderOptions) {
|
||||
super(
|
||||
{
|
||||
name: "recaptcha",
|
||||
endpoint: "https://www.google.com/recaptcha/api/siteverify",
|
||||
client: {
|
||||
responseField: "g-recaptcha-response",
|
||||
scriptUrl: "https://www.google.com/recaptcha/api.js?render=explicit",
|
||||
widgetClass: "g-recaptcha",
|
||||
},
|
||||
scoreDirection: "higher-is-human",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function recaptchaProvider(options: SiteverifyProviderOptions): RecaptchaProvider {
|
||||
return new RecaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CreateCaptchaOptions,
|
||||
VerifyCaptchaInput,
|
||||
} from "../types.ts";
|
||||
|
||||
export class SelfHostedCaptchaProvider implements CaptchaProvider {
|
||||
readonly name = "self-hosted" as const;
|
||||
readonly client;
|
||||
|
||||
constructor(readonly engine: CaptchaEngine) {
|
||||
this.client = {
|
||||
responseField: "wrn-captcha-response",
|
||||
managedCreateUrl: `${engine.basePath}/challenge`,
|
||||
};
|
||||
}
|
||||
|
||||
createChallenge(options: CreateCaptchaOptions) {
|
||||
return this.engine.create(options);
|
||||
}
|
||||
|
||||
verify(input: VerifyCaptchaInput) {
|
||||
return input.responseToken || input.providerToken
|
||||
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
|
||||
: this.engine.verify(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function selfHostedProvider(engine: CaptchaEngine): SelfHostedCaptchaProvider {
|
||||
return new SelfHostedCaptchaProvider(engine);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
CaptchaProvider,
|
||||
CaptchaProviderClientConfig,
|
||||
CaptchaProviderName,
|
||||
CaptchaVerificationResult,
|
||||
VerifyCaptchaInput,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface SiteverifyProviderOptions {
|
||||
secretKey: string;
|
||||
siteKey?: string;
|
||||
expectedHostnames?: string[];
|
||||
expectedAction?: string;
|
||||
minScore?: number;
|
||||
timeoutMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface SiteverifyPreset {
|
||||
name: CaptchaProviderName;
|
||||
endpoint: string;
|
||||
client: CaptchaProviderClientConfig;
|
||||
sendSiteKey?: boolean;
|
||||
scoreDirection?: "higher-is-human" | "higher-is-risk";
|
||||
}
|
||||
|
||||
interface SiteverifyPayload {
|
||||
success?: boolean;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
score?: number;
|
||||
challenge_ts?: string;
|
||||
"error-codes"?: string[];
|
||||
error_codes?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function providerFailure(
|
||||
name: CaptchaProviderName,
|
||||
action: string,
|
||||
code: string,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): CaptchaVerificationResult {
|
||||
return { success: false, provider: name, action, code, message, metadata };
|
||||
}
|
||||
|
||||
export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
readonly name: CaptchaProviderName;
|
||||
readonly client: CaptchaProviderClientConfig;
|
||||
|
||||
constructor(
|
||||
private readonly preset: SiteverifyPreset,
|
||||
private readonly options: SiteverifyProviderOptions,
|
||||
) {
|
||||
if (!options.secretKey) throw new TypeError(`${preset.name} secretKey is required`);
|
||||
this.name = preset.name;
|
||||
this.client = { ...preset.client, siteKey: options.siteKey };
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
const token = input.providerToken ?? input.responseToken;
|
||||
const action = input.action;
|
||||
if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
|
||||
if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
|
||||
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
|
||||
if (input.ip) body.set("remoteip", input.ip);
|
||||
if (this.preset.sendSiteKey && this.options.siteKey) body.set("sitekey", this.options.siteKey);
|
||||
try {
|
||||
const response = await (this.options.fetch ?? fetch)(this.preset.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
accept: "application/json",
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as SiteverifyPayload;
|
||||
if (!data.success) {
|
||||
const codes = data["error-codes"] ?? data.error_codes ?? [];
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
codes.includes("timeout-or-duplicate") || codes.includes("already-seen-response")
|
||||
? "already-used"
|
||||
: codes.includes("expired-input-response")
|
||||
? "expired"
|
||||
: "invalid-input",
|
||||
"Provider verification failed",
|
||||
{ errorCodes: codes },
|
||||
);
|
||||
}
|
||||
const expectedAction = this.options.expectedAction ?? action;
|
||||
if (data.action && expectedAction && data.action !== expectedAction) {
|
||||
return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
|
||||
receivedAction: data.action,
|
||||
});
|
||||
}
|
||||
if (
|
||||
this.options.expectedHostnames?.length &&
|
||||
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
|
||||
) {
|
||||
return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
|
||||
hostname: data.hostname,
|
||||
});
|
||||
}
|
||||
if (this.options.minScore !== undefined && typeof data.score === "number") {
|
||||
const rejected = this.preset.scoreDirection === "higher-is-risk"
|
||||
? data.score >= this.options.minScore
|
||||
: data.score < this.options.minScore;
|
||||
if (rejected) {
|
||||
return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
|
||||
score: data.score,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: this.name,
|
||||
action,
|
||||
score: data.score,
|
||||
hostname: data.hostname,
|
||||
metadata: {
|
||||
challengeTimestamp: data.challenge_ts,
|
||||
raw: data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
|
||||
error instanceof DOMException && error.name === "AbortError"
|
||||
? `${this.name} verification timed out`
|
||||
: `${this.name} verification failed`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
|
||||
|
||||
export class TurnstileCaptchaProvider extends SiteverifyCaptchaProvider {
|
||||
constructor(options: SiteverifyProviderOptions) {
|
||||
super(
|
||||
{
|
||||
name: "turnstile",
|
||||
endpoint: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
client: {
|
||||
responseField: "cf-turnstile-response",
|
||||
scriptUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
||||
widgetClass: "cf-turnstile",
|
||||
},
|
||||
scoreDirection: "higher-is-human",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function turnstileProvider(options: SiteverifyProviderOptions): TurnstileCaptchaProvider {
|
||||
return new TurnstileCaptchaProvider(options);
|
||||
}
|
||||
Reference in New Issue
Block a user