Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: anti-bot filtering with reCAPTCHA v3 #137

Draft
wants to merge 26 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
43239e9
added recaptcha detection text
Dec 6, 2023
732ea84
added home button to menu dialog for ease of navigation
Dec 6, 2023
c7e3646
added recaptcha provider, hooks and utils
Dec 6, 2023
86b76c0
added types, service hooks and recaptcha server functions
Dec 6, 2023
f0a459d
pnp files
Dec 6, 2023
ac0d485
Merge remote-tracking branch 'origin/main' into feature/recaptcha
Dec 6, 2023
c9b93e2
pnp files
Dec 6, 2023
6013661
fix: minor yarn build issue
Dec 7, 2023
b59bc29
updated env file
Dec 7, 2023
9cf860f
added yarn cache and hide recaptcha logo
Dec 7, 2023
e8c8b15
Revert "pnp files"
Dec 7, 2023
1273af6
added zod based types for recaptcha response
Dec 7, 2023
4e7929d
moved and renamed files
Dec 7, 2023
e8b9d8e
code refactor based on review comments
Dec 7, 2023
ff83345
added recaptcha secret to deployment staging
Dec 7, 2023
d3d9ce4
code refactor based on comment reviews
Dec 7, 2023
b63e4a0
refactor: removed recaptcha provider
Dec 11, 2023
738a8bf
refactor: updated recaptcha types and removed eslint ignores
Dec 11, 2023
d404c53
refactor: updated recaptcha to use a hook instead of a context
Dec 11, 2023
575e954
refactor: removed grecaptcha types
Dec 11, 2023
5b58500
refactor: yarn.lock
Dec 11, 2023
58b4746
Merge remote-tracking branch 'origin/main' into feature/recaptcha
Dec 15, 2023
7c642c2
updated based on review
Dec 15, 2023
0a35010
updated based on review comments
Dec 15, 2023
29ff6e0
updated based on review comments
Dec 15, 2023
e9d1605
updated based on review comments
Dec 15, 2023
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ APP_URL="http://localhost:3000"
NEXT_PUBLIC_WS_URL="ws://localhost:3001"
NEXT_PUBLIC_MOCK_AUTH="false"
PLAYERS_PER_MATCH="2"
NEXT_PUBLIC_RECAPTCHA_SITE_KEY="add-recaptcha-site-key-here"
RECAPTCHA_SECRET_KEY="add-recaptcha-secret-key-here"

543 changes: 15 additions & 528 deletions .pnp.cjs

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions deployment/staging/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ spec:
value: wss://bot-busters.staging.kryha.dev
- name: PLAYERS_PER_MATCH
value: "4"
- name: NEXT_PUBLIC_RECAPTCHA_SITE_KEY
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
valueFrom:
secretKeyRef:
name: backend-secrets
key: recaptcha_site_key
1 change: 1 addition & 0 deletions deployment/staging/secrets.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ type: Opaque
data:
db_password: $DB_PASSWORD
nextauth_secret: $NEXTAUTH_SECRET
recaptcha_site_key: $NEXT_PUBLIC_RECAPTCHA_SITE_KEY
1 change: 1 addition & 0 deletions src/assets/text/homepage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const homepage = {
"The daily top 100 gets Aleo credits transferred to their wallets. Start playing now!",
topRanked: (position: number, username: string, score: number) =>
`${position + 1}. ${username} ${score} points`,
botDetected: "Recaptcha detected a bot. Please try again.",
};
15 changes: 13 additions & 2 deletions src/components/menu-dialog/menu-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import { Button, Dialog, Slide, Stack } from "@mui/material";
import { type TransitionProps } from "@mui/material/transitions";

import { text } from "~/assets/text/index.js";

import { styles } from "./styles.js";
import { Footer } from "./footer.jsx";
import { MenuOptions } from "./menu-options.jsx";
import { useRouter } from "next/router";
import { pages } from "~/router.js";

const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: ReactElement;
},
ref: Ref<unknown>
ref: Ref<unknown>,
) {
return <Slide direction="left" ref={ref} {...props} />;
});
Expand All @@ -24,6 +25,13 @@ interface Props {
}

export const MenuDialog: FC<Props> = ({ open, setOpen }) => {
const { push } = useRouter();

const goHome = () => {
setOpen(false);
void push(pages.home);
};
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

const handleClose = () => {
setOpen(false);
};
Expand All @@ -37,6 +45,9 @@ export const MenuDialog: FC<Props> = ({ open, setOpen }) => {
sx={styles.dialog}
>
<Stack sx={styles.buttonWrapper}>
<Button variant="contained" sx={styles.button} onClick={goHome}>
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
{text.general.home}
</Button>
<Button variant="contained" sx={styles.button} onClick={handleClose}>
{text.general.close}
</Button>
Expand Down
3 changes: 3 additions & 0 deletions src/components/menu-dialog/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export const styles = {
},
buttonWrapper: {
alignItems: "flex-end",
flexDirection: "row",
justifyContent: "space-between",
pt: 3,
pr: 3,
pl: 3,
width: "100vw",
},
button: {
Expand Down
40 changes: 34 additions & 6 deletions src/containers/home/index.tsx → src/containers/home/home.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
import { Button, Stack, Typography } from "@mui/material";
import React from "react";
import React, { useState } from "react";
import { signIn, useSession } from "next-auth/react";
import { useRouter } from "next/router.js";

import { text } from "~/assets/text/index.js";
import { useRecaptcha } from "~/service/index.js";
import { TopRanked } from "~/components/index.js";
import { isValidSession } from "~/utils/session.js";
import { api } from "~/utils/api.js";
import { text } from "~/assets/text/index.js";
import { pages } from "~/router.js";
import { TOP_RANKED_PLAYERS } from "~/constants/index.js";
import { isValidSession } from "~/utils/session.js";

import { styles } from "./styles.js";
import Script from "next/script";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

export const Homepage = () => {
const { push } = useRouter();
const [isRecaptchaFailed, setIsRecaptchaFailed] = useState<boolean>(false);
const verifyCaptcha = api.recaptcha.verify.useMutation();
const join = api.lobby.join.useMutation();
const { data: sessionData } = useSession();
const { executeRecaptcha, id, src, onError, onLoad, strategy, ...props } =
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
useRecaptcha();

const handleGameStart = async () => {
const captchaToken = await executeRecaptcha("start_game");
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

try {
if (!isValidSession(sessionData)) {
await signIn("credentials", { callbackUrl: pages.lobby });
await signIn("credentials", {
callbackUrl: pages.lobby,
});
} else {
await push(pages.lobby);
const result = await verifyCaptcha.mutateAsync({ captchaToken });
if (!result) {
void push(pages.lobby);
} else {
setIsRecaptchaFailed(true);
return;
}
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
}
} catch (error) {
console.error(error);
Expand All @@ -38,6 +53,11 @@ export const Homepage = () => {
<Stack sx={styles.description}>
<Typography variant="h5">{text.homepage.descriptionPart1}</Typography>
<Typography variant="h5">{text.homepage.descriptionPart2}</Typography>
{isRecaptchaFailed && (
<Typography variant="h6" color={"error"}>
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
{text.homepage.botDetected}
</Typography>
)}
</Stack>
<Stack sx={styles.actions}>
<Button
Expand All @@ -61,6 +81,14 @@ export const Homepage = () => {
</Button>
</Stack>
<TopRanked players={TOP_RANKED_PLAYERS} />
<Script
id={id}
src={src}
strategy={strategy}
onLoad={onLoad}
onError={onError}
{...props}
/>
</Stack>
);
};
1 change: 1 addition & 0 deletions src/containers/home/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./home.jsx";
4 changes: 4 additions & 0 deletions src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const env = createEnv({
.string()
.default("5")
.transform((val) => Number(val)),
RECAPTCHA_SECRET_KEY: z.string().min(1),
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
},

/**
Expand All @@ -38,6 +39,7 @@ export const env = createEnv({
.enum(["true", "false"])
.default("false")
.transform((val) => (val === "true" ? true : false)),
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: z.string().min(1),
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
},

/**
Expand All @@ -55,6 +57,8 @@ export const env = createEnv({
NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL,
NEXT_PUBLIC_MOCK_AUTH: process.env.NEXT_PUBLIC_MOCK_AUTH,
PLAYERS_PER_MATCH: process.env.PLAYERS_PER_MATCH,
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
Expand Down
2 changes: 1 addition & 1 deletion src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Homepage } from "~/containers/home/index.jsx";
import { Homepage } from "~/containers/home/index.js";

export default function Home() {
return <Homepage />;
Expand Down
8 changes: 7 additions & 1 deletion src/server/api/root.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { userRouter, matchRouter, lobbyRouter } from "./routers/index.js";
import {
lobbyRouter,
matchRouter,
recaptchaRouter,
userRouter,
} from "./routers/index.js";
import { createTRPCRouter } from "./trpc.js";

/**
Expand All @@ -10,6 +15,7 @@ export const appRouter = createTRPCRouter({
lobby: lobbyRouter,
match: matchRouter,
user: userRouter,
recaptcha: recaptchaRouter,
});

// export type definition of API
Expand Down
1 change: 1 addition & 0 deletions src/server/api/routers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./lobby.js";
export * from "./match.js";
export * from "./user.js";
export * from "./recaptcha.js";
55 changes: 55 additions & 0 deletions src/server/api/routers/recaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env.mjs";
import { type RecaptchaResponse } from "~/types/recaptcha";

const SCORE_THRESHOLD = 0.5;
export const recaptchaRouter = createTRPCRouter({
verify: protectedProcedure
.input(z.object({ captchaToken: z.string() }))
.mutation(async ({ input }) => {
const { captchaToken } = input;

// Check if it's not in production (development mode)
if (env.NODE_ENV !== "production") {
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

try {
// Verify the Google reCaptcha token v3
const response = await fetch(
"https://www.google.com/recaptcha/api/siteverify",
{
method: "POST",
headers: {
"Content-Type":
"application/x-www-form-urlencoded; charset=utf-8",
},
body: `secret=${env.RECAPTCHA_SECRET_KEY}&response=${captchaToken}`,
},
);

/**
* The structure of response from the verify API is
* {
* "success": true | false, // whether this request was a valid reCAPTCHA token for your site
* "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
* "hostname": string, // the hostname of the site where the reCAPTCHA was solved
* "error-codes": [...] // optional
}
*/
const json = (await response.json()) as RecaptchaResponse;

if (!json.success) {
console.log(`Recaptcha token is not valid`);
throw new TRPCError({ code: "BAD_REQUEST" });
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
}

// If the score is below < SCORE_THRESHOLD, return true
return json.score < SCORE_THRESHOLD;
} catch (error) {
console.log(`Recaptcha cannot be verified`);
}
}),
});
1 change: 1 addition & 0 deletions src/service/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./profanity-filter/index.js";
export * from "./recaptcha/index.js";
1 change: 1 addition & 0 deletions src/service/recaptcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./recaptcha.js";
Loading