feat(scheduling): pluggable captcha (Turnstile) on public booking
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { ForbiddenException, Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
// Pluggable abuse guard for the public booking surface (spec §6.1). The default
|
||||
// is a no-op so unconfigured environments behave exactly as before. When a
|
||||
// captcha provider is configured (currently Cloudflare Turnstile via
|
||||
// TURNSTILE_SECRET) the guard verifies the visitor-supplied token and rejects
|
||||
// requests it can't validate. We deliberately do NOT solve captchas — we only
|
||||
// forward the token the browser widget produced to the provider's siteverify.
|
||||
|
||||
export const ABUSE_GUARD = Symbol('ABUSE_GUARD')
|
||||
|
||||
export interface AbuseGuard {
|
||||
// Whether a captcha token is expected from the client. The booking app uses
|
||||
// this to decide whether to render a widget (mirrored client-side via the
|
||||
// public site-key env), but the server is the source of truth on enforcement.
|
||||
readonly enabled: boolean
|
||||
|
||||
// Verify a request. `token` is the captcha token from the client, if any.
|
||||
// `remoteIp` is the visitor IP for providers that score on it. Throws a 4xx
|
||||
// when the token is missing/invalid; resolves silently when acceptable.
|
||||
verify(token: string | undefined, remoteIp?: string): Promise<void>
|
||||
}
|
||||
|
||||
// No-op guard — accepts everything. Used when no captcha provider is configured.
|
||||
@Injectable()
|
||||
export class NoopAbuseGuard implements AbuseGuard {
|
||||
readonly enabled = false
|
||||
|
||||
async verify(): Promise<void> {
|
||||
// Intentionally does nothing.
|
||||
}
|
||||
}
|
||||
|
||||
interface TurnstileVerifyResponse {
|
||||
success: boolean
|
||||
'error-codes'?: string[]
|
||||
}
|
||||
|
||||
// Cloudflare Turnstile verifier. Posts the client token to siteverify with the
|
||||
// secret; any non-success (missing token, network error, provider rejection)
|
||||
// becomes a 403 so we fail closed once captcha is switched on.
|
||||
@Injectable()
|
||||
export class TurnstileAbuseGuard implements AbuseGuard {
|
||||
readonly enabled = true
|
||||
private readonly logger = new Logger(TurnstileAbuseGuard.name)
|
||||
private static readonly SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
|
||||
constructor(private readonly secret: string) {}
|
||||
|
||||
async verify(token: string | undefined, remoteIp?: string): Promise<void> {
|
||||
if (!token) throw new ForbiddenException('Captcha verification required.')
|
||||
|
||||
const form = new URLSearchParams()
|
||||
form.set('secret', this.secret)
|
||||
form.set('response', token)
|
||||
if (remoteIp) form.set('remoteip', remoteIp)
|
||||
|
||||
let body: TurnstileVerifyResponse
|
||||
try {
|
||||
const res = await fetch(TurnstileAbuseGuard.SITEVERIFY, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: form,
|
||||
})
|
||||
body = (await res.json()) as TurnstileVerifyResponse
|
||||
} catch (err) {
|
||||
// Fail closed: if we can't reach the verifier we don't let the request through.
|
||||
this.logger.error(`Turnstile siteverify request failed: ${String(err)}`)
|
||||
throw new ForbiddenException('Captcha verification unavailable. Please try again.')
|
||||
}
|
||||
|
||||
if (!body.success) {
|
||||
this.logger.warn(`Turnstile rejected token: ${(body['error-codes'] ?? []).join(', ') || 'unknown'}`)
|
||||
throw new ForbiddenException('Captcha verification failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Factory used by the module: pick Turnstile when its secret is set, else no-op.
|
||||
export function abuseGuardFactory(config: ConfigService): AbuseGuard {
|
||||
const secret = config.get<string>('TURNSTILE_SECRET')?.trim()
|
||||
if (secret) return new TurnstileAbuseGuard(secret)
|
||||
return new NoopAbuseGuard()
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export class SlotsQueryDto {
|
||||
export class CreateHoldDto {
|
||||
@IsISO8601()
|
||||
startUtc!: string
|
||||
|
||||
// Optional captcha token (e.g. Cloudflare Turnstile). Enforced server-side
|
||||
// only when a captcha provider is configured; ignored otherwise.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
captchaToken?: string
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@@ -48,6 +55,13 @@ export class CreateBookingDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
holdId?: string
|
||||
|
||||
// Optional captcha token (e.g. Cloudflare Turnstile). Enforced server-side
|
||||
// only when a captcha provider is configured; ignored otherwise.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
captchaToken?: string
|
||||
}
|
||||
|
||||
export class CancelBookingDto {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { Body, Controller, Get, Inject, Ip, Param, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'
|
||||
import { TenantsService } from '../../tenants/tenants.service.js'
|
||||
import { ABUSE_GUARD, type AbuseGuard } from '../abuse/abuse-guard.js'
|
||||
import type { BookingContext } from '../bookings/bookings.service.js'
|
||||
import { BookingsService } from '../bookings/bookings.service.js'
|
||||
import { EventTypesService } from '../event-types/event-types.service.js'
|
||||
@@ -29,6 +30,7 @@ export class PublicSchedulingController {
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly hosts: HostsService,
|
||||
private readonly eventTypes: EventTypesService,
|
||||
@Inject(ABUSE_GUARD) private readonly abuseGuard: AbuseGuard,
|
||||
) {}
|
||||
|
||||
@Get(':tenantSlug/:hostSlug/:eventTypeSlug')
|
||||
@@ -65,7 +67,9 @@ export class PublicSchedulingController {
|
||||
@Param('hostSlug') hostSlug: string,
|
||||
@Param('eventTypeSlug') eventTypeSlug: string,
|
||||
@Body() dto: CreateHoldDto,
|
||||
@Ip() ip: string,
|
||||
) {
|
||||
await this.abuseGuard.verify(dto.captchaToken, ip)
|
||||
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
|
||||
const { holdId, expiresAt } = await this.bookings.hold(ctx, new Date(dto.startUtc))
|
||||
return { holdId, expiresAt: expiresAt.toISOString() }
|
||||
@@ -78,7 +82,9 @@ export class PublicSchedulingController {
|
||||
@Param('hostSlug') hostSlug: string,
|
||||
@Param('eventTypeSlug') eventTypeSlug: string,
|
||||
@Body() dto: CreateBookingDto,
|
||||
@Ip() ip: string,
|
||||
) {
|
||||
await this.abuseGuard.verify(dto.captchaToken, ip)
|
||||
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
|
||||
const booking = await this.bookings.confirm(ctx, {
|
||||
startUtc: new Date(dto.startUtc),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ScheduleModule } from '@nestjs/schedule'
|
||||
import { ThrottlerModule } from '@nestjs/throttler'
|
||||
@@ -12,6 +13,7 @@ import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
import { ABUSE_GUARD, abuseGuardFactory } from './abuse/abuse-guard.js'
|
||||
import { AvailabilityService } from './availability/availability.service.js'
|
||||
import { BookingsService } from './bookings/bookings.service.js'
|
||||
import { CalendarRetryWorker } from './bookings/calendar-retry.worker.js'
|
||||
@@ -61,6 +63,9 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
|
||||
JmapMailer,
|
||||
BookingReminderWorker,
|
||||
CalendarRetryWorker,
|
||||
// Pluggable captcha guard for the public booking surface (Turnstile when
|
||||
// TURNSTILE_SECRET is set, otherwise a no-op).
|
||||
{ provide: ABUSE_GUARD, useFactory: abuseGuardFactory, inject: [ConfigService] },
|
||||
],
|
||||
})
|
||||
export class SchedulingModule {}
|
||||
|
||||
Reference in New Issue
Block a user