From f54a8ed72f022d4ed59ba06050edd78710e0674a Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 16 Apr 2026 14:29:07 +1000 Subject: [PATCH] feat: add turnstile captcha to auth flow (#2703) --- .env.example | 8 ++ apps/remix/app/components/forms/signin.tsx | 31 ++++- apps/remix/app/components/forms/signup.tsx | 35 ++++-- package-lock.json | 11 ++ package.json | 1 + packages/auth/server/routes/email-password.ts | 15 ++- packages/auth/server/types/email-password.ts | 2 + packages/lib/errors/app-error.ts | 2 + .../lib/server-only/captcha/verify-captcha.ts | 107 ++++++++++++++++++ .../lib/server-only/rate-limit/rate-limits.ts | 4 +- packages/tsconfig/process-env.d.ts | 6 + turbo.json | 4 +- 12 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 packages/lib/server-only/captcha/verify-captcha.ts diff --git a/.env.example b/.env.example index d9d430824..f92132f2b 100644 --- a/.env.example +++ b/.env.example @@ -182,6 +182,14 @@ GOOGLE_VERTEX_LOCATION="global" # https://console.cloud.google.com/vertex-ai/studio/settings/api-keys GOOGLE_VERTEX_API_KEY="" +# [[CLOUDFLARE TURNSTILE]] +# OPTIONAL: Cloudflare Turnstile site key (public). When configured, Turnstile challenges +# will be shown on sign-up (visible) and sign-in (invisible) pages. +# See: https://developers.cloudflare.com/turnstile/ +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +# OPTIONAL: Cloudflare Turnstile secret key (server-side verification). +NEXT_PRIVATE_TURNSTILE_SECRET_KEY= + # [[E2E Tests]] E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 27a900276..6a1763d46 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; +import type { TurnstileInstance } from '@marsidev/react-turnstile'; +import { Turnstile } from '@marsidev/react-turnstile'; import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; import { KeyRoundIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; @@ -16,7 +18,8 @@ import { z } from 'zod'; import { authClient } from '@documenso/auth/client'; import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -101,6 +104,10 @@ export const SignInForm = ({ const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled; + const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY'); + const turnstileRef = useRef(null); + const [captchaToken, setCaptchaToken] = useState(null); + const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const redirectPath = useMemo(() => { @@ -217,6 +224,7 @@ export const SignInForm = ({ password, totpCode, backupCode, + captchaToken: captchaToken ?? undefined, redirectPath, }); } catch (err) { @@ -251,6 +259,10 @@ export const SignInForm = ({ AuthenticationErrorCode.InvalidTwoFactorCode, () => msg`The two-factor authentication code provided is incorrect.`, ) + .with( + AppErrorCode.INVALID_CAPTCHA, + () => msg`We were unable to verify that you're human. Please try again.`, + ) .otherwise(() => handleFallbackErrorMessages(error.code)); toast({ @@ -258,6 +270,9 @@ export const SignInForm = ({ description: _(errorMessage), variant: 'destructive', }); + + turnstileRef.current?.reset(); + setCaptchaToken(null); } }; @@ -378,6 +393,18 @@ export const SignInForm = ({ )} /> + {turnstileSiteKey && ( + setCaptchaToken(null)} + options={{ + size: 'invisible', + }} + /> + )} +