feat(scheduling): pluggable captcha (Turnstile) on public booking

This commit is contained in:
Ronni Baslund
2026-06-07 09:02:35 +02:00
parent e1a77b085f
commit e33b7f18a3
7 changed files with 197 additions and 2 deletions
+4
View File
@@ -18,6 +18,10 @@ export default defineNuxtConfig({
public: { public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL siteUrl: process.env.NUXT_PUBLIC_SITE_URL
|| (process.env.NODE_ENV === 'production' ? 'https://booking.dezky.eu' : 'http://localhost:3000'), || (process.env.NODE_ENV === 'production' ? 'https://booking.dezky.eu' : 'http://localhost:3000'),
// Cloudflare Turnstile site-key. When set, the booking form renders a
// captcha widget and sends its token with the booking POST. Server-side
// enforcement is gated independently by TURNSTILE_SECRET on platform-api.
turnstileSiteKey: process.env.NUXT_PUBLIC_TURNSTILE_SITE_KEY || '',
}, },
}, },
@@ -30,6 +30,71 @@ const submitting = ref(false)
const submitError = ref<string | null>(null) const submitError = ref<string | null>(null)
const booking = ref<PublicBooking | null>(null) const booking = ref<PublicBooking | null>(null)
// Cloudflare Turnstile (optional). Only when a public site-key is configured do
// we load the script + render a widget on the details step; otherwise the form
// behaves exactly as before. We never solve the captcha — the widget produces a
// token that we forward to platform-api, which verifies it server-side.
const turnstileSiteKey = useRuntimeConfig().public.turnstileSiteKey as string
const captchaToken = ref<string | null>(null)
const captchaEl = ref<HTMLElement | null>(null)
let captchaWidgetId: string | null = null
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).turnstile) return resolve()
const existing = document.querySelector(`script[src^="${TURNSTILE_SRC}"]`) as HTMLScriptElement | null
if (existing) {
existing.addEventListener('load', () => resolve())
existing.addEventListener('error', () => reject(new Error('turnstile load failed')))
return
}
const s = document.createElement('script')
s.src = TURNSTILE_SRC
s.async = true
s.defer = true
s.addEventListener('load', () => resolve())
s.addEventListener('error', () => reject(new Error('turnstile load failed')))
document.head.appendChild(s)
})
}
async function renderCaptcha() {
if (!turnstileSiteKey || !import.meta.client || !captchaEl.value || captchaWidgetId) return
try {
await loadTurnstileScript()
const turnstile = (window as any).turnstile
if (!turnstile || !captchaEl.value) return
captchaWidgetId = turnstile.render(captchaEl.value, {
sitekey: turnstileSiteKey,
callback: (token: string) => {
captchaToken.value = token
},
'expired-callback': () => {
captchaToken.value = null
},
'error-callback': () => {
captchaToken.value = null
},
})
} catch {
// If the widget can't load we leave captchaToken null; the server decides
// whether that's fatal (it is only when TURNSTILE_SECRET is set).
}
}
function resetCaptcha() {
captchaToken.value = null
if (import.meta.client && captchaWidgetId && (window as any).turnstile) {
;(window as any).turnstile.reset(captchaWidgetId)
}
}
// Render the widget whenever the details step becomes visible.
watch(step, (s) => {
if (s === 'details' && turnstileSiteKey) void nextTick(renderCaptcha)
})
// Whitelabel theming — drive the accent from the tenant's brandColor. // Whitelabel theming — drive the accent from the tenant's brandColor.
watchEffect(() => { watchEffect(() => {
if (!import.meta.client || !info.value) return if (!import.meta.client || !info.value) return
@@ -90,6 +155,7 @@ async function confirm() {
attendeeEmail: form.email, attendeeEmail: form.email,
attendeeTimezone: visitorTz.value, attendeeTimezone: visitorTz.value,
attendeeNotes: form.notes || undefined, attendeeNotes: form.notes || undefined,
captchaToken: captchaToken.value || undefined,
}, },
}) })
} }
@@ -101,6 +167,9 @@ async function confirm() {
await loadSlots() await loadSlots()
} else if (e?.statusCode === 503) { } else if (e?.statusCode === 503) {
submitError.value = 'The calendar is temporarily unavailable. Please try again in a moment.' submitError.value = 'The calendar is temporarily unavailable. Please try again in a moment.'
} else if (e?.statusCode === 403) {
submitError.value = 'Captcha verification failed. Please complete the challenge and try again.'
resetCaptcha()
} else { } else {
submitError.value = e?.statusMessage || 'Something went wrong. Please try again.' submitError.value = e?.statusMessage || 'Something went wrong. Please try again.'
} }
@@ -222,8 +291,13 @@ useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.va
<label class="bk-label" for="notes">Notes (optional)</label> <label class="bk-label" for="notes">Notes (optional)</label>
<textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" /> <textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" />
</div> </div>
<div v-if="turnstileSiteKey" ref="captchaEl" class="captcha" />
<p v-if="submitError" class="err">{{ submitError }}</p> <p v-if="submitError" class="err">{{ submitError }}</p>
<button type="submit" class="bk-btn bk-btn--primary" :disabled="submitting"> <button
type="submit"
class="bk-btn bk-btn--primary"
:disabled="submitting || (!!turnstileSiteKey && !captchaToken)"
>
{{ submitting ? 'Confirming…' : 'Confirm booking' }} {{ submitting ? 'Confirming…' : 'Confirm booking' }}
</button> </button>
</form> </form>
@@ -271,6 +345,7 @@ useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.va
.slot { height: 44px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); font-size: 15px; font-weight: 600; cursor: pointer; } .slot { height: 44px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); font-size: 15px; font-weight: 600; cursor: pointer; }
.slot:hover { border-color: var(--accent); } .slot:hover { border-color: var(--accent); }
.form { display: flex; flex-direction: column; gap: 16px; margin-top: 14px; } .form { display: flex; flex-direction: column; gap: 16px; margin-top: 14px; }
.captcha { min-height: 65px; }
.chosen { font-weight: 600; margin: 0 0 6px; } .chosen { font-weight: 600; margin: 0 0 6px; }
.back { background: none; border: none; color: var(--text-mute); cursor: pointer; padding: 0 0 12px; font-size: 14px; } .back { background: none; border: none; color: var(--text-mute); cursor: pointer; padding: 0 0 12px; font-size: 14px; }
.done { text-align: center; } .done { text-align: center; }
@@ -590,6 +590,9 @@ services:
DEZKY_TRAEFIK: "1" DEZKY_TRAEFIK: "1"
# How nitro reaches platform-api inside the docker network. # How nitro reaches platform-api inside the docker network.
PLATFORM_API_INTERNAL_URL: http://platform-api:3001 PLATFORM_API_INTERNAL_URL: http://platform-api:3001
# Optional Cloudflare Turnstile site-key — when set, the booking form
# renders a captcha widget. Empty by default (no widget).
NUXT_PUBLIC_TURNSTILE_SITE_KEY: ${NUXT_PUBLIC_TURNSTILE_SITE_KEY:-}
volumes: volumes:
- ../../apps/booking:/app - ../../apps/booking:/app
- booking_node_modules:/app/node_modules - booking_node_modules:/app/node_modules
@@ -686,6 +689,9 @@ services:
SCHEDULING_CREDENTIAL_KEY: ${SCHEDULING_CREDENTIAL_KEY} SCHEDULING_CREDENTIAL_KEY: ${SCHEDULING_CREDENTIAL_KEY}
BOOKING_PUBLIC_URL: ${BOOKING_PUBLIC_URL:-https://booking.dezky.local} BOOKING_PUBLIC_URL: ${BOOKING_PUBLIC_URL:-https://booking.dezky.local}
MEET_PUBLIC_URL: ${MEET_PUBLIC_URL:-https://meet.dezky.local} MEET_PUBLIC_URL: ${MEET_PUBLIC_URL:-https://meet.dezky.local}
# Optional Cloudflare Turnstile secret. When set, the public booking holds
# and bookings endpoints verify the visitor's captcha token; unset = no-op.
TURNSTILE_SECRET: ${TURNSTILE_SECRET:-}
volumes: volumes:
- ../../services/platform-api:/app - ../../services/platform-api:/app
- platform_api_node_modules:/app/node_modules - platform_api_node_modules:/app/node_modules
@@ -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 { export class CreateHoldDto {
@IsISO8601() @IsISO8601()
startUtc!: string 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 { export class CreateBookingDto {
@@ -48,6 +55,13 @@ export class CreateBookingDto {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
holdId?: string 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 { 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 { Throttle, ThrottlerGuard } from '@nestjs/throttler'
import { TenantsService } from '../../tenants/tenants.service.js' 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 type { BookingContext } from '../bookings/bookings.service.js'
import { BookingsService } from '../bookings/bookings.service.js' import { BookingsService } from '../bookings/bookings.service.js'
import { EventTypesService } from '../event-types/event-types.service.js' import { EventTypesService } from '../event-types/event-types.service.js'
@@ -29,6 +30,7 @@ export class PublicSchedulingController {
private readonly tenants: TenantsService, private readonly tenants: TenantsService,
private readonly hosts: HostsService, private readonly hosts: HostsService,
private readonly eventTypes: EventTypesService, private readonly eventTypes: EventTypesService,
@Inject(ABUSE_GUARD) private readonly abuseGuard: AbuseGuard,
) {} ) {}
@Get(':tenantSlug/:hostSlug/:eventTypeSlug') @Get(':tenantSlug/:hostSlug/:eventTypeSlug')
@@ -65,7 +67,9 @@ export class PublicSchedulingController {
@Param('hostSlug') hostSlug: string, @Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string, @Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateHoldDto, @Body() dto: CreateHoldDto,
@Ip() ip: string,
) { ) {
await this.abuseGuard.verify(dto.captchaToken, ip)
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug) const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const { holdId, expiresAt } = await this.bookings.hold(ctx, new Date(dto.startUtc)) const { holdId, expiresAt } = await this.bookings.hold(ctx, new Date(dto.startUtc))
return { holdId, expiresAt: expiresAt.toISOString() } return { holdId, expiresAt: expiresAt.toISOString() }
@@ -78,7 +82,9 @@ export class PublicSchedulingController {
@Param('hostSlug') hostSlug: string, @Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string, @Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateBookingDto, @Body() dto: CreateBookingDto,
@Ip() ip: string,
) { ) {
await this.abuseGuard.verify(dto.captchaToken, ip)
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug) const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const booking = await this.bookings.confirm(ctx, { const booking = await this.bookings.confirm(ctx, {
startUtc: new Date(dto.startUtc), startUtc: new Date(dto.startUtc),
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose' import { MongooseModule } from '@nestjs/mongoose'
import { ScheduleModule } from '@nestjs/schedule' import { ScheduleModule } from '@nestjs/schedule'
import { ThrottlerModule } from '@nestjs/throttler' 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 { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js' import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.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 { AvailabilityService } from './availability/availability.service.js'
import { BookingsService } from './bookings/bookings.service.js' import { BookingsService } from './bookings/bookings.service.js'
import { CalendarRetryWorker } from './bookings/calendar-retry.worker.js' import { CalendarRetryWorker } from './bookings/calendar-retry.worker.js'
@@ -61,6 +63,9 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
JmapMailer, JmapMailer,
BookingReminderWorker, BookingReminderWorker,
CalendarRetryWorker, 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 {} export class SchedulingModule {}