diff --git a/.vscode/settings.json b/.vscode/settings.json index 56a51f78..c59304cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "python.formatting.provider": "black", - "python.analysis.extraPaths": ["${workspaceFolder}/oasst-shared"] + "python.analysis.extraPaths": ["${workspaceFolder}/oasst-shared"], + "prettier.singleQuote": false } diff --git a/docker-compose.yaml b/docker-compose.yaml index 908457cd..4376e25d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -125,6 +125,8 @@ services: - EMAIL_FROM=info@example.com - NEXTAUTH_URL=http://localhost:3000 - DEBUG_LOGIN=true + - NEXT_PUBLIC_CLOUDFARE_CAPTCHA_SITE_KEY=1x00000000000000000000AA + - CLOUDFLARE_CAPTCHA_SERCERT_KEY=1x0000000000000000000000000000000AA depends_on: webdb: condition: service_healthy diff --git a/website/.env b/website/.env index 4bbfe001..a5bb7e0a 100644 --- a/website/.env +++ b/website/.env @@ -15,4 +15,5 @@ EMAIL_SERVER_HOST=localhost EMAIL_SERVER_PORT=1025 EMAIL_FROM=info@example.com -NEXT_PUBLIC_CLOUDFARE_CAPTCHA_SITE_KEY=1x0000000000000000000000000000000AA +NEXT_PUBLIC_CLOUDFLARE_CAPTCHA_SITE_KEY=1x00000000000000000000AA +CLOUDFLARE_CAPTCHA_SERCERT_KEY=1x0000000000000000000000000000000AA diff --git a/website/src/components/CloudfareCaptcha.tsx b/website/src/components/CloudfareCaptcha.tsx deleted file mode 100644 index d4d827f4..00000000 --- a/website/src/components/CloudfareCaptcha.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Turnstile } from "@marsidev/react-turnstile"; -import { forwardRef } from "react"; - -export const CloudFareCatpcha = forwardRef((ref, props) => { - return ; -}); - -CloudFareCatpcha.displayName = "CloudFareCatpcha"; diff --git a/website/src/components/CloudflareCaptcha.tsx b/website/src/components/CloudflareCaptcha.tsx new file mode 100644 index 00000000..ad0104f5 --- /dev/null +++ b/website/src/components/CloudflareCaptcha.tsx @@ -0,0 +1,20 @@ +import { useColorMode } from "@chakra-ui/react"; +import { Turnstile, TurnstileInstance, TurnstileProps } from "@marsidev/react-turnstile"; +import { forwardRef } from "react"; + +export const CloudFlareCatpcha = forwardRef>((props, ref) => { + const { colorMode } = useColorMode(); + return ( + + ); +}); + +CloudFlareCatpcha.displayName = "CloudFlareCatpcha"; diff --git a/website/src/lib/captcha.ts b/website/src/lib/captcha.ts new file mode 100644 index 00000000..2eb4f0a6 --- /dev/null +++ b/website/src/lib/captcha.ts @@ -0,0 +1,56 @@ +type CaptchaErrorCode = + | "missing-input-secret" + | "invalid-input-secret" + | "missing-input-response" + | "invalid-input-response" + | "bad-request" + | "timeout-or-duplicate" + | "internal-error"; + +type CheckCaptchaResponse = { + success: boolean; + challenge_ts?: string; + hostname: string; + "error-codes": CaptchaErrorCode[]; + action?: string; + cdata?: string; +}; + +// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ +export const checkCaptcha = async ( + token: string, + ipAdress: string, + options?: { cdata?: string; action?: string } +): Promise => { + const data = new FormData(); + + data.append("secret", process.env.CLOUDFLARE_CAPTCHA_SERCERT_KEY); + data.append("response", token); + data.append("remoteip", ipAdress); + + const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + body: data, + method: "POST", + }); + + const res: CheckCaptchaResponse = await result.json(); + return { + ...res, + success: getSuccess(res, options?.action, options?.cdata), + }; +}; + +const getSuccess = (response: CheckCaptchaResponse, action: string | undefined, cdata: string | undefined) => { + if (action === undefined && cdata === undefined) { + return response.success; + } + + if (action) { + if (cdata) { + return response.action === action && response.cdata === cdata; + } + return response.action === action; + } + + return false; +}; diff --git a/website/src/middleware.ts b/website/src/middleware.ts index 21eeaaa7..87651301 100644 --- a/website/src/middleware.ts +++ b/website/src/middleware.ts @@ -1,5 +1,7 @@ -export { default } from "next-auth/middleware"; +import { NextResponse } from "next/server"; +import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; +import { checkCaptcha } from "./lib/captcha"; /** * Guards these pages and redirects them to the sign in page. */ @@ -14,5 +16,26 @@ export const config = { "/tasks/:path*", "/leaderboard", "/messages/:path*", + "/api/auth/signin/email", ], }; + +const middleware = async (req: NextRequestWithAuth) => { + if (req.method === "POST" && req.nextUrl.pathname === "/api/auth/signin/email") { + const data = await req.formData(); + const res = await checkCaptcha(data.get("captcha").toString(), req.ip); + + if (res.success) { + return NextResponse.next(); + } + + const url = req.nextUrl.clone(); + url.pathname = "/api/auth/invalid-captcha"; + + return NextResponse.redirect(url); + } + + return withAuth(req); +}; + +export default middleware; diff --git a/website/src/pages/api/auth/invalid-captcha.ts b/website/src/pages/api/auth/invalid-captcha.ts new file mode 100644 index 00000000..86fe7eab --- /dev/null +++ b/website/src/pages/api/auth/invalid-captcha.ts @@ -0,0 +1,7 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(_: NextApiRequest, res: NextApiResponse) { + return res.status(200).json({ + url: "/auth/signin?error=InvalidCaptcha", + }); +} diff --git a/website/src/pages/auth/signin.tsx b/website/src/pages/auth/signin.tsx index d171182d..3036a0b9 100644 --- a/website/src/pages/auth/signin.tsx +++ b/website/src/pages/auth/signin.tsx @@ -1,5 +1,6 @@ import { Button, ButtonProps, Input, Stack, useColorModeValue } from "@chakra-ui/react"; import { useColorMode } from "@chakra-ui/react"; +import { TurnstileInstance } from "@marsidev/react-turnstile"; import { Bug, Github, Mail } from "lucide-react"; import { GetServerSideProps } from "next"; import Head from "next/head"; @@ -7,9 +8,10 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { ClientSafeProvider, getProviders, signIn } from "next-auth/react"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { AuthLayout } from "src/components/AuthLayout"; +import { CloudFlareCatpcha } from "src/components/CloudflareCaptcha"; import { Footer } from "src/components/Footer"; import { Header } from "src/components/Header"; import { Discord } from "src/components/Icons/Discord"; @@ -26,6 +28,7 @@ export type SignInErrorTypes = | "EmailSignin" | "CredentialsSignin" | "SessionRequired" + | "InvalidCaptcha" | "default"; const errorMessages: Record = { @@ -39,6 +42,7 @@ const errorMessages: Record = { EmailSignin: "The e-mail could not be sent.", CredentialsSignin: "Sign in failed. Check the details you provided are correct.", SessionRequired: "Please sign in to access this page.", + InvalidCaptcha: "Invalid captcha", default: "Unable to sign in.", }; @@ -62,14 +66,20 @@ function Signin({ providers }: SigninProps) { } }, [router]); - const signinWithEmail = (data: { email: string }) => { - signIn(email.id, { callbackUrl: "/dashboard", email: data.email }); + const signinWithEmail = async (data: { email: string }) => { + const res = await signIn(email.id, { + callbackUrl: "/dashboard", + email: data.email, + captcha: captcha.current?.getResponse(), + }); + console.log(res); }; const { colorMode } = useColorMode(); const bgColorClass = colorMode === "light" ? "bg-gray-50" : "bg-chakra-gray-900"; const buttonBgColor = colorMode === "light" ? "#2563eb" : "#2563eb"; const { register, handleSubmit } = useForm<{ email: string }>(); + const captcha = useRef(); return (
@@ -90,7 +100,8 @@ function Signin({ providers }: SigninProps) { placeholder="Email Address" {...register("email")} /> - }> + + } mt="4"> Continue with Email diff --git a/website/types/env.d.ts b/website/types/env.d.ts index 959662db..79fc51fc 100644 --- a/website/types/env.d.ts +++ b/website/types/env.d.ts @@ -2,7 +2,10 @@ declare global { namespace NodeJS { interface ProcessEnv { NODE_ENV: "development" | "production"; - NEXT_PUBLIC_CLOUDFARE_CAPTCHA_SITE_KEY: string; + NEXT_PUBLIC_CLOUDFLARE_CAPTCHA_SITE_KEY: string; + CLOUDFLARE_CAPTCHA_SERCERT_KEY: string; } } } + +export {};