Skip to content

Optional Turnstile CAPTCHA verification for the unauthenticated email-sending API actions #346

Description

@devondragon

Summary

Add optional, configuration-gated CAPTCHA (Cloudflare Turnstile) verification to the framework's unauthenticated, email-sending API actions, so consuming applications can enable bot protection without each writing their own filter.

Motivation

Three of the framework's UserAPI endpoints are unauthenticated and each sends mail to a caller-supplied address:

Endpoint Handler
POST /user/registration UserAPI.registerUserAccount
POST /user/resetPassword UserAPI.resetPassword
POST /user/resendRegistrationToken UserAPI.resendRegistrationToken

Any deployment of the framework with open registration and verification email enabled exposes these to automated abuse: account-creation floods, and mailing arbitrary third parties from the deployment's domain. The second is the more damaging one — it burns the sending domain's reputation, and the operator finds out from their ESP rather than from their own logs.

The framework has no CAPTCHA support today (no CAPTCHA or Turnstile classes ship in 5.1.1). Every consumer that wants this has to write the same interceptor, in front of handlers it does not own, and get the failure-response shape right against JSONResponse and the framework's bundled client JS. That is the kind of thing the framework should own.

There is already a companion library — com.digitalsanctuary:ds-spring-cf-turnstile — providing TurnstileValidationService. This issue is about wiring it in optionally, not about writing a new validator.

Proposed design

Configuration-gated, default off. No behavior change for any existing consumer that does not opt in. Something in the shape of:

user:
  security:
    captcha:
      enabled: false          # master switch, default false
      provider: turnstile     # only turnstile initially
      protect:                # per-action, so operators choose their own friction trade-off
        registration: true
        resetPassword: true
        resendRegistrationToken: true
        login: false          # off by default — see below

Per-action granularity matters. The three email-sending actions are near-universally worth protecting. Login is deliberately separate and defaults off: it is a daily action for legitimate users, the framework already has per-account lockout (failedLoginAttempts), and many deployments handle login abuse at the CDN edge instead. Operators should be able to protect the email endpoints without taxing every login.

Validation runs ahead of the handler. The three endpoints are JSON APIs returning ResponseEntity<JSONResponse>, so a failed check should return a JSONResponse with a distinct code and a localized message, letting the bundled client JS render it the same way it renders other API errors. A redirect or an HTML error body would be silently dropped by that JS.

Optional dependency. ds-spring-cf-turnstile should stay optional on the classpath — resolve TurnstileValidationService through ObjectProvider / Optional and auto-configure only when present, so consumers who do not want it pay nothing.

Fail closed when enabled. If CAPTCHA is enabled but the validation service is absent or the provider is unreachable, reject rather than pass through. A CAPTCHA that silently degrades to "allow" under load is worse than no CAPTCHA, because the operator believes they are protected. This should be explicit and documented, not incidental. Consider whether it warrants its own override for operators who would rather stay available.

Never silently pass. Related: the configuration should make it impossible to end up with a provider configured with test/always-pass credentials in production without it being visible — at minimum a startup WARN when enabled: true and the configured sitekey matches a known test key.

Sitekey exposure for templates. Consumers own their registration and password-reset templates, so the framework cannot render the widget for them. It can make the sitekey available to the view layer (a @ControllerAdvice model attribute, or a documented property reference) so each consumer does not re-plumb it.

Out of scope

Acceptance criteria

  1. With captcha.enabled: false (the default), behavior is byte-identical to today, and the library builds and runs without ds-spring-cf-turnstile on the classpath.
  2. With it enabled for a given action, a request carrying no token or an invalid token is rejected before the handler runs, and no email is sent.
  3. Rejection returns a JSONResponse the bundled client JS renders as an error.
  4. Each protected action can be toggled independently.
  5. Enabling CAPTCHA with the validation service unavailable fails closed, and this is covered by a test.
  6. Documented in the README/wiki alongside the other user.security.* settings, including the test-key warning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions