Skip to content
This repository has been archived by the owner on Sep 17, 2024. It is now read-only.

feat: captcha module #156

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions deno.lock

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions modules/captcha/actors/throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ActorBase, ActorContext, Empty } from "../module.gen.ts";
import { ThrottleRequest, ThrottleResponse } from "../utils/types.ts";

type Input = undefined;

interface State {
start: number;
count: number;
}

export class Actor extends ActorBase<Input, State> {
public initialize(_ctx: ActorContext): State {
// Will refill on first call of `throttle`
return {
start: 0,
count: 0,
};
}

throttle(_ctx: ActorContext, req: ThrottleRequest): ThrottleResponse {
const now = Date.now();

if (now - this.state.start > req.period) {
this.state.start = now;
this.state.count = 1;
return { success: true };
}

if (this.state.count >= req.requests) {
return { success: false };
}

this.state.count += 1;

return { success: true };
}

reset(_ctx: ActorContext, req: Empty): Empty {
this.state.start = 0;
this.state.count = 0;

return {};
}
}
34 changes: 34 additions & 0 deletions modules/captcha/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"status": "stable",
"name": "Captcha",
"description": "",
"icon": "",
"tags": [],
"authors": [
"rivet-gg",
"ABCxFF"
],
"scripts": {
"verify_captcha_token": {
"name": "Verify Captcha Response",
"public": false
},
"guard": {
"name": "Ratelimit Guarded with Captcha Challenge",
"public": false
}
},
"errors": {
"captcha_failed": {
"name": "Captcha Challenge Failed",
"internal": false
},
"captcha_needed": {
"name": "Captcha Required (Rate Limit Exceeded)",
"internal": false
}
},
"actors": {
"throttle": {}
}
}
54 changes: 54 additions & 0 deletions modules/captcha/scripts/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { RuntimeError, ScriptContext } from "../module.gen.ts";
import { getPublicConfig } from "../utils/get_sitekey.ts";
// import { getPublicConfig } from "../utils/get_sitekey.ts";
import type { CaptchaProvider, ThrottleRequest, ThrottleResponse } from "../utils/types.ts";

export interface Request {
type: string;
key: string;
requests: number;
period: number;
captchaToken?: string | null,
captchaProvider: CaptchaProvider
}

export type Response = Record<string, never>;

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
const key = `${JSON.stringify(req.type)}.${JSON.stringify(req.key)}`;

if (req.captchaToken) {
try {
await ctx.modules.captcha.verifyCaptchaToken({
token: req.captchaToken,
provider: req.captchaProvider
});

await ctx.actors.throttle.getOrCreateAndCall<undefined, {}, {}>(key, undefined, "reset", {});

return {};
} catch {
// If we error, it means the captcha failed, we can continue with our normal ratelimitting
}
}

const res = await ctx.actors.throttle.getOrCreateAndCall<
undefined,
ThrottleRequest,
ThrottleResponse
>(key, undefined, "throttle", {
requests: req.requests,
period: req.period,
});

if (!res.success) {
throw new RuntimeError("captcha_needed", {
meta: getPublicConfig(req.captchaProvider)
});
}

return {};
}
36 changes: 36 additions & 0 deletions modules/captcha/scripts/verify_captcha_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { RuntimeError, ScriptContext } from "../module.gen.ts";
import { validateHCaptchaResponse } from "../utils/providers/hcaptcha.ts";
import { validateCFTurnstileResponse } from "../utils/providers/turnstile.ts";
// import { validateHCaptchaResponse } from "../providers/hcaptcha.ts";
// import { validateCFTurnstileResponse } from "../providers/turnstile.ts";
import { CaptchaProvider } from "../utils/types.ts";

export interface Request {
token: string,
provider: CaptchaProvider
}

export type Response = Record<string, never>;

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
const captchaToken = req.token;
const captchaProvider = req.provider;

let success: boolean = false;
if ("hcaptcha" in captchaProvider) {
success = await validateHCaptchaResponse(captchaProvider.hcaptcha.secret, captchaToken);
} else if ("turnstile" in captchaProvider) {
success = await validateCFTurnstileResponse(captchaProvider.turnstile.secret, captchaToken);
} else {
success = true;
}

if (!success) {
throw new RuntimeError("captcha_failed");
}

return {};
}
45 changes: 45 additions & 0 deletions modules/captcha/tests/e2e_guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, TestContext } from "../module.gen.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";

const didFail = async (x: () => Promise<void>) => {
try {
await x();
return false
} catch {
return true;
}
}

test("e2e success and failure", async (ctx: TestContext) => {
const PERIOD = 5000;
const REQUESTS = 5;

const captchaProvider = {
turnstile: {
secret: "0x0000000000000000000000000000000000000000",
sitekey: "" // doesn't really matter here
}
}

assertEquals(false, await didFail(async () => {
for (let i = 0; i < REQUESTS; ++i) {
await ctx.modules.captcha.guard({
type: "ip",
key: "aaaa",
requests: REQUESTS,
period: PERIOD,
captchaProvider
});
}
}));

assertEquals(true, await didFail(async () => {
await ctx.modules.captcha.guard({
type: "ip",
key: "aaaa",
requests: REQUESTS,
period: PERIOD,
captchaProvider
});
}));
});
75 changes: 75 additions & 0 deletions modules/captcha/tests/e2e_verify_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, TestContext } from "../module.gen.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";

const didFail = async (x: () => Promise<void>) => {
try {
await x();
return false
} catch {
return true;
}
}

test(
"hcaptcha success and failure",
async (ctx: TestContext) => {
const shouldBeFalse = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
hcaptcha: {
secret: "0x0000000000000000000000000000000000000000",
sitekey: "" // doesn't really matter here
}
},
token: "10000000-aaaa-bbbb-cccc-000000000001"
});
});
assertEquals(shouldBeFalse, false);

const shouldBeTrue = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
hcaptcha: {
secret: "0x0000000000000000000000000000000000000000",
sitekey: "" // doesn't really matter here
}
},
token: "lorem"
});
});
assertEquals(shouldBeTrue, true);
},
);

test(
"turnstile success and failure",
async (ctx: TestContext) => {
// Always passes
const shouldBeTrue = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
turnstile: {
secret: "2x0000000000000000000000000000000AA",
sitekey: "" // doesn't really matter here
}
},
token: "lorem"
});
});
assertEquals(shouldBeTrue, true);

// Always fails
const shouldBeFalse = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
turnstile: {
secret: "1x0000000000000000000000000000000AA",
sitekey: "" // doesn't really matter here
}
},
token: "ipsum"
});
});
assertEquals(shouldBeFalse, false);
},
);
19 changes: 19 additions & 0 deletions modules/captcha/utils/get_sitekey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CaptchaProvider, PublicCaptchaProviderConfig } from "./types.ts";

export const getPublicConfig = (provider: CaptchaProvider): PublicCaptchaProviderConfig => {
if ("hcaptcha" in provider) {
return {
hcaptcha: { sitekey: provider.hcaptcha.sitekey }
};
} else if ("turnstile" in provider) {
return {
turnstile: {
sitekey: provider.turnstile.sitekey
}
}
} else {
return {
test: {}
}
}
}
21 changes: 21 additions & 0 deletions modules/captcha/utils/providers/hcaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const API = "https://api.hcaptcha.com/siteverify";
export const validateHCaptchaResponse = async (
secret: string,
response: string
): Promise<boolean> => {
try {
const body = new FormData();
body.append("secret", secret);
body.append("response", response);
const result = await fetch(API, {
body,
method: "POST",
});

const { success } = await result.json();

return success;
} catch {}

return false;
}
21 changes: 21 additions & 0 deletions modules/captcha/utils/providers/turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const API = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export const validateCFTurnstileResponse = async (
secret: string,
response: string
): Promise<boolean> => {
try {
const result = await fetch(API, {
body: JSON.stringify({ secret, response }),
method: "POST",
headers: {
"Content-Type": "application/json",
}
});

const { success } = await result.json();

return success;
} catch {}

return false;
}
31 changes: 31 additions & 0 deletions modules/captcha/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@


interface ProviderCFTurnstile {
sitekey: string;
secret: string;
}

interface ProviderHCaptcha {
// TODO: Score threshold
sitekey: string;
secret: string;
}
type PublicCFTurnstileConfig = { sitekey: string; }
type PublicHCaptchaConfig = { sitekey: string; }

export type CaptchaProvider = { test: Record<never, never> }
| { turnstile: ProviderCFTurnstile }
| { hcaptcha: ProviderHCaptcha };

export type PublicCaptchaProviderConfig = { test: Record<never, never> }
| { turnstile: PublicCFTurnstileConfig }
| { hcaptcha: PublicHCaptchaConfig };

export interface ThrottleRequest {
requests: number;
period: number;
}

export interface ThrottleResponse {
success: boolean;
}
Loading
Loading