313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|