chore: prevent bot form submissions with cloudflare turnstile

This commit is contained in:
isra el
2025-12-07 19:05:19 +03:00
parent 463747b82f
commit af19f6c75a
12 changed files with 441 additions and 6 deletions

View File

@@ -28,3 +28,5 @@ MAIL_REPLY_TO=
# SMS Queue Configuration
USE_SMS_QUEUE=false
REDIS_URL=redis://localhost:6379 # if queue is enabled, redis url is required
CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

View File

@@ -12,6 +12,9 @@ export class RegisterInputDTO {
@ApiProperty({ type: String, required: true })
password: string
@ApiProperty({ type: String, required: true })
turnstileToken: string
}
export class LoginInputDTO {
@@ -20,11 +23,17 @@ export class LoginInputDTO {
@ApiProperty({ type: String, required: true })
password: string
@ApiProperty({ type: String, required: true })
turnstileToken: string
}
export class RequestResetPasswordInputDTO {
@ApiProperty({ type: String, required: true })
email: string
@ApiProperty({ type: String, required: true })
turnstileToken: string
}
export class ResetPasswordInputDTO {

View File

@@ -8,6 +8,7 @@ import { AuthService } from './auth.service'
import { JwtStrategy } from './jwt.strategy'
import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
import { MailModule } from 'src/mail/mail.module'
import { CommonModule } from '../common/common.module'
import {
PasswordReset,
PasswordResetSchema,
@@ -44,9 +45,16 @@ import { OptionalAuthGuard } from './guards/optional-auth.guard'
signOptions: { expiresIn: process.env.JWT_EXPIRATION || '60d' },
}),
MailModule,
CommonModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, AuthGuard, OptionalAuthGuard, MongooseModule],
providers: [
AuthService,
JwtStrategy,
AuthGuard,
OptionalAuthGuard,
MongooseModule,
],
exports: [AuthService, JwtModule, AuthGuard, OptionalAuthGuard],
})
export class AuthModule {}

View File

