From d7972040784076438a9e5fb03483852d5e5bbc68 Mon Sep 17 00:00:00 2001 From: incognitotgt Date: Sat, 20 Jul 2024 23:58:50 -0400 Subject: [PATCH] feat(auth/v1.0): add cloudflare turnstile support for auth --- .config/config-schema.json | 14 ++++++++++++ next.config.mjs | 4 ++-- package.json | 1 + pnpm-lock.yaml | 14 ++++++++++++ src/app/auth/login/page.tsx | 13 +++++++++-- src/app/auth/signup/page.tsx | 43 +++++++++++++++++++++++++----------- src/app/layout.tsx | 2 +- src/components/turnstile.tsx | 4 ++++ src/lib/turnstile.ts | 22 ++++++++++++++++++ src/types/config.d.ts | 7 ++++++ 10 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 src/components/turnstile.tsx create mode 100644 src/lib/turnstile.ts diff --git a/.config/config-schema.json b/.config/config-schema.json index 40da2df..ae77b28 100644 --- a/.config/config-schema.json +++ b/.config/config-schema.json @@ -52,6 +52,20 @@ "secret": { "description": "The JWT secret used to sign tokens.", "type": "string" + }, + "turnstile": { + "additionalProperties": false, + "description": "Cloudflare turnstile configuration. Leave `undefined` to disable turnstile.", + "properties": { + "secret": { + "type": "string" + }, + "siteKey": { + "type": "string" + } + }, + "required": ["secret", "siteKey"], + "type": "object" } }, "required": ["secret"], diff --git a/next.config.mjs b/next.config.mjs index bf7e3b2..68661ea 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,5 @@ -import { execSync } from "node:child_process"; // @ts-check +import { execSync } from "node:child_process"; import NextBundleAnalyzer from "@next/bundle-analyzer"; /** @type {import('next').NextConfig} */ const nextConfig = { @@ -16,6 +16,7 @@ const nextConfig = { env: { GIT_COMMIT: process.env.NODE_ENV === "production" ? execSync("git rev-parse HEAD").toString().trim() : "DEVELOP", BUILD_DATE: Date.now().toString(), + TURNSTILE_SITEKEY: JSON.parse(process.env.CONFIG || "").auth.turnstile?.siteKey, }, experimental: { ppr: true, @@ -50,5 +51,4 @@ const nextConfig = { return config; }, }; - export default NextBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(nextConfig); diff --git a/package.json b/package.json index 2888b03..e758afc 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "config:generate": "ts-json-schema-generator --tsconfig ./tsconfig.json --path ./src/types/config.d.ts --type Config > ./.config/config-schema.json" }, "dependencies": { + "@marsidev/react-turnstile": "^0.7.2", "@next/bundle-analyzer": "^14.2.3", "@novnc/novnc": "^1.4.0", "@paralleldrive/cuid2": "^2.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d006a9..1f67a18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@marsidev/react-turnstile': + specifier: ^0.7.2 + version: 0.7.2(react-dom@19.0.0-rc-163365a0-20240717(react@19.0.0-rc-163365a0-20240717))(react@19.0.0-rc-163365a0-20240717) '@next/bundle-analyzer': specifier: ^14.2.3 version: 14.2.3 @@ -937,6 +940,12 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@marsidev/react-turnstile@0.7.2': + resolution: {integrity: sha512-0jwLvAUkcLkaYaS6jBOZB3zzUiKi5dU3kZtlaeBX6yV7Y4CbFEtfHCY352ovphNz1v0ZjpOj6+3QUczJvD56VA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@next/bundle-analyzer@14.2.3': resolution: {integrity: sha512-Z88hbbngMs7njZKI8kTJIlpdLKYfMSLwnsqYe54AP4aLmgL70/Ynx/J201DQ+q2Lr6FxFw1uCeLGImDrHOl2ZA==} @@ -3545,6 +3554,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@marsidev/react-turnstile@0.7.2(react-dom@19.0.0-rc-163365a0-20240717(react@19.0.0-rc-163365a0-20240717))(react@19.0.0-rc-163365a0-20240717)': + dependencies: + react: 19.0.0-rc-163365a0-20240717 + react-dom: 19.0.0-rc-163365a0-20240717(react@19.0.0-rc-163365a0-20240717) + '@next/bundle-analyzer@14.2.3': dependencies: webpack-bundle-analyzer: 4.10.1 diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 36ebb1f..99bab3c 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,4 +1,5 @@ import { StyledSubmit } from "@/components/submit-button"; +import Turnstile from "@/components/turnstile"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { CardContent, CardDescription } from "@/components/ui/card"; @@ -7,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { auth, signIn } from "@/lib/auth"; import { providers } from "@/lib/auth.config"; import { getConfig } from "@/lib/config"; +import turnstileCheck from "@/lib/turnstile"; import { AlertCircle, Info } from "lucide-react"; import type { CredentialsSignin } from "next-auth"; import Link from "next/link"; @@ -43,9 +45,15 @@ export default async function Login({ action={async (data) => { "use server"; try { - await signIn("credentials", data); + if (await turnstileCheck(data)) { + await signIn("credentials", data); + } else { + throw new Error("Failed captcha"); + } } catch (error) { - redirect(`/auth/login?error=${(error as CredentialsSignin).cause?.err?.message}`); + redirect( + `/auth/login?error=${(error as CredentialsSignin).cause?.err?.message || (error as Error).message}`, + ); } }} > @@ -69,6 +77,7 @@ export default async function Login({ required className="w-full" /> + Log in ) : null} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 5308bd9..fb620be 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,4 +1,5 @@ import { StyledSubmit } from "@/components/submit-button"; +import Turnstile from "@/components/turnstile"; import { Alert, AlertTitle } from "@/components/ui/alert"; import { CardContent, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -6,10 +7,11 @@ import { Label } from "@/components/ui/label"; import { auth } from "@/lib/auth"; import { getConfig } from "@/lib/config"; import { db, user } from "@/lib/drizzle/db"; +import turnstileCheck from "@/lib/turnstile"; import { createId } from "@paralleldrive/cuid2"; import { hash } from "argon2"; import { Info } from "lucide-react"; -import { redirect } from "next/navigation"; +import { redirect, unstable_rethrow } from "next/navigation"; export default async function Page({ searchParams, @@ -19,7 +21,7 @@ export default async function Page({ const session = await auth(); if (session) redirect("/"); const config = getConfig(); - const { message } = searchParams; + const { message, error } = searchParams; if (!config.auth.credentials || !config.auth.credentials.signups) redirect(`/auth/error?error=${encodeURIComponent("Signups are disabled for this instance.")}`); return ( @@ -31,21 +33,35 @@ export default async function Page({ {message} ) : null} + {error ? ( + + + {error} + + ) : null}
{ "use server"; - const userCheck = await db.query.user.findFirst({ - where: (user, { eq }) => eq(user.email, data.get("email")?.toString() || ""), - }); - if (userCheck) redirect("/auth/login?error=Email%20already%20in%20use"); - await db.insert(user).values({ - name: data.get("name")?.toString(), - email: data.get("email")?.toString() as string, - password: await hash(data.get("password")?.toString() as string), - id: createId(), - }); - redirect("/auth/login?message=Account%20created%20successfully"); + try { + if (!(await turnstileCheck(data))) { + redirect("/auth/signup?error=Failed%captcha"); + } + const userCheck = await db.query.user.findFirst({ + where: (user, { eq }) => eq(user.email, data.get("email")?.toString() || ""), + }); + if (userCheck) redirect("/auth/login?error=Email%20already%20in%20use"); + await db.insert(user).values({ + name: data.get("name")?.toString(), + email: data.get("email")?.toString() as string, + password: await hash(data.get("password")?.toString() as string), + id: createId(), + }); + redirect("/auth/login?message=Account%20created%20successfully"); + } catch (e) { + unstable_rethrow(e); + throw e; + } }} > @@ -71,6 +87,7 @@ export default async function Page({ required className="w-full" /> + Sign up diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3c781cb..9c3610a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -34,7 +34,7 @@ export function generateMetadata(): Metadata { : undefined, }; } -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; diff --git a/src/components/turnstile.tsx b/src/components/turnstile.tsx new file mode 100644 index 0000000..147e0ec --- /dev/null +++ b/src/components/turnstile.tsx @@ -0,0 +1,4 @@ +import { Turnstile as BaseTurnstile } from "@marsidev/react-turnstile"; +export default function Turnstile() { + return process.env.TURNSTILE_SITEKEY ? : null; +} diff --git a/src/lib/turnstile.ts b/src/lib/turnstile.ts new file mode 100644 index 0000000..04b7bb0 --- /dev/null +++ b/src/lib/turnstile.ts @@ -0,0 +1,22 @@ +import { getConfig } from "./config"; + +export default async function turnstileCheck(data: FormData) { + const config = getConfig(); + if (!config.auth.turnstile) return true; + const key = data.get("cf-turnstile-response")?.toString(); + const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + body: JSON.stringify({ + secret: config.auth.turnstile.secret, + response: key, + }), + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + const outcome = await result.json(); + if (outcome.success) { + return true; + } + return false; +} diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 4cbf74f..0fc7727 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -42,6 +42,13 @@ export interface AuthConfig { * The JWT secret used to sign tokens. **/ secret: string; + /** + * Cloudflare turnstile configuration. Leave `undefined` to disable turnstile. + **/ + turnstile?: { + secret: string; + siteKey: string; + }; /** * Credentials configuration. Leave `undefined` to disable user/password signups. **/