From d28a2e1209d0aae1549f2b91fae45a5507792fe2 Mon Sep 17 00:00:00 2001 From: Bieber Date: Tue, 30 Sep 2025 19:22:08 +0800 Subject: [PATCH] feat: add cloudflare turnstile --- .../src/features/auth/auth.module.ts | 2 + .../auth/local-auth/local-auth.controller.ts | 8 +- .../auth/local-auth/local-auth.module.ts | 2 + .../auth/local-auth/local-auth.service.ts | 83 +++++++- .../auth/strategies/local.strategy.spec.ts | 21 +- .../auth/strategies/local.strategy.ts | 14 +- .../auth/turnstile/turnstile.module.ts | 8 + .../auth/turnstile/turnstile.service.ts | 192 ++++++++++++++++++ .../open-api/setting-open-api.controller.ts | 7 +- .../open-api/setting-open-api.module.ts | 2 + apps/nextjs-app/.env.example | 6 +- .../src/features/auth/components/SignForm.tsx | 44 +++- .../auth/components/TurnstileWidget.tsx | 157 ++++++++++++++ packages/common-i18n/src/locales/de/auth.json | 6 +- packages/common-i18n/src/locales/en/auth.json | 6 +- packages/common-i18n/src/locales/es/auth.json | 6 +- packages/common-i18n/src/locales/fr/auth.json | 9 + packages/common-i18n/src/locales/it/auth.json | 6 +- packages/common-i18n/src/locales/ja/auth.json | 9 + packages/common-i18n/src/locales/ru/auth.json | 9 + packages/common-i18n/src/locales/tr/auth.json | 9 + packages/common-i18n/src/locales/uk/auth.json | 6 +- packages/common-i18n/src/locales/zh/auth.json | 6 +- .../openapi/src/admin/setting/get-public.ts | 1 + packages/openapi/src/auth/signin.ts | 1 + packages/openapi/src/auth/signup.ts | 1 + 26 files changed, 599 insertions(+), 22 deletions(-) create mode 100644 apps/nestjs-backend/src/features/auth/turnstile/turnstile.module.ts create mode 100644 apps/nestjs-backend/src/features/auth/turnstile/turnstile.service.ts create mode 100644 apps/nextjs-app/src/features/auth/components/TurnstileWidget.tsx diff --git a/apps/nestjs-backend/src/features/auth/auth.module.ts b/apps/nestjs-backend/src/features/auth/auth.module.ts index 6381cfe447..7c72d6ae2e 100644 --- a/apps/nestjs-backend/src/features/auth/auth.module.ts +++ b/apps/nestjs-backend/src/features/auth/auth.module.ts @@ -18,6 +18,7 @@ import { SocialModule } from './social/social.module'; import { AccessTokenStrategy } from './strategies/access-token.strategy'; import { JwtStrategy } from './strategies/jwt.strategy'; import { SessionStrategy } from './strategies/session.strategy'; +import { TurnstileModule } from './turnstile/turnstile.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { SessionStrategy } from './strategies/session.strategy'; }), SocialModule, PermissionModule, + TurnstileModule, JwtModule.registerAsync({ useFactory: (config: IAuthConfig) => ({ secret: config.jwt.secret, diff --git a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.controller.ts b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.controller.ts index 440b71a530..e202c00cf7 100644 --- a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.controller.ts +++ b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.controller.ts @@ -51,7 +51,7 @@ export class LocalAuthController { @UseGuards(LocalAuthGuard) @HttpCode(200) @Post('signin') - async signin(@Req() req: Express.Request): Promise { + async signin(@Req() req: Request): Promise { return req.user as IUserMeVo; } @@ -60,9 +60,11 @@ export class LocalAuthController { async signup( @Body(new ZodValidationPipe(signupSchema)) body: ISignup, @Res({ passthrough: true }) res: Response, - @Req() req: Express.Request + @Req() req: Request ): Promise { - const user = pickUserMe(await this.authService.signup(body)); + const remoteIp = + req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string); + const user = pickUserMe(await this.authService.signup(body, remoteIp)); // set cookie, passport login await new Promise((resolve, reject) => { req.login(user, (err) => (err ? reject(err) : resolve())); diff --git a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts index cb6d178499..e5b50138f4 100644 --- a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts +++ b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts @@ -8,11 +8,13 @@ import { UserModule } from '../../user/user.module'; import { SessionStoreService } from '../session/session-store.service'; import { SessionModule } from '../session/session.module'; import { LocalStrategy } from '../strategies/local.strategy'; +import { TurnstileModule } from '../turnstile/turnstile.module'; import { LocalAuthController } from './local-auth.controller'; import { LocalAuthService } from './local-auth.service'; @Module({ imports: [ + TurnstileModule, SettingModule, UserModule, SessionModule, diff --git a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts index 48482863c9..aefaf54d01 100644 --- a/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts +++ b/apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts @@ -30,6 +30,7 @@ import { MailSenderService } from '../../mail-sender/mail-sender.service'; import { SettingService } from '../../setting/setting.service'; import { UserService } from '../../user/user.service'; import { SessionStoreService } from '../session/session-store.service'; +import { TurnstileService } from '../turnstile/turnstile.service'; @Injectable() export class LocalAuthService { @@ -47,7 +48,8 @@ export class LocalAuthService { @MailConfig() private readonly mailConfig: IMailConfig, @BaseConfig() private readonly baseConfig: IBaseConfig, private readonly jwtService: JwtService, - private readonly settingService: SettingService + private readonly settingService: SettingService, + private readonly turnstileService: TurnstileService ) {} private async encodePassword(password: string) { @@ -91,6 +93,22 @@ export class LocalAuthService { return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null; } + /** + * Validate user by email and password with Turnstile verification + */ + async validateUserByEmailWithTurnstile( + email: string, + pass: string, + turnstileToken?: string, + remoteIp?: string + ) { + // Validate Turnstile token if enabled + await this.validateTurnstileIfEnabled(turnstileToken, remoteIp); + + // Proceed with normal user validation + return this.validateUserByEmail(email, pass); + } + private jwtSignupCode(email: string, code: string) { return this.jwtService.signAsync( { email, code }, @@ -136,8 +154,67 @@ export class LocalAuthService { } } - async signup(body: ISignup) { - const { email, password, defaultSpaceName, refMeta, inviteCode } = body; + /** + * Validate Turnstile token if Turnstile is enabled + */ + private async validateTurnstileIfEnabled( + turnstileToken?: string, + remoteIp?: string + ): Promise { + if (!this.turnstileService.isTurnstileEnabled()) { + return; // Turnstile is not enabled, skip validation + } + + if (!turnstileToken) { + throw new BadRequestException('Turnstile token is required'); + } + + const validation = await this.turnstileService.validateTurnstileTokenWithRetry( + turnstileToken, + remoteIp + ); + + if (!validation.valid) { + this.logger.warn('Turnstile validation failed', { + reason: validation.reason, + remoteIp, + }); + + let errorMessage = 'Verification failed. Please try again.'; + + switch (validation.reason) { + case 'turnstile_disabled': + errorMessage = 'Verification service is not available'; + break; + case 'invalid_token_format': + case 'token_too_long': + errorMessage = 'Invalid verification token'; + break; + case 'turnstile_failed': + errorMessage = 'Verification failed. Please refresh and try again.'; + break; + case 'api_error': + case 'internal_error': + case 'max_retries_exceeded': + errorMessage = 'Verification service temporarily unavailable. Please try again.'; + break; + } + + throw new BadRequestException(errorMessage); + } + + this.logger.debug('Turnstile validation successful', { + hostname: validation.data?.hostname, + action: validation.data?.action, + }); + } + + async signup(body: ISignup, remoteIp?: string) { + const { email, password, defaultSpaceName, refMeta, inviteCode, turnstileToken } = body; + + // Validate Turnstile token if enabled + await this.validateTurnstileIfEnabled(turnstileToken, remoteIp); + await this.verifySignup(body); const user = await this.userService.getUserByEmail(email); diff --git a/apps/nestjs-backend/src/features/auth/strategies/local.strategy.spec.ts b/apps/nestjs-backend/src/features/auth/strategies/local.strategy.spec.ts index 6e911c2b6c..b09a314f16 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/local.strategy.spec.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/local.strategy.spec.ts @@ -1,6 +1,8 @@ +/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable sonarjs/no-duplicate-string */ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import type { Request } from 'express'; import { mockDeep, mockReset } from 'vitest-mock-extended'; import { CacheService } from '../../../cache/cache.service'; import { GlobalModule } from '../../../global/global.module'; @@ -14,6 +16,15 @@ describe('LocalStrategy', () => { const cacheService = mockDeep(); const testEmail = 'test@test.com'; const testPassword = '12345678a'; + const mokeReq = { + ip: '127.0.0.1', + connection: { + remoteAddress: '127.0.0.1', + }, + headers: { + 'x-forwarded-for': '127.0.0.1', + }, + } as unknown as Request; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -41,7 +52,7 @@ describe('LocalStrategy', () => { maxLoginAttempts: 0, accountLockoutMinutes: 0, }; - await expect(localStrategy.validate(testEmail, testPassword)).rejects.toThrow( + await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow( 'Email or password is incorrect' ); }); @@ -57,7 +68,7 @@ describe('LocalStrategy', () => { return undefined; }); - await expect(localStrategy.validate(testEmail, testPassword)).rejects.toThrow( + await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow( 'Your account has been locked out, please try again after 10 minutes' ); }); @@ -74,7 +85,7 @@ describe('LocalStrategy', () => { return undefined; }); - await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({ + await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({ response: 'Email or password is incorrect', }); expect(cacheService.setDetail).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 3, 30); @@ -92,7 +103,7 @@ describe('LocalStrategy', () => { return undefined; }); - await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({ + await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({ response: 'Your account has been locked out, please try again after 10 minutes', }); expect(cacheService.set).toHaveBeenCalledWith(`signin:lockout:${testEmail}`, true, 10); @@ -106,7 +117,7 @@ describe('LocalStrategy', () => { }; cacheService.get.mockImplementation(async () => undefined); - await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({ + await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({ response: 'Email or password is incorrect', }); expect(cacheService.setDetail).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 1, 30); diff --git a/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts index 1d7e90f219..34c32c46e1 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { HttpErrorCode } from '@teable/core'; +import type { Request } from 'express'; import { Strategy } from 'passport-local'; import { CacheService } from '../../../cache/cache.service'; import { AuthConfig, IAuthConfig } from '../../../configs/auth.config'; @@ -21,12 +22,21 @@ export class LocalStrategy extends PassportStrategy(Strategy) { super({ usernameField: 'email', passwordField: 'password', + passReqToCallback: true, }); } - async validate(email: string, password: string) { + async validate(req: Request, email: string, password: string) { try { - const user = await this.authService.validateUserByEmail(email, password); + const turnstileToken = req.body?.turnstileToken; + const remoteIp = + req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string); + const user = await this.authService.validateUserByEmailWithTurnstile( + email, + password, + turnstileToken, + remoteIp + ); if (!user) { throw new CustomHttpException( 'Email or password is incorrect', diff --git a/apps/nestjs-backend/src/features/auth/turnstile/turnstile.module.ts b/apps/nestjs-backend/src/features/auth/turnstile/turnstile.module.ts new file mode 100644 index 0000000000..f8bb760aaf --- /dev/null +++ b/apps/nestjs-backend/src/features/auth/turnstile/turnstile.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TurnstileService } from './turnstile.service'; + +@Module({ + providers: [TurnstileService], + exports: [TurnstileService], +}) +export class TurnstileModule {} diff --git a/apps/nestjs-backend/src/features/auth/turnstile/turnstile.service.ts b/apps/nestjs-backend/src/features/auth/turnstile/turnstile.service.ts new file mode 100644 index 0000000000..6149582c47 --- /dev/null +++ b/apps/nestjs-backend/src/features/auth/turnstile/turnstile.service.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface ITurnstileValidationResponse { + success: boolean; + 'error-codes'?: string[]; + challenge_ts?: string; + hostname?: string; + action?: string; + cdata?: string; + metadata?: { + ephemeral_id?: string; + }; +} + +interface ITurnstileValidationRequest { + secret: string; + response: string; + remoteip?: string; + idempotency_key?: string; +} + +@Injectable() +export class TurnstileService { + private readonly logger = new Logger(TurnstileService.name); + private readonly turnstileSecretKey: string; + private readonly turnstileSiteKey: string; + private readonly isEnabled: boolean; + + constructor(private readonly configService: ConfigService) { + this.turnstileSecretKey = this.configService.get('TURNSTILE_SECRET_KEY') || ''; + this.turnstileSiteKey = this.configService.get('TURNSTILE_SITE_KEY') || ''; + this.isEnabled = Boolean(this.turnstileSiteKey && this.turnstileSecretKey); + + if (this.isEnabled) { + this.logger.log('Turnstile validation is enabled'); + } else { + this.logger.log('Turnstile validation is disabled - missing site key or secret key'); + } + } + + /** + * Check if Turnstile is enabled based on environment configuration + */ + isTurnstileEnabled(): boolean { + return this.isEnabled; + } + + /** + * Get the Turnstile site key for client-side rendering + */ + getTurnstileSiteKey(): string | null { + return this.isEnabled ? this.turnstileSiteKey : null; + } + + /** + * Validate Turnstile token with Cloudflare's siteverify API + */ + async validateTurnstileToken( + token: string, + remoteIp?: string, + expectedAction?: string, + expectedHostname?: string + ): Promise<{ valid: boolean; reason?: string; data?: ITurnstileValidationResponse }> { + if (!this.isEnabled) { + this.logger.warn('Turnstile validation attempted but service is not enabled'); + return { valid: false, reason: 'turnstile_disabled' }; + } + + if (!token || typeof token !== 'string') { + return { valid: false, reason: 'invalid_token_format' }; + } + + if (token.length > 2048) { + return { valid: false, reason: 'token_too_long' }; + } + + const requestData: ITurnstileValidationRequest = { + secret: this.turnstileSecretKey, + response: token, + }; + + if (remoteIp) { + requestData.remoteip = remoteIp; + } + + try { + const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + this.logger.error(`Turnstile API returned ${response.status}: ${response.statusText}`); + return { valid: false, reason: 'api_error' }; + } + + const result: ITurnstileValidationResponse = await response.json(); + + if (!result.success) { + this.logger.warn('Turnstile validation failed', { + errorCodes: result['error-codes'], + token: token.substring(0, 20) + '...', + }); + return { + valid: false, + reason: 'turnstile_failed', + data: result, + }; + } + + // Log action and hostname for monitoring (but don't reject) + if (expectedAction && result.action && result.action !== expectedAction) { + this.logger.debug('Turnstile action info', { + expected: expectedAction, + received: result.action, + }); + } + + if (expectedHostname && result.hostname && result.hostname !== expectedHostname) { + this.logger.debug('Turnstile hostname info', { + expected: expectedHostname, + received: result.hostname, + }); + } + + // Check token age (warn if older than 4 minutes) + if (result.challenge_ts) { + const challengeTime = new Date(result.challenge_ts); + const now = new Date(); + const ageMinutes = (now.getTime() - challengeTime.getTime()) / (1000 * 60); + + if (ageMinutes > 4) { + this.logger.warn(`Turnstile token is ${ageMinutes.toFixed(1)} minutes old`); + } + } + + this.logger.debug('Turnstile validation successful', { + hostname: result.hostname, + action: result.action, + challengeTs: result.challenge_ts, + }); + + return { valid: true, data: result }; + } catch (error) { + this.logger.error('Turnstile validation error', error); + return { valid: false, reason: 'internal_error' }; + } + } + + /** + * Validate Turnstile token with retry logic + */ + async validateTurnstileTokenWithRetry( + token: string, + remoteIp?: string, + expectedAction?: string, + expectedHostname?: string, + maxRetries: number = 3 + ): Promise<{ valid: boolean; reason?: string; data?: ITurnstileValidationResponse }> { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = await this.validateTurnstileToken( + token, + remoteIp, + expectedAction, + expectedHostname + ); + + // If validation succeeded or failed for non-retryable reasons, return immediately + if (result.valid || (result.reason !== 'api_error' && result.reason !== 'internal_error')) { + return result; + } + + // If this is the last attempt, return the error + if (attempt === maxRetries) { + return result; + } + + // Wait before retrying (exponential backoff) + const delay = Math.pow(2, attempt - 1) * 1000; + await new Promise((resolve) => setTimeout(resolve, delay)); + + this.logger.warn(`Turnstile validation attempt ${attempt} failed, retrying in ${delay}ms`); + } + + return { valid: false, reason: 'max_retries_exceeded' }; + } +} diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts index 8aa91b480f..6ec7fc8ab2 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts @@ -30,11 +30,15 @@ import { import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; import { Public } from '../../auth/decorators/public.decorator'; +import { TurnstileService } from '../../auth/turnstile/turnstile.service'; import { SettingOpenApiService } from './setting-open-api.service'; @Controller('api/admin/setting') export class SettingOpenApiController { - constructor(private readonly settingOpenApiService: SettingOpenApiService) {} + constructor( + private readonly settingOpenApiService: SettingOpenApiService, + private readonly turnstileService: TurnstileService + ) {} /** * Get the instance settings, now we have config for AI, there are some sensitive fields, we need check the permission before return. @@ -80,6 +84,7 @@ export class SettingOpenApiController { }, appGenerationEnabled: Boolean(appConfig?.apiKey), webSearchEnabled: Boolean(webSearchConfig?.apiKey), + turnstileSiteKey: this.turnstileService.getTurnstileSiteKey(), }; } diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.module.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.module.ts index b1c447551a..9b928b3208 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.module.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { MulterModule } from '@nestjs/platform-express'; import multer from 'multer'; import { StorageModule } from '../../attachments/plugins/storage.module'; +import { TurnstileModule } from '../../auth/turnstile/turnstile.module'; import { SettingModule } from '../setting.module'; import { SettingOpenApiController } from './setting-open-api.controller'; import { SettingOpenApiService } from './setting-open-api.service'; @@ -13,6 +14,7 @@ import { SettingOpenApiService } from './setting-open-api.service'; }), StorageModule, SettingModule, + TurnstileModule, ], controllers: [SettingOpenApiController], exports: [SettingOpenApiService], diff --git a/apps/nextjs-app/.env.example b/apps/nextjs-app/.env.example index 8c05b5e6f7..2009ff268c 100644 --- a/apps/nextjs-app/.env.example +++ b/apps/nextjs-app/.env.example @@ -181,4 +181,8 @@ NEXT_ENV_IMAGES_ALL_REMOTE=true NEXT_BUILD_ENV_ASSET_PREFIX=minio.example.com # performance cache redis url -BACKEND_PERFORMANCE_CACHE=redis://default:teable@127.0.0.1:6379/0 \ No newline at end of file +BACKEND_PERFORMANCE_CACHE=redis://default:teable@127.0.0.1:6379/0 + +# cloudflare turnstile config for auth verify +TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA diff --git a/apps/nextjs-app/src/features/auth/components/SignForm.tsx b/apps/nextjs-app/src/features/auth/components/SignForm.tsx index 426b227e53..ea5470924e 100644 --- a/apps/nextjs-app/src/features/auth/components/SignForm.tsx +++ b/apps/nextjs-app/src/features/auth/components/SignForm.tsx @@ -22,6 +22,7 @@ import type { ZodIssue } from 'zod'; import { fromZodError } from 'zod-validation-error'; import { authConfig } from '../../i18n/auth.config'; import { SendVerificationButton } from './SendVerificationButton'; +import TurnstileWidget from './TurnstileWidget'; export interface ISignForm { className?: string; @@ -37,13 +38,14 @@ export const SignForm: FC = (props) => { const [inviteCode, setInviteCode] = useState(router.query.inviteCode as string); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + const [turnstileToken, setTurnstileToken] = useState(); const emailRef = useRef(null); const { data: setting } = useQuery({ queryKey: ReactQueryKeys.getPublicSetting(), queryFn: () => getPublicSetting().then(({ data }) => data), }); - const { enableWaitlist = false, disallowSignUp = false } = setting ?? {}; + const { enableWaitlist = false, disallowSignUp = false, turnstileSiteKey } = setting ?? {}; const joinWaitlist = useCallback(() => { if (enableWaitlist) { @@ -57,6 +59,7 @@ export const SignForm: FC = (props) => { setSignupVerificationCode(undefined); setSignupVerificationToken(undefined); setError(undefined); + setTurnstileToken(undefined); }, [type]); const { mutate: submitMutation } = useMutation({ @@ -163,6 +166,21 @@ export const SignForm: FC = (props) => { const showVerificationCode = type === 'signup' && signupVerificationToken; + // Turnstile callbacks + const handleTurnstileVerify = useCallback((token: string) => setTurnstileToken(token), []); + const handleTurnstileError = useCallback(() => { + setTurnstileToken(undefined); + setError(t('auth:signError.turnstileError')); + }, [t]); + const handleTurnstileExpire = useCallback(() => { + setTurnstileToken(undefined); + setError(t('auth:signError.turnstileExpired')); + }, [t]); + const handleTurnstileTimeout = useCallback(() => { + setTurnstileToken(undefined); + setError(t('auth:signError.turnstileTimeout')); + }, [t]); + async function onSubmit(event: React.FormEvent) { event.preventDefault(); @@ -178,6 +196,7 @@ export const SignForm: FC = (props) => { password, verification: code ? { code, token: signupVerificationToken } : undefined, inviteCode: enableWaitlist ? inviteCode : undefined, + turnstileToken: turnstileToken, }; const { error } = validation(form); @@ -191,6 +210,12 @@ export const SignForm: FC = (props) => { return; } + // Check Turnstile verification if enabled + if (turnstileSiteKey && !turnstileToken) { + setError(t('auth:signError.turnstileRequired')); + return; + } + // Using custom isLoading instead of submitMutation.isLoading because isLoading only reflects the mutation state, // and we need the loader to persist during the delay between the request completion and the redirect. setIsLoading(true); @@ -315,6 +340,23 @@ export const SignForm: FC = (props) => { )} + + {/* Turnstile Widget */} + {turnstileSiteKey && ( +
+ +
+ )} +