@@ -13,6 +13,7 @@ import {
PasswordResetDocument,
} from './schemas/password-reset.schema'
import { MailService } from '../mail/mail.service'
import { TurnstileService } from '../common/turnstile.service'
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
import { AccessLog } from './schemas/access-log.schema'
import {
@@ -32,9 +33,12 @@ export class AuthService {
@InjectModel(EmailVerification.name)
private emailVerificationModel: Model<EmailVerificationDocument>,
private readonly mailService: MailService,
private readonly turnstileService: TurnstileService,
) {}
async login(userData: any) {
await this.turnstileService.verify(userData.turnstileToken)
const user = await this.usersService.findOne({ email: userData.email })
if (!user) {
throw new HttpException(
@@ -109,6 +113,8 @@ export class AuthService {
}
async register(userData: any) {
await this.turnstileService.verify(userData.turnstileToken)
const existingUser = await this.usersService.findOne({
email: userData.email,
})
@@ -123,8 +129,9 @@ export class AuthService {
this.validatePassword(userData.password)
const hashedPassword = await bcrypt.hash(userData.password, 10)
const { turnstileToken, ...sanitizedUserData } = userData
const user = await this.usersService.create({
...userData,
...sanitizedUserData,
password: hashedPassword,
})
@@ -152,7 +159,12 @@ export class AuthService {
}
}
async requestResetPassword({ email }: RequestResetPasswordInputDTO) {
async requestResetPassword({
email,
turnstileToken,
}: RequestResetPasswordInputDTO) {
await this.turnstileService.verify(turnstileToken)
const user = await this.usersService.findOne({ email })
if (!user) {
return {

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common'
import { TurnstileService } from './turnstile.service'
@Module({
providers: [TurnstileService],
exports: [TurnstileService],
})
export class CommonModule {}

View File

@@ -0,0 +1,51 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import axios from 'axios'
@Injectable()
export class TurnstileService {
async verify(token: string) {
if (!token) {
throw new HttpException(
{ error: 'Bot verification is required' },
HttpStatus.BAD_REQUEST,
)
}
if (!process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY) {
throw new HttpException(
{ error: 'Turnstile secret key is not configured' },
HttpStatus.INTERNAL_SERVER_ERROR,
)
}
try {
const params = new URLSearchParams()
params.append('secret', process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY)
params.append('response', token)
const response = await axios.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
params,
{
headers: { 'content-type': 'application/x-www-form-urlencoded' },
},
)
if (!response.data?.success) {
throw new HttpException(
{ error: 'Bot verification failed. Please try again.' },
HttpStatus.BAD_REQUEST,
)
}
} catch (error) {
if (error instanceof HttpException) {
throw error
}
throw new HttpException(
{ error: 'Unable to verify bot check. Please try again.' },
HttpStatus.BAD_REQUEST,
)
}
}
}

View File

@@ -13,4 +13,4 @@ MAIL_USER=
MAIL_PASS=
MAIL_FROM=
ADMIN_EMAIL=
ADMIN_EMAIL=NEXT_PUBLIC_TURNSTILE_SITE_KEY=

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
@@ -17,10 +18,14 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Routes } from '@/config/routes'
import { useTurnstile } from '@/lib/turnstile'
const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(1, { message: 'Password is required' }),
turnstileToken: z
.string()
.min(1, { message: 'Please complete the bot verification' }),
})
type LoginFormValues = z.infer<typeof loginSchema>
@@ -33,16 +38,54 @@ export default function LoginForm() {
defaultValues: {
email: '',
password: '',
turnstileToken: '',
},
})
const {
containerRef: turnstileRef,
token: turnstileToken,
error: turnstileError,
} = useTurnstile({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
onToken: (token) =>
form.setValue('turnstileToken', token, { shouldValidate: true }),
onError: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
onExpire: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
})
useEffect(() => {
if (turnstileToken) {
form.clearErrors('turnstileToken')
}
}, [turnstileToken, form])
useEffect(() => {
if (turnstileError) {
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
}
}, [turnstileError, form])
const onSubmit = async (data: LoginFormValues) => {
form.clearErrors()
if (!data.turnstileToken) {
form.setError('turnstileToken', {
type: 'manual',
message: 'Please complete the bot verification',
})
return
}
try {
const result = await signIn('email-password-login', {
redirect: true,
callbackUrl: Routes.dashboard,
email: data.email,
password: data.password,
turnstileToken: data.turnstileToken,
})
if (result?.error) {
@@ -97,6 +140,22 @@ export default function LoginForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name='turnstileToken'
render={() => (
<FormItem>
<FormLabel className='text-sm'>Bot verification</FormLabel>
<FormControl>
<div
ref={turnstileRef}
className='min-h-[65px] w-full flex justify-center'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className='text-sm font-medium text-red-500'>
{form.formState.errors.root.message}

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -18,6 +19,7 @@ import {
import { signIn } from 'next-auth/react'
import { Checkbox } from '@/components/ui/checkbox'
import { Routes } from '@/config/routes'
import { useTurnstile } from '@/lib/turnstile'
const registerSchema = z.object({
name: z
@@ -29,6 +31,9 @@ const registerSchema = z.object({
.min(8, { message: 'Password must be at least 8 characters long' }),
phone: z.string().optional(),
marketingOptIn: z.boolean().optional().default(true),
turnstileToken: z
.string()
.min(1, { message: 'Please complete the bot verification' }),
})
type RegisterFormValues = z.infer<typeof registerSchema>
@@ -44,12 +49,47 @@ export default function RegisterForm() {
password: '',
phone: '',
marketingOptIn: true,
turnstileToken: '',
},
})
const {
containerRef: turnstileRef,
token: turnstileToken,
error: turnstileError,
} = useTurnstile({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
onToken: (token) =>
form.setValue('turnstileToken', token, { shouldValidate: true }),
onError: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
onExpire: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
})
useEffect(() => {
if (turnstileToken) {
form.clearErrors('turnstileToken')
}
}, [turnstileToken, form])
useEffect(() => {
if (turnstileError) {
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
}
}, [turnstileError, form])
const onSubmit = async (data: RegisterFormValues) => {
form.clearErrors()
if (!data.turnstileToken) {
form.setError('turnstileToken', {
type: 'manual',
message: 'Please complete the bot verification',
})
return
}
try {
const result = await signIn('email-password-register', {
redirect: false,
@@ -58,6 +98,7 @@ export default function RegisterForm() {
name: data.name,
phone: data.phone,
marketingOptIn: data.marketingOptIn,
turnstileToken: data.turnstileToken,
})
if (result?.error) {
@@ -133,6 +174,22 @@ export default function RegisterForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name='turnstileToken'
render={() => (
<FormItem>
<FormLabel className='text-sm'>Bot verification</FormLabel>
<FormControl>
<div
ref={turnstileRef}
className='min-h-[65px] w-full flex justify-center'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className='text-sm font-medium text-red-500'>
{form.formState.errors.root.message}

View File

@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
@@ -28,9 +29,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { Routes } from '@/config/routes'
import { useTurnstile } from '@/lib/turnstile'
const requestPasswordResetSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
turnstileToken: z
.string()
.min(1, { message: 'Please complete the bot verification' }),
})
type RequestPasswordResetFormValues = z.infer<typeof requestPasswordResetSchema>
@@ -40,13 +45,49 @@ export default function RequestPasswordResetForm() {
resolver: zodResolver(requestPasswordResetSchema),
defaultValues: {
email: '',
turnstileToken: '',
},
})
const {
containerRef: turnstileRef,
token: turnstileToken,
error: turnstileError,
} = useTurnstile({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
onToken: (token) =>
form.setValue('turnstileToken', token, { shouldValidate: true }),
onError: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
onExpire: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
})
useEffect(() => {
if (turnstileToken) {
form.clearErrors('turnstileToken')
}
}, [turnstileToken, form])
useEffect(() => {
if (turnstileError) {
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
}
}, [turnstileError, form])
const onRequestPasswordReset = async (
data: RequestPasswordResetFormValues
) => {
form.clearErrors()
if (!data.turnstileToken) {
form.setError('turnstileToken', {
type: 'manual',
message: 'Please complete the bot verification',
})
return
}
try {
await httpBrowserClient.post(
ApiEndpoints.auth.requestPasswordReset(),
@@ -89,6 +130,22 @@ export default function RequestPasswordResetForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name='turnstileToken'
render={() => (
<FormItem>
<FormLabel className='text-sm'>Bot verification</FormLabel>
<FormControl>
<div
ref={turnstileRef}
className='min-h-[65px] w-full flex justify-center'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
className='w-full'
type='submit'

View File

@@ -29,13 +29,15 @@ export const authOptions = {
credentials: {
email: { label: 'email', type: 'text' },
password: { label: 'Password', type: 'password' },
turnstileToken: { label: 'Turnstile Token', type: 'text' },
},
async authorize(credentials) {
const { email, password } = credentials
const { email, password, turnstileToken } = credentials
try {
const res = await httpServerClient.post(ApiEndpoints.auth.login(), {
email,
password,
turnstileToken,
})
const user = res.data.data.user
@@ -60,9 +62,10 @@ export const authOptions = {
password: { label: 'Password', type: 'password' },
name: { label: 'Name', type: 'text' },
phone: { label: 'Phone', type: 'text' },
turnstileToken: { label: 'Turnstile Token', type: 'text' },
},
async authorize(credentials) {
const { email, password, name, phone } = credentials
const { email, password, name, phone, turnstileToken } = credentials
try {
const res = await httpServerClient.post(
ApiEndpoints.auth.register(),
@@ -71,6 +74,7 @@ export const authOptions = {
password,
name,
phone,
turnstileToken,
}
)

167
web/lib/turnstile.ts Normal file
View File

@@ -0,0 +1,167 @@
import { type RefObject, useEffect, useRef, useState } from 'react'
declare global {
interface Window {
turnstile?: {
render: (
container: HTMLElement,
options: {
sitekey: string
callback?: (token: string) => void
'error-callback'?: () => void
'expired-callback'?: () => void
},
) => string
reset: (widgetId?: string) => void
}
}
}
const TURNSTILE_SCRIPT_ID = 'cf-turnstile-script'
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
let loadTurnstilePromise: Promise<void> | null = null
const createLoadPromise = () =>
new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.id = TURNSTILE_SCRIPT_ID
script.src = TURNSTILE_SCRIPT_SRC
script.async = true
script.defer = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Turnstile script'))
document.body.appendChild(script)
})
const ensureTurnstileScript = () => {
if (typeof window === 'undefined') {
return Promise.reject(new Error('Turnstile unavailable on server'))
}
if (window.turnstile) {
return Promise.resolve()
}
if (loadTurnstilePromise) {
return loadTurnstilePromise
}
const existing = document.getElementById(TURNSTILE_SCRIPT_ID)
if (existing) {
const scriptEl = existing as HTMLScriptElement
loadTurnstilePromise = new Promise<void>((resolve) => {
scriptEl.addEventListener('load', () => resolve(), { once: true })
const readyStates = ['loaded', 'complete']
const state = (scriptEl as any).readyState as string | undefined
if (state && readyStates.includes(state)) {
resolve()
}
})
return loadTurnstilePromise
}
loadTurnstilePromise = createLoadPromise()
return loadTurnstilePromise
}
type UseTurnstileOptions = {
siteKey?: string
onToken?: (token: string) => void
onError?: (message: string) => void
onExpire?: (message: string) => void
}
type UseTurnstileResult = {
containerRef: RefObject<HTMLDivElement>
token: string
error: string | null
isReady: boolean
}
export const useTurnstile = ({
siteKey,
onToken,
onError,
onExpire,
}: UseTurnstileOptions): UseTurnstileResult => {
const containerRef = useRef<HTMLDivElement | null>(null)
const widgetIdRef = useRef<string | null>(null)
const onTokenRef = useRef(onToken)
const onErrorRef = useRef(onError)
const onExpireRef = useRef(onExpire)
const [token, setToken] = useState('')
const [error, setError] = useState<string | null>(null)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
onTokenRef.current = onToken
onErrorRef.current = onError
onExpireRef.current = onExpire
}, [onToken, onError, onExpire])
useEffect(() => {
if (!siteKey) {
setError('Turnstile site key is not configured')
onErrorRef.current?.('Turnstile site key is not configured')
return
}
ensureTurnstileScript()
.then(() => setIsReady(true))
.catch(() => {
const message = 'Bot check failed to load. Please retry.'
setError(message)
onErrorRef.current?.(message)
})
}, [siteKey])
useEffect(() => {
if (
!isReady ||
!containerRef.current ||
!window.turnstile ||
!siteKey ||
widgetIdRef.current
) {
return
}
// Defensive: clear any existing content to avoid duplicate render in StrictMode.
containerRef.current.innerHTML = ''
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (receivedToken) => {
setToken(receivedToken)
setError(null)
onTokenRef.current?.(receivedToken)
},
'error-callback': () => {
setToken('')
const message = 'Bot verification failed. Please retry.'
setError(message)
onErrorRef.current?.(message)
},
'expired-callback': () => {
setToken('')
const message = 'Bot check expired. Please try again.'
setError(message)
onExpireRef.current?.(message)
},
})
return () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current)
widgetIdRef.current = null
}
}
}, [isReady, siteKey])
return {
containerRef,
token,
error,
isReady,
}
}