New Captcha Package added
This commit is contained in:
1 parent
d0aded0392
commit
8b728a3e5d
157 files changed
+12092
-1913
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { ManagedCaptchaService } from "./service.ts";
|
||||
export * from "./types.ts";
|
||||
export * from "./store.ts";
|
||||
export * from "./service.ts";
|
||||
|
||||
if (import.meta.main) {
|
||||
const port = Number(process.env.PORT ?? 8787);
|
||||
const service = new ManagedCaptchaService({
|
||||
adminToken: process.env.CAPTCHA_ADMIN_TOKEN ?? "replace-this-development-admin-token",
|
||||
baseUrl: process.env.CAPTCHA_BASE_URL ?? `http://localhost:${port}`,
|
||||
});
|
||||
Bun.serve({ port, fetch: (request) => service.handle(request) });
|
||||
console.log(`WRNexus Managed CAPTCHA listening on http://localhost:${port}`);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
constantTimeEqual,
|
||||
createCaptchaEngine,
|
||||
MemoryCaptchaStore,
|
||||
randomId,
|
||||
sha256,
|
||||
type CaptchaEngine,
|
||||
type CreateCaptchaOptions,
|
||||
type VerifyCaptchaInput,
|
||||
} from "@wrnexus/captcha/server";
|
||||
import { MemoryManagedProjectStore } from "./store.ts";
|
||||
import type {
|
||||
ManagedCaptchaProject,
|
||||
ManagedCaptchaServiceOptions,
|
||||
ManagedProjectStore,
|
||||
PublicProjectKey,
|
||||
} from "./types.ts";
|
||||
|
||||
function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
|
||||
return Response.json(body, { status, headers: { "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers } });
|
||||
}
|
||||
function corsHeaders(request: Request): HeadersInit {
|
||||
const origin = request.headers.get("origin");
|
||||
return origin ? { "access-control-allow-origin": origin, vary: "Origin" } : {};
|
||||
}
|
||||
function month(now: number): string { return new Date(now).toISOString().slice(0, 7); }
|
||||
function bearer(request: Request): string { return request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? ""; }
|
||||
function hostnameFrom(request: Request, payload: Record<string, unknown>): string {
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) return new URL(origin).hostname.toLowerCase();
|
||||
const explicit = typeof payload.hostname === "string" ? payload.hostname.trim().toLowerCase() : "";
|
||||
return explicit || new URL(request.url).hostname.toLowerCase();
|
||||
}
|
||||
function publicProject(project: ManagedCaptchaProject): Omit<ManagedCaptchaProject, "secretHash" | "engineSecret"> {
|
||||
const { secretHash: _secretHash, engineSecret: _engineSecret, ...safe } = project;
|
||||
return safe;
|
||||
}
|
||||
|
||||
export class ManagedCaptchaService {
|
||||
readonly projects: ManagedProjectStore;
|
||||
private readonly engines = new Map<string, CaptchaEngine>();
|
||||
private readonly challengeOwners = new Map<string, string>();
|
||||
private readonly now: () => number;
|
||||
private readonly randomBytes: (length: number) => Uint8Array;
|
||||
private readonly defaultMonthlyQuota: number;
|
||||
|
||||
constructor(private readonly options: ManagedCaptchaServiceOptions) {
|
||||
if (!options.adminToken || options.adminToken.length < 24) throw new TypeError("A strong admin token is required");
|
||||
this.projects = options.projectStore ?? new MemoryManagedProjectStore();
|
||||
this.now = options.now ?? Date.now;
|
||||
this.randomBytes = options.randomBytes ?? ((length) => crypto.getRandomValues(new Uint8Array(length)));
|
||||
this.defaultMonthlyQuota = options.defaultMonthlyQuota ?? 10_000;
|
||||
}
|
||||
|
||||
async createProject(input: { name: string; allowedHostnames?: string[]; monthlyQuota?: number }): Promise<PublicProjectKey> {
|
||||
const timestamp = this.now();
|
||||
const secretKey = `wrn_secret_${randomId(this.randomBytes, 32)}`;
|
||||
const project: ManagedCaptchaProject = {
|
||||
id: `project_${randomId(this.randomBytes, 16)}`,
|
||||
name: input.name.trim(),
|
||||
siteKey: `wrn_site_${randomId(this.randomBytes, 20)}`,
|
||||
secretHash: await sha256(secretKey),
|
||||
engineSecret: randomId(this.randomBytes, 48),
|
||||
allowedHostnames: [...new Set((input.allowedHostnames ?? []).map((hostname) => hostname.trim().toLowerCase()).filter(Boolean))],
|
||||
monthlyQuota: input.monthlyQuota ?? this.defaultMonthlyQuota,
|
||||
usageMonth: month(timestamp),
|
||||
usageCount: 0,
|
||||
enabled: true,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
if (!project.name) throw new TypeError("Project name is required");
|
||||
await this.projects.create(project);
|
||||
return { id: project.id, name: project.name, siteKey: project.siteKey, secretKey, allowedHostnames: project.allowedHostnames, monthlyQuota: project.monthlyQuota };
|
||||
}
|
||||
|
||||
async rotateSecret(id: string): Promise<{ secretKey: string }> {
|
||||
const project = await this.projects.getById(id);
|
||||
if (!project) throw new Error("Unknown project");
|
||||
const secretKey = `wrn_secret_${randomId(this.randomBytes, 32)}`;
|
||||
project.secretHash = await sha256(secretKey);
|
||||
project.updatedAt = this.now();
|
||||
await this.projects.update(project);
|
||||
return { secretKey };
|
||||
}
|
||||
|
||||
async handle(request: Request): Promise<Response> {
|
||||
try {
|
||||
return await this.route(request);
|
||||
} catch {
|
||||
return json({ error: "invalid-request" }, 400, corsHeaders(request));
|
||||
}
|
||||
}
|
||||
|
||||
private async route(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...corsHeaders(request),
|
||||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||
"access-control-allow-headers": "content-type,authorization",
|
||||
"access-control-max-age": "600",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/v1/challenges") return this.createChallenge(request);
|
||||
if (request.method === "POST" && url.pathname === "/v1/solve") return this.solveChallenge(request);
|
||||
if (request.method === "POST" && url.pathname === "/v1/verify") return this.verifyToken(request);
|
||||
const audio = url.pathname.match(/^\/v1\/audio\/([^/]+)$/);
|
||||
if (audio && (request.method === "GET" || request.method === "HEAD")) return this.audioChallenge(request, decodeURIComponent(audio[1]!));
|
||||
if (url.pathname === "/v1/projects" && request.method === "POST") return this.adminCreateProject(request);
|
||||
if (url.pathname === "/v1/projects" && request.method === "GET") return this.adminListProjects(request);
|
||||
const rotate = url.pathname.match(/^\/v1\/projects\/([^/]+)\/rotate-secret$/);
|
||||
if (rotate && request.method === "POST") return this.adminRotate(request, decodeURIComponent(rotate[1]!));
|
||||
return json({ error: "not-found" }, 404);
|
||||
}
|
||||
|
||||
private engine(project: ManagedCaptchaProject): CaptchaEngine {
|
||||
let engine = this.engines.get(project.id);
|
||||
if (!engine) {
|
||||
engine = this.options.engineFactory?.(project) ?? createCaptchaEngine({
|
||||
secret: project.engineSecret,
|
||||
store: this.options.captchaStore ?? new MemoryCaptchaStore(),
|
||||
basePath: "/v1",
|
||||
bindIp: false,
|
||||
});
|
||||
this.engines.set(project.id, engine);
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
private async createChallenge(request: Request): Promise<Response> {
|
||||
const payload = await request.json() as Record<string, unknown>;
|
||||
const project = await this.projects.getBySiteKey(String(payload.siteKey ?? ""));
|
||||
if (!project || !project.enabled) return json({ error: "invalid-site-key" }, 403, corsHeaders(request));
|
||||
const hostname = hostnameFrom(request, payload);
|
||||
if (project.allowedHostnames.length && !project.allowedHostnames.includes(hostname)) return json({ error: "hostname-not-allowed" }, 403, corsHeaders(request));
|
||||
if (!(await this.consumeQuota(project))) return json({ error: "quota-exceeded" }, 429, corsHeaders(request));
|
||||
const challenge = await this.engine(project).create({ ...(payload as unknown as CreateCaptchaOptions), hostname });
|
||||
const publicBase = this.options.baseUrl?.replace(/\/+$/, "") ?? "";
|
||||
challenge.provider = "wrnexus-managed";
|
||||
challenge.verifyUrl = `${publicBase}/v1/solve`;
|
||||
if (challenge.audioUrl && publicBase) challenge.audioUrl = `${publicBase}${challenge.audioUrl}`;
|
||||
this.challengeOwners.set(challenge.id, project.id);
|
||||
return json(challenge, 201, corsHeaders(request));
|
||||
}
|
||||
|
||||
private async audioChallenge(request: Request, id: string): Promise<Response> {
|
||||
const owner = this.challengeOwners.get(id);
|
||||
if (!owner) return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
|
||||
const project = await this.projects.getById(owner);
|
||||
if (!project) return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
|
||||
const key = new URL(request.url).searchParams.get("key") ?? "";
|
||||
try {
|
||||
const audio = await this.engine(project).renderAudio(id, key);
|
||||
if (!audio) return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
|
||||
|
||||
const body: BodyInit | null =
|
||||
request.method === "HEAD" ? null : Uint8Array.from(audio.bytes).buffer;
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
...corsHeaders(request),
|
||||
"content-type": audio.contentType,
|
||||
"content-length": String(audio.bytes.byteLength),
|
||||
"cache-control": "private, no-store, max-age=0",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus/managed-captcha] Unable to render CAPTCHA audio:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return new Response("CAPTCHA audio is unavailable", {
|
||||
status: 503,
|
||||
headers: {
|
||||
...corsHeaders(request),
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "no-store, max-age=0",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async solveChallenge(request: Request): Promise<Response> {
|
||||
const payload = await request.json() as Record<string, unknown>;
|
||||
const id = String(payload.challengeId ?? "");
|
||||
const owner = this.challengeOwners.get(id);
|
||||
if (!owner) return json({ success: false, code: "invalid-input", message: "Unknown challenge" }, 400, corsHeaders(request));
|
||||
const project = await this.projects.getById(owner);
|
||||
if (!project) return json({ success: false, code: "invalid-input" }, 400, corsHeaders(request));
|
||||
const hostname = hostnameFrom(request, payload);
|
||||
const result = await this.engine(project).verify({ ...(payload as unknown as VerifyCaptchaInput), hostname });
|
||||
result.provider = "wrnexus-managed";
|
||||
if (result.success || result.code === "expired" || result.code === "attempts-exhausted") this.challengeOwners.delete(id);
|
||||
return json(result, result.success ? 200 : 400, corsHeaders(request));
|
||||
}
|
||||
|
||||
private async verifyToken(request: Request): Promise<Response> {
|
||||
const secret = bearer(request);
|
||||
const project = secret ? await this.projects.getBySecretHash(await sha256(secret)) : undefined;
|
||||
if (!project || !project.enabled) return json({ success: false, code: "invalid-secret" }, 401);
|
||||
const payload = await request.json() as VerifyCaptchaInput;
|
||||
const result = await this.engine(project).verifyResponseToken(payload);
|
||||
result.provider = "wrnexus-managed";
|
||||
return json(result, result.success ? 200 : 400);
|
||||
}
|
||||
|
||||
private async consumeQuota(project: ManagedCaptchaProject): Promise<boolean> {
|
||||
const currentMonth = month(this.now());
|
||||
if (project.usageMonth !== currentMonth) { project.usageMonth = currentMonth; project.usageCount = 0; }
|
||||
if (project.usageCount >= project.monthlyQuota) return false;
|
||||
project.usageCount += 1;
|
||||
project.updatedAt = this.now();
|
||||
await this.projects.update(project);
|
||||
return true;
|
||||
}
|
||||
|
||||
private adminAllowed(request: Request): boolean {
|
||||
const supplied = bearer(request);
|
||||
return supplied.length > 0 && constantTimeEqual(supplied, this.options.adminToken);
|
||||
}
|
||||
private async adminCreateProject(request: Request): Promise<Response> {
|
||||
if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401);
|
||||
try { return json(await this.createProject(await request.json() as { name: string; allowedHostnames?: string[]; monthlyQuota?: number }), 201); }
|
||||
catch (error) { return json({ error: error instanceof Error ? error.message : "invalid-project" }, 400); }
|
||||
}
|
||||
private async adminListProjects(request: Request): Promise<Response> {
|
||||
if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401);
|
||||
return json({ projects: (await this.projects.list()).map(publicProject) });
|
||||
}
|
||||
private async adminRotate(request: Request, id: string): Promise<Response> {
|
||||
if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401);
|
||||
try { return json(await this.rotateSecret(id)); }
|
||||
catch { return json({ error: "not-found" }, 404); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ManagedCaptchaProject, ManagedProjectStore } from "./types.ts";
|
||||
|
||||
export class MemoryManagedProjectStore implements ManagedProjectStore {
|
||||
private readonly projects = new Map<string, ManagedCaptchaProject>();
|
||||
async create(project: ManagedCaptchaProject): Promise<void> {
|
||||
if ([...this.projects.values()].some((item) => item.siteKey === project.siteKey)) throw new Error("Duplicate site key");
|
||||
this.projects.set(project.id, structuredClone(project));
|
||||
}
|
||||
async update(project: ManagedCaptchaProject): Promise<void> {
|
||||
if (!this.projects.has(project.id)) throw new Error("Unknown project");
|
||||
this.projects.set(project.id, structuredClone(project));
|
||||
}
|
||||
async getById(id: string) { const value = this.projects.get(id); return value && structuredClone(value); }
|
||||
async getBySiteKey(siteKey: string) { const value = [...this.projects.values()].find((item) => item.siteKey === siteKey); return value && structuredClone(value); }
|
||||
async getBySecretHash(secretHash: string) { const value = [...this.projects.values()].find((item) => item.secretHash === secretHash); return value && structuredClone(value); }
|
||||
async list() { return [...this.projects.values()].map((item) => structuredClone(item)); }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CaptchaEngine, CaptchaStore } from "@wrnexus/captcha/server";
|
||||
|
||||
export interface ManagedCaptchaProject {
|
||||
id: string;
|
||||
name: string;
|
||||
siteKey: string;
|
||||
secretHash: string;
|
||||
engineSecret: string;
|
||||
allowedHostnames: string[];
|
||||
monthlyQuota: number;
|
||||
usageMonth: string;
|
||||
usageCount: number;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PublicProjectKey {
|
||||
id: string;
|
||||
name: string;
|
||||
siteKey: string;
|
||||
secretKey: string;
|
||||
allowedHostnames: string[];
|
||||
monthlyQuota: number;
|
||||
}
|
||||
|
||||
export interface ManagedProjectStore {
|
||||
create(project: ManagedCaptchaProject): Promise<void>;
|
||||
update(project: ManagedCaptchaProject): Promise<void>;
|
||||
getById(id: string): Promise<ManagedCaptchaProject | undefined>;
|
||||
getBySiteKey(siteKey: string): Promise<ManagedCaptchaProject | undefined>;
|
||||
getBySecretHash(secretHash: string): Promise<ManagedCaptchaProject | undefined>;
|
||||
list(): Promise<ManagedCaptchaProject[]>;
|
||||
}
|
||||
|
||||
export interface ManagedCaptchaServiceOptions {
|
||||
adminToken: string;
|
||||
projectStore?: ManagedProjectStore;
|
||||
captchaStore?: CaptchaStore;
|
||||
baseUrl?: string;
|
||||
defaultMonthlyQuota?: number;
|
||||
now?: () => number;
|
||||
randomBytes?: (length: number) => Uint8Array;
|
||||
engineFactory?: (project: ManagedCaptchaProject) => CaptchaEngine;
|
||||
}
|
||||
Reference in new issue
Block a user