feat: add turnstile captcha to auth flow (#2703)

This commit is contained in:
Lucas Smith
2026-04-16 14:29:07 +10:00
committed by GitHub
parent 5082226e08
commit f54a8ed72f
12 changed files with 211 additions and 15 deletions
+13 -2
View File
@@ -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>;
+2
View File
@@ -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 },
@@ -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
View File
@@ -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
*/