From 698e19d07d6b6499eab4507a0784073d93388f79 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 24 Mar 2026 15:18:02 -0700 Subject: [PATCH 1/5] Turnstile implementation --- .env.example | 4 + common/src/env-schema.ts | 2 + .../app/api/auth/verify-turnstile/route.ts | 78 +++++++++++++++++++ .../web/src/components/login/login-card.tsx | 59 +++++++++++++- .../src/components/sign-in/sign-in-button.tsx | 4 +- .../sign-in/sign-in-card-footer.tsx | 4 +- .../web/src/components/turnstile-widget.tsx | 63 +++++++++++++++ freebuff/web/src/middleware.ts | 67 ++++++++++++++++ freebuff/web/src/types/turnstile.d.ts | 25 ++++++ packages/internal/src/env-schema.ts | 2 + .../app/api/auth/verify-turnstile/route.ts | 78 +++++++++++++++++++ web/src/components/login/login-card.tsx | 64 ++++++++++++++- web/src/components/sign-in/sign-in-button.tsx | 4 +- .../sign-in/sign-in-card-footer.tsx | 8 +- web/src/components/turnstile-widget.tsx | 63 +++++++++++++++ web/src/middleware.ts | 67 ++++++++++++++++ web/src/types/turnstile.d.ts | 25 ++++++ 17 files changed, 607 insertions(+), 10 deletions(-) create mode 100644 freebuff/web/src/app/api/auth/verify-turnstile/route.ts create mode 100644 freebuff/web/src/components/turnstile-widget.tsx create mode 100644 freebuff/web/src/middleware.ts create mode 100644 freebuff/web/src/types/turnstile.d.ts create mode 100644 web/src/app/api/auth/verify-turnstile/route.ts create mode 100644 web/src/components/turnstile-widget.tsx create mode 100644 web/src/middleware.ts create mode 100644 web/src/types/turnstile.d.ts diff --git a/.env.example b/.env.example index a1b46a0b88..ce0fd74103 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ DISCORD_PUBLIC_KEY=dummy_discord_public_key DISCORD_BOT_TOKEN=dummy_discord_bot_token DISCORD_APPLICATION_ID=dummy_discord_app_id +# Cloudflare Turnstile (optional — bot protection on signup) +TURNSTILE_SECRET_KEY=dummy_turnstile_secret_key +NEXT_PUBLIC_TURNSTILE_SITE_KEY=dummy_turnstile_site_key + # Frontend/Public Variables NEXT_PUBLIC_CB_ENVIRONMENT=dev NEXT_PUBLIC_CODEBUFF_APP_URL=http://localhost:3000 diff --git a/common/src/env-schema.ts b/common/src/env-schema.ts index 23eb38f9a4..9c32c950d7 100644 --- a/common/src/env-schema.ts +++ b/common/src/env-schema.ts @@ -11,6 +11,7 @@ export const clientEnvSchema = z.object({ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1), NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(), + NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(), NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000), } satisfies Record<`${typeof CLIENT_ENV_PREFIX}${string}`, any>) export const clientEnvVars = clientEnvSchema.keyof().options @@ -33,5 +34,6 @@ export const clientProcessEnv: ClientInput = { process.env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL, NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID, + NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_WEB_PORT: process.env.NEXT_PUBLIC_WEB_PORT, } diff --git a/freebuff/web/src/app/api/auth/verify-turnstile/route.ts b/freebuff/web/src/app/api/auth/verify-turnstile/route.ts new file mode 100644 index 0000000000..d1ab2b4342 --- /dev/null +++ b/freebuff/web/src/app/api/auth/verify-turnstile/route.ts @@ -0,0 +1,78 @@ +import crypto from 'crypto' + +import { env } from '@codebuff/internal/env' +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + +import type { NextRequest } from 'next/server' + +import { logger } from '@/util/logger' + +const TURNSTILE_VERIFY_URL = + 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + +export async function POST(request: NextRequest) { + const secretKey = env.TURNSTILE_SECRET_KEY + if (!secretKey) { + return NextResponse.json({ success: true }) + } + + const body = await request.json() + const parsed = z.object({ token: z.string().min(1) }).safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { success: false, error: 'Invalid token' }, + { status: 400 }, + ) + } + + const formData = new FormData() + formData.append('secret', secretKey) + formData.append('response', parsed.data.token) + + const ip = + request.headers.get('CF-Connecting-IP') ?? + request.headers.get('X-Forwarded-For') ?? + '' + if (ip) { + formData.append('remoteip', ip) + } + + const result = await fetch(TURNSTILE_VERIFY_URL, { + method: 'POST', + body: formData, + }) + const outcome = (await result.json()) as { + success: boolean + 'error-codes'?: string[] + } + + if (!outcome.success) { + logger.warn( + { errorCodes: outcome['error-codes'] }, + 'Turnstile verification failed', + ) + return NextResponse.json( + { success: false, error: 'Verification failed' }, + { status: 403 }, + ) + } + + const timestamp = Date.now().toString() + const signature = crypto + .createHmac('sha256', env.NEXTAUTH_SECRET) + .update(timestamp) + .digest('hex') + + const response = NextResponse.json({ success: true }) + response.cookies.set('turnstile_verified', `${timestamp}.${signature}`, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + return response +} diff --git a/freebuff/web/src/components/login/login-card.tsx b/freebuff/web/src/components/login/login-card.tsx index a539ea44ff..14c7f1ac4e 100644 --- a/freebuff/web/src/components/login/login-card.tsx +++ b/freebuff/web/src/components/login/login-card.tsx @@ -3,9 +3,10 @@ import Image from 'next/image' import { useSearchParams } from 'next/navigation' import { useSession, signIn } from 'next-auth/react' -import { Suspense } from 'react' +import { Suspense, useCallback, useRef, useState } from 'react' import { SignInCardFooter } from '@/components/sign-in/sign-in-card-footer' +import { TurnstileWidget } from '@/components/turnstile-widget' import { Button } from '@/components/ui/button' import { Card, @@ -15,9 +16,48 @@ import { CardFooter, } from '@/components/ui/card' +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY + export function LoginCard({ authCode }: { authCode?: string | null }) { const { data: session } = useSession() const searchParams = useSearchParams() ?? new URLSearchParams() + const [turnstileVerified, setTurnstileVerified] = useState( + !TURNSTILE_SITE_KEY, + ) + const [turnstileError, setTurnstileError] = useState(null) + const turnstileErrorShownRef = useRef(false) + + const handleTurnstileVerify = useCallback(async (token: string) => { + try { + const response = await fetch('/api/auth/verify-turnstile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + const result = await response.json() + if (result.success) { + setTurnstileVerified(true) + setTurnstileError(null) + turnstileErrorShownRef.current = false + } else { + setTurnstileError('Verification failed. Please refresh and try again.') + } + } catch { + setTurnstileError('Verification failed. Please refresh and try again.') + } + }, []) + + const handleTurnstileError = useCallback((errorCode: string) => { + console.error('Turnstile error:', errorCode) + if (!turnstileErrorShownRef.current) { + turnstileErrorShownRef.current = true + setTurnstileError('Verification error. Please refresh and try again.') + } + }, []) + + const handleTurnstileExpired = useCallback(() => { + setTurnstileVerified(false) + }, []) const handleContinueAsUser = () => { const referralCode = searchParams.get('referral_code') @@ -129,7 +169,22 @@ export function LoginCard({ authCode }: { authCode?: string | null }) { ) : ( - + <> + {TURNSTILE_SITE_KEY && ( + + + {turnstileError && ( +

{turnstileError}

+ )} +
+ )} + + )} diff --git a/freebuff/web/src/components/sign-in/sign-in-button.tsx b/freebuff/web/src/components/sign-in/sign-in-button.tsx index a2d652fa7c..240e7c78b5 100644 --- a/freebuff/web/src/components/sign-in/sign-in-button.tsx +++ b/freebuff/web/src/components/sign-in/sign-in-button.tsx @@ -12,9 +12,11 @@ import type { OAuthProviderType } from 'next-auth/providers/oauth-types' export function SignInButton({ providerName, providerDomain, + disabled, }: { providerName: OAuthProviderType providerDomain: string + disabled?: boolean }) { const [isPending, startTransition] = useTransition() const pathname = usePathname() @@ -52,7 +54,7 @@ export function SignInButton({ return (