feat(scheduling): pluggable captcha (Turnstile) on public booking
This commit is contained in:
@@ -18,6 +18,10 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL
|
||||
|| (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 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.
|
||||
watchEffect(() => {
|
||||
if (!import.meta.client || !info.value) return
|
||||
@@ -90,6 +155,7 @@ async function confirm() {
|
||||
attendeeEmail: form.email,
|
||||
attendeeTimezone: visitorTz.value,
|
||||
attendeeNotes: form.notes || undefined,
|
||||
captchaToken: captchaToken.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -101,6 +167,9 @@ async function confirm() {
|
||||
await loadSlots()
|
||||
} else if (e?.statusCode === 503) {
|
||||
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 {
|
||||
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>
|
||||
<textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" />
|
||||
</div>
|
||||
<div v-if="turnstileSiteKey" ref="captchaEl" class="captcha" />
|
||||
<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' }}
|
||||
</button>
|
||||
</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:hover { border-color: var(--accent); }
|
||||
.form { display: flex; flex-direction: column; gap: 16px; margin-top: 14px; }
|
||||
.captcha { min-height: 65px; }
|
||||
.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; }
|
||||
.done { text-align: center; }
|
||||
|
||||
@@ -590,6 +590,9 @@ services:
|
||||
DEZKY_TRAEFIK: "1"
|
||||
# How nitro reaches platform-api inside the docker network.
|
||||
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:
|
||||
- ../../apps/booking:/app
|
||||
- booking_node_modules:/app/node_modules
|
||||
@@ -686,6 +689,9 @@ services:
|
||||
SCHEDULING_CREDENTIAL_KEY: ${SCHEDULING_CREDENTIAL_KEY}
|
||||
BOOKING_PUBLIC_URL: ${BOOKING_PUBLIC_URL:-https://booking.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:
|
||||
- ../../services/platform-api:/app
|
||||
- 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 {
|
||||
@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