mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: add turnstile captcha to auth flow (#2703)
This commit is contained in:
parent
5082226e08
commit
f54a8ed72f
12 changed files with 211 additions and 15 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<TurnstileInstance>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(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 && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'invisible',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, 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 { useForm } from 'react-hook-form';
|
||||
import { FaIdCardClip } from 'react-icons/fa6';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
|
|
@ -15,6 +17,7 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
|||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
|
@ -89,6 +92,11 @@ export const SignUpForm = ({
|
|||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
|
|
@ -111,6 +119,7 @@ export const SignUpForm = ({
|
|||
email,
|
||||
password,
|
||||
signature,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
});
|
||||
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
|
|
@ -139,6 +148,9 @@ export const SignUpForm = ({
|
|||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -246,13 +258,7 @@ export const SignUpForm = ({
|
|||
className="flex w-full flex-1 flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
@ -324,6 +330,19 @@ export const SignUpForm = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
|
|
|
|||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
|||
"@libpdf/core": "^0.3.3",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
|
|
@ -5042,6 +5043,16 @@
|
|||
"vite": "^3 || ^4 || ^5.0.9 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@marsidev/react-turnstile": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.0.tgz",
|
||||
"integrity": "sha512-Ph6mcj8u9WBDsBO7s9jKPsyRDz1sBPBJwrk+Ngx09vFInvKsQ6U6kW5amEcGq4dHOreB6DgFrOJk7/fy318YlQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdx-js/mdx": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
|
|||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
forgotPasswordRateLimit,
|
||||
|
|
@ -60,7 +61,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
|
||||
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
|
||||
|
||||
const loginLimitResult = await loginRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
|
|
@ -84,6 +85,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
});
|
||||
}
|
||||
|
||||
await verifyCaptchaToken({
|
||||
token: captchaToken,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
});
|
||||
|
||||
if (
|
||||
email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
email.toLowerCase() === deletedServiceAccountEmail()
|
||||
|
|
@ -188,7 +194,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
});
|
||||
}
|
||||
|
||||
const { name, email, password, signature } = c.req.valid('json');
|
||||
const { name, email, password, signature, captchaToken } = c.req.valid('json');
|
||||
|
||||
const signupLimitResult = await signupRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
|
|
@ -202,6 +208,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
});
|
||||
}
|
||||
|
||||
await verifyCaptchaToken({
|
||||
token: captchaToken,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
});
|
||||
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
|
||||
statusCode: 400,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const ZSignInSchema = z.object({
|
|||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
csrfToken: z.string().trim(),
|
||||
captchaToken: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
|
||||
|
|
@ -39,6 +40,7 @@ export const ZSignUpSchema = z.object({
|
|||
email: zEmail(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().nullish(),
|
||||
captchaToken: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export enum AppErrorCode {
|
|||
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
|
||||
'NOT_FOUND' = 'NOT_FOUND',
|
||||
'NOT_SETUP' = 'NOT_SETUP',
|
||||
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
|
||||
'UNAUTHORIZED' = 'UNAUTHORIZED',
|
||||
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
|
||||
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
|
||||
|
|
@ -29,6 +30,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
|||
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
|
||||
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
|
|
|
|||
107
packages/lib/server-only/captcha/verify-captcha.ts
Normal file
107
packages/lib/server-only/captcha/verify-captcha.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
type TurnstileVerifyResponse = {
|
||||
success: boolean;
|
||||
'error-codes': string[];
|
||||
challenge_ts?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a captcha token server-side.
|
||||
*
|
||||
* Currently supports Cloudflare Turnstile. This is a no-op if
|
||||
* `NEXT_PRIVATE_TURNSTILE_SECRET_KEY` is not configured, making captcha
|
||||
* verification an opt-in feature.
|
||||
*/
|
||||
export const verifyCaptchaToken = async ({
|
||||
token,
|
||||
ipAddress,
|
||||
}: {
|
||||
token?: string | null;
|
||||
ipAddress?: string | null;
|
||||
}) => {
|
||||
const secretKey = process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY;
|
||||
|
||||
// If no secret key is configured, skip verification.
|
||||
if (!secretKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
logger.warn({
|
||||
msg: 'Captcha verification rejected: missing token',
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: 'Captcha token is required',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
|
||||
formData.append('secret', secretKey);
|
||||
formData.append('response', token);
|
||||
|
||||
if (ipAddress) {
|
||||
formData.append('remoteip', ipAddress);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(TURNSTILE_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
msg: 'Captcha verification failed: network error calling siteverify',
|
||||
err,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: 'Captcha verification failed',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error({
|
||||
msg: 'Captcha verification failed: non-2xx response from siteverify',
|
||||
status: response.status,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: `Captcha verification request failed with status ${response.status}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const result: TurnstileVerifyResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn({
|
||||
msg: 'Captcha verification rejected by provider',
|
||||
errorCodes: result['error-codes'],
|
||||
hostname: result.hostname,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: `Captcha verification failed: ${result['error-codes']?.join(', ') ?? 'unknown'}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -4,8 +4,8 @@ import { createRateLimit } from './rate-limit';
|
|||
|
||||
export const signupRateLimit = createRateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 10,
|
||||
window: '1h',
|
||||
max: 3,
|
||||
window: '3h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = createRateLimit({
|
||||
|
|
|
|||
6
packages/tsconfig/process-env.d.ts
vendored
6
packages/tsconfig/process-env.d.ts
vendored
|
|
@ -102,6 +102,12 @@ declare namespace NodeJS {
|
|||
POSTGRES_PRISMA_URL?: string;
|
||||
POSTGRES_URL_NON_POOLING?: string;
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile environment variables
|
||||
*/
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY?: string;
|
||||
NEXT_PRIVATE_TURNSTILE_SECRET_KEY?: string;
|
||||
|
||||
/**
|
||||
* Google Vertex AI environment variables
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@
|
|||
"DANGEROUS_BYPASS_RATE_LIMITS",
|
||||
"NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS",
|
||||
"NEXT_PRIVATE_OIDC_PROMPT",
|
||||
"NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS"
|
||||
"NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS",
|
||||
"NEXT_PUBLIC_TURNSTILE_SITE_KEY",
|
||||
"NEXT_PRIVATE_TURNSTILE_SECRET_KEY"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue