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

Put captcha on lobbies api #161

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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.

2 changes: 1 addition & 1 deletion modules/auth_email/scripts/verify_add_email_pass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function run(
}

// Ensure that the email is not associated with ANY accounts in ANY way.
const providedUser = await ctx.modules.users.authenticateToken({
const providedUser = await ctx.modules.users.authenticateTokenInternal({
userToken: req.userToken,
});
await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId]));
Expand Down
2 changes: 1 addition & 1 deletion modules/auth_email/scripts/verify_add_no_pass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function run(
const { email } = await verifyCode(ctx, req.verificationToken, req.code);

// Ensure that the email is not already associated with another account
const providedUser = await ctx.modules.users.authenticateToken({
const providedUser = await ctx.modules.users.authenticateTokenInternal({
userToken: req.userToken,
});
await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId]));
Expand Down
2 changes: 1 addition & 1 deletion modules/auth_email/scripts/verify_link_email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function run(
const { email } = await verifyCode(ctx, req.verificationToken, req.code);

// Ensure that the email is not already associated with another account
const providedUser = await ctx.modules.users.authenticateToken({
const providedUser = await ctx.modules.users.authenticateTokenInternal({
userToken: req.userToken,
});
await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId]));
Expand Down
4 changes: 2 additions & 2 deletions modules/auth_email/tests/already_used.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ test("email_link_then_add_pass", async (ctx: TestContext) => {
const password = faker.internet.password();

const { userToken } = await signUpEmailLink(ctx, email);
const { user } = await ctx.modules.users.authenticateToken({
const { user } = await ctx.modules.users.authenticateTokenInternal({
userToken,
fetchUser: true,
});
Expand Down Expand Up @@ -161,7 +161,7 @@ test("email_link_then_add_no_pass", async (ctx: TestContext) => {
const email = faker.internet.email();

const { userToken } = await signUpEmailLink(ctx, email);
const { user } = await ctx.modules.users.authenticateToken({
const { user } = await ctx.modules.users.authenticateTokenInternal({
userToken,
fetchUser: true,
});
Expand Down
2 changes: 1 addition & 1 deletion modules/auth_email/tests/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function checkLogin(
newToken: string,
) {
const { userId: signedInUserId, user: signedInUser } = await ctx.modules.users
.authenticateToken({
.authenticateTokenInternal({
userToken: newToken,
fetchUser: true,
});
Expand Down
4 changes: 2 additions & 2 deletions modules/auth_email/tests/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ test("create_with_email_and_login_passwordless", async (ctx: TestContext) => {
userToken = signUpRes.userToken;
}

const { user } = await ctx.modules.users.authenticateToken({
const { user } = await ctx.modules.users.authenticateTokenInternal({
userToken,
fetchUser: true,
});
Expand Down Expand Up @@ -65,7 +65,7 @@ test("create_with_email_and_login_password", async (ctx: TestContext) => {
userToken = signUpRes.userToken;
}

const { user } = await ctx.modules.users.authenticateToken({
const { user } = await ctx.modules.users.authenticateTokenInternal({
userToken,
fetchUser: true,
});
Expand Down
2 changes: 1 addition & 1 deletion modules/auth_email/utils/link_assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function ensureNotAssociated(
}
}
// Email matches an existing identity using this provider
const existingUser = await ctx.modules.users.authenticateToken(
const existingUser = await ctx.modules.users.authenticateTokenInternal(
existingIdentity,
);

Expand Down
4 changes: 2 additions & 2 deletions modules/auth_username_password/tests/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ test("test_sign_up", async (ctx: TestContext) => {
password,
});

const { userId } = await ctx.modules.users.authenticateToken({
const { userId } = await ctx.modules.users.authenticateTokenInternal({
userToken: token.token,
});

Expand Down Expand Up @@ -51,7 +51,7 @@ test("test_sign_in", async (ctx: TestContext) => {
password,
});

const { userId } = await ctx.modules.users.authenticateToken({
const { userId } = await ctx.modules.users.authenticateTokenInternal({
userToken: token.token,
});

Expand Down
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);
},
);
Loading
Loading