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 b9c9c82f..9a0202e7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -125,6 +125,9 @@ 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 + - NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA=true depends_on: webdb: condition: service_healthy diff --git a/website/.env b/website/.env index 18cbfcde..e81374e7 100644 --- a/website/.env +++ b/website/.env @@ -17,3 +17,7 @@ NEXTAUTH_SECRET=O/M2uIbGj+lDD2oyNa8ax4jEOJqCPJzO53UbWShmq98= EMAIL_SERVER_HOST=localhost EMAIL_SERVER_PORT=1025 EMAIL_FROM=info@example.com + +NEXT_PUBLIC_CLOUDFLARE_CAPTCHA_SITE_KEY=1x00000000000000000000AA +CLOUDFLARE_CAPTCHA_SERCERT_KEY=1x0000000000000000000000000000000AA +NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA=false diff --git a/website/cypress/e2e/auth/signin.cy.ts b/website/cypress/e2e/auth/signin.cy.ts index 2a651f1f..9d6e63e1 100644 --- a/website/cypress/e2e/auth/signin.cy.ts +++ b/website/cypress/e2e/auth/signin.cy.ts @@ -14,11 +14,16 @@ describe("signin flow", () => { cy.request("GET", "/api/auth/csrf") .then((response) => { const csrfToken = response.body.csrfToken; - cy.request("POST", "/api/auth/signin/email", { - callbackUrl: "/", - email: emailAddress, - csrfToken, - json: "true", + cy.request({ + method: "POST", + url: "/api/auth/signin/email", + body: { + callbackUrl: "/", + email: emailAddress, + csrfToken, + json: "true", + captcha: "XXXX.DUMMY.TOKEN.XXXX", + }, }); }) .then((response) => { diff --git a/website/cypress/support/commands.ts b/website/cypress/support/commands.ts index 2507116d..b2ce81f6 100644 --- a/website/cypress/support/commands.ts +++ b/website/cypress/support/commands.ts @@ -54,11 +54,16 @@ Cypress.Commands.add("signInWithEmail", (emailAddress) => { cy.request("GET", "/api/auth/csrf") .then((response) => { const csrfToken = response.body.csrfToken; - cy.request("POST", "/api/auth/signin/email", { - callbackUrl: "/", - email: emailAddress, - csrfToken, - json: "true", + cy.request({ + method: "POST", + url: "/api/auth/signin/email", + body: { + callbackUrl: "/", + email: emailAddress, + csrfToken, + json: "true", + captcha: "XXXX.DUMMY.TOKEN.XXXX", + }, }); }) .then(() => { diff --git a/website/package-lock.json b/website/package-lock.json index 0c92c0db..41167055 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -17,6 +17,7 @@ "@emotion/styled": "^11.10.5", "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.13", + "@marsidev/react-turnstile": "^0.0.7", "@next-auth/prisma-adapter": "^1.0.5", "@next/font": "^13.1.0", "@prisma/client": "^4.7.1", @@ -5544,6 +5545,15 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-0.0.7.tgz", + "integrity": "sha512-BWXZ6/ddE96cP/U3jkLO8wbJi6qOpE4wH67h6EkD57TRy4RSauBBYKRZkuUEpfgm2wdWoPukPz4LfnoV5KJGrQ==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@mdx-js/mdx": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", @@ -42320,6 +42330,12 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@marsidev/react-turnstile": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-0.0.7.tgz", + "integrity": "sha512-BWXZ6/ddE96cP/U3jkLO8wbJi6qOpE4wH67h6EkD57TRy4RSauBBYKRZkuUEpfgm2wdWoPukPz4LfnoV5KJGrQ==", + "requires": {} + }, "@mdx-js/mdx": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", diff --git a/website/package.json b/website/package.json index 25fee307..8c495a2c 100644 --- a/website/package.json +++ b/website/package.json @@ -34,6 +34,7 @@ "@emotion/styled": "^11.10.5", "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.13", + "@marsidev/react-turnstile": "^0.0.7", "@next-auth/prisma-adapter": "^1.0.5", "@next/font": "^13.1.0", "@prisma/client": "^4.7.1", diff --git a/website/src/components/CloudflareCaptcha.tsx b/website/src/components/CloudflareCaptcha.tsx new file mode 100644 index 00000000..550e7e25 --- /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 CloudFlareCaptcha = forwardRef>((props, ref) => { + const { colorMode } = useColorMode(); + return ( + + ); +}); + +CloudFlareCaptcha.displayName = "CloudFlareCaptcha"; diff --git a/website/src/lib/captcha.ts b/website/src/lib/captcha.ts new file mode 100644 index 00000000..384f69dd --- /dev/null +++ b/website/src/lib/captcha.ts @@ -0,0 +1,57 @@ +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), + }; +}; + +// This function hasn't been tested yet, Cloudflare doesn't send `action` and `cdata` with a demo key. +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/pages/api/auth/[...nextauth].ts b/website/src/pages/api/auth/[...nextauth].ts index 7a5ddbe4..5a316256 100644 --- a/website/src/pages/api/auth/[...nextauth].ts +++ b/website/src/pages/api/auth/[...nextauth].ts @@ -1,11 +1,13 @@ import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { boolean } from "boolean"; +import { NextApiRequest, NextApiResponse } from "next"; import type { AuthOptions } from "next-auth"; import NextAuth from "next-auth"; import { Provider } from "next-auth/providers"; import CredentialsProvider from "next-auth/providers/credentials"; import DiscordProvider from "next-auth/providers/discord"; import EmailProvider from "next-auth/providers/email"; +import { checkCaptcha } from "src/lib/captcha"; import prisma from "src/lib/prismadb"; import { generateUsername } from "unique-username-generator"; @@ -74,7 +76,7 @@ const adminUserMap = process.env.ADMIN_USERS.split(",").reduce((result, entry) = return result; }, new Map()); -export const authOptions: AuthOptions = { +const authOptions: AuthOptions = { // Ensure we can store user data in a database. adapter: PrismaAdapter(prisma), providers, @@ -148,24 +150,41 @@ export const authOptions: AuthOptions = { } }, }, - /* - * We maybe need this, we maybe don't. Checking in this uncommented until - * it's confirmed we can drop this. - cookies: { - sessionToken: { - name: `next-auth.session-token`, - options: { - httpOnly: true, - sameSite: "none", - path: "/", - secure: true, - }, - }, - }, - */ session: { strategy: "jwt", }, }; -export default NextAuth(authOptions); +export default function auth(req: NextApiRequest, res: NextApiResponse) { + return NextAuth(req, res, { + ...authOptions, + callbacks: { + ...authOptions.callbacks, + async signIn({ account }) { + if (account.provider !== "email" || !boolean(process.env.NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA)) { + return true; + } + + const captcha = req.body.captcha; + + const res = await checkCaptcha(captcha, getIp(req)); + + if (res.success) { + return true; + } + + return "/auth/signin?error=InvalidCaptcha"; + }, + }, + }); +} + +const getIp = (req: NextApiRequest) => { + try { + // https://stackoverflow.com/questions/66111742/get-the-client-ip-on-nextjs-and-use-ssr + const forwarded = req.headers["x-forwarded-for"]; + return typeof forwarded === "string" ? forwarded.split(/, /)[0] : req.socket.remoteAddress; + } catch { + return ""; + } +}; diff --git a/website/src/pages/auth/signin.tsx b/website/src/pages/auth/signin.tsx index d171182d..b65fa742 100644 --- a/website/src/pages/auth/signin.tsx +++ b/website/src/pages/auth/signin.tsx @@ -1,15 +1,18 @@ import { Button, ButtonProps, Input, Stack, useColorModeValue } from "@chakra-ui/react"; import { useColorMode } from "@chakra-ui/react"; +import { TurnstileInstance } from "@marsidev/react-turnstile"; +import { boolean } from "boolean"; import { Bug, Github, Mail } from "lucide-react"; import { GetServerSideProps } from "next"; import Head from "next/head"; import Link from "next/link"; import { useRouter } from "next/router"; -import { ClientSafeProvider, getProviders, signIn } from "next-auth/react"; +import { 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 { CloudFlareCaptcha } 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 +29,7 @@ export type SignInErrorTypes = | "EmailSignin" | "CredentialsSignin" | "SessionRequired" + | "InvalidCaptcha" | "default"; const errorMessages: Record = { @@ -39,6 +43,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 +67,10 @@ function Signin({ providers }: SigninProps) { } }, [router]); - const signinWithEmail = (data: { email: string }) => { - signIn(email.id, { callbackUrl: "/dashboard", email: data.email }); - }; - 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 }>(); + return (
@@ -78,24 +79,8 @@ function Signin({ providers }: SigninProps) { - {credentials && } - {email && ( -
- - - }> - Continue with Email - - -
- )} + {credentials && } + {email && } {discord && (