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 11 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.

Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@testing-library/react": "^14.0.0",
"@types/bad-words": "^3.0.3",
"@types/eslint": "^8.44.2",
"@types/grecaptcha": "^3.0.7",
"@types/jest": "^29.5.6",
"@types/lodash": "^4.14.202",
"@types/node": "^18.16.0",
Expand Down
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.",
};
12 changes: 11 additions & 1 deletion src/components/menu-dialog/menu-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ 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";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

const Transition = forwardRef(function Transition(
props: TransitionProps & {
Expand All @@ -24,6 +25,12 @@ interface Props {
}

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

const goHome = () => {
void push(pages.home);
setOpen(false);
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
};
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
const handleClose = () => {
setOpen(false);
};
Expand All @@ -37,6 +44,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
25 changes: 21 additions & 4 deletions src/containers/home/index.tsx → src/containers/home/home.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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";

Expand All @@ -9,20 +9,32 @@ import { api } from "~/utils/api.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 { useReCaptcha } from "~/service/use-reCaptcha.js";
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 } = 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 +50,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 Down
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";
2 changes: 2 additions & 0 deletions src/containers/reCaptcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./reCaptcha";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
export * from "./utils.js";
152 changes: 152 additions & 0 deletions src/containers/reCaptcha/reCaptcha.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"use client";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

import React, {
createContext,
useCallback,
useContext,
useDebugValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Script, { type ScriptProps } from "next/script.js";

import { getRecaptchaScriptSrc } from "./utils.js";
import { type IReCaptcha } from "~/types/recaptcha";
import { env } from "~/env.mjs";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

interface ReCaptchaContextProps {
/** reCAPTCHA_site_key */
readonly reCaptchaKey: string | null;
/** Global ReCaptcha object */
readonly grecaptcha: IReCaptcha | null;
/** Is ReCaptcha script loaded */
readonly loaded: boolean;
/** Is ReCaptcha failed to load */
readonly error: boolean;
}

const ReCaptchaContext = createContext<ReCaptchaContextProps>({
reCaptchaKey: null,
grecaptcha: null,
loaded: false,
error: false,
});

const useReCaptchaContext = () => {
const values = useContext(ReCaptchaContext);
useDebugValue(`grecaptcha available: ${values?.loaded ? "Yes" : "No"}`);
useDebugValue(
`ReCaptcha Script: ${values?.loaded ? "Loaded" : "Not Loaded"}`,
);
useDebugValue(`Failed to load Script: ${values?.error ? "Yes" : "No"}`);
return values;
};

interface ReCaptchaProviderProps extends Partial<Omit<ScriptProps, "onLoad">> {
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
reCaptchaKey?: string;
language?: string;
useRecaptchaNet?: boolean;
useEnterprise?: boolean;
children?: React.ReactNode;
onLoad?: (grecaptcha: IReCaptcha, e: undefined) => void;
}

const ReCaptchaProvider: React.FC<ReCaptchaProviderProps> = ({
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
reCaptchaKey: passedReCaptchaKey,

useEnterprise = false,
useRecaptchaNet = false,
language,
children,

id = "google-recaptcha-v3",
strategy = "beforeInteractive",

src: passedSrc,
onLoad: passedOnLoad,
onError: passedOnError,

...props
}) => {
const [grecaptcha, setGreCaptcha] = useState<IReCaptcha | null>(null);
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);

const reCaptchaKey =
(passedReCaptchaKey ?? env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY) || "";

const src =
passedSrc ??
getRecaptchaScriptSrc({
reCaptchaKey,
language,
useRecaptchaNet,
useEnterprise,
});

// Reset state when script src is changed
const mounted = useRef(false);

useEffect(() => {
if (mounted.current) {
setLoaded(false);
setError(false);
}
mounted.current = true;
}, [src]);

// Handle script load
const onLoad = useCallback(
(e?: never) => {
const grecaptcha = useEnterprise
? window?.grecaptcha?.enterprise
: window?.grecaptcha;

if (grecaptcha) {
grecaptcha.ready(() => {
setGreCaptcha(grecaptcha);
setLoaded(true);
passedOnLoad?.(grecaptcha, e);
});
}
},
[passedOnLoad, useEnterprise],
);

// Run 'onLoad' function once just in case if grecaptcha is already globally available in window
useEffect(() => onLoad(), [onLoad]);
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

// Handle script error
const onError = useCallback(
(e: ErrorEvent) => {
setError(true);
passedOnError?.(e);
},
[passedOnError],
);

// Prevent unnecessary rerenders
const value = useMemo(
() => ({ reCaptchaKey, grecaptcha, loaded, error }),
[reCaptchaKey, grecaptcha, loaded, error],
);

return (
<ReCaptchaContext.Provider value={value}>
{children}
<Script
id={id}
src={src}
strategy={strategy}
onLoad={onLoad}
onError={onError}
{...props}
/>
</ReCaptchaContext.Provider>
);
};

export { ReCaptchaContext, useReCaptchaContext, ReCaptchaProvider };
export type { ReCaptchaContextProps, ReCaptchaProviderProps };
32 changes: 32 additions & 0 deletions src/containers/reCaptcha/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

import { useEffect, useLayoutEffect } from "react";

/**
* Function to generate the src for the script tag
* Refs: https://developers.google.com/recaptcha/docs/loading
*/
export const getRecaptchaScriptSrc = ({
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
reCaptchaKey,
language,
useRecaptchaNet = false,
useEnterprise = false,
}: {
reCaptchaKey?: string;
language?: string;
useRecaptchaNet?: boolean;
useEnterprise?: boolean;
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use prefix should be reserved for hooks

} = {}): string => {
const hostName = useRecaptchaNet ? "recaptcha.net" : "google.com";
const script = useEnterprise ? "enterprise.js" : "api.js";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use union types instead of booleans e.g. type CaptchaHost = "recaptcha.net" | "google.com" this way it's more scalable and it's not hardcoded since you limit the input to these n amount of strings


let src = `https://www.${hostName}/recaptcha/${script}?`;
if (reCaptchaKey) src += `render=${reCaptchaKey}`;
if (language) src += `&hl=${language}`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if reCaptchaKey is false and language is true? you get a ?&hl in your URL which is theoretically invalid


return src;
};

// https://usehooks-ts.com/react-hook/use-isomorphic-layout-effect
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
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
9 changes: 7 additions & 2 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Homepage } from "~/containers/home/index.jsx";
import { Homepage } from "~/containers/home/index.js";
import { ReCaptchaProvider } from "~/containers/reCaptcha";
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved

export default function Home() {
return <Homepage />;
return (
<ReCaptchaProvider>
privilegemendes marked this conversation as resolved.
Show resolved Hide resolved
<Homepage />
</ReCaptchaProvider>
);
}
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";
Loading