diff --git a/CLAUDE.md b/CLAUDE.md index a61fc5ee..5f7a7ec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,7 @@ The [SpringUserFrameworkDemoApp](https://github.com/devondragon/SpringUserFramew com.digitalsanctuary.spring.user ├── api/ # REST endpoints (UserAPI) ├── audit/ # Audit logging system +├── captcha/ # Optional CAPTCHA (Turnstile) on unauthenticated email-sending API actions ├── controller/ # MVC controllers for HTML pages ├── dev/ # Dev login auto-configuration (local profile only) ├── dto/ # Data transfer objects @@ -119,12 +120,13 @@ com.digitalsanctuary.spring.user - `BaseSessionProfile` - Session-scoped profile access - `UserPreDeleteEvent` - Listen for user deletion to clean up related data - `AuthenticationEntryPoint` - Override via `@ConditionalOnMissingBean` to customize session expiry behavior +- `CaptchaService` - Supply a bean to replace the built-in Turnstile CAPTCHA provider ### Auto-Configuration -- Entry point: `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` → `UserConfiguration` +- Entry point: `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` → `UserConfiguration` plus feature auto-configurations (audit mail, CAPTCHA, security beans, security filter chain) - `UserAutoConfigurationRegistrar` dynamically registers the library package for entity/repository scanning -- No conditional annotations — all features load, controlled by `user.*` properties +- Most features load unconditionally and are controlled by `user.*` properties. The CAPTCHA support (`captcha` package) is the exception: it is gated by `@ConditionalOnProperty`/`@ConditionalOnClass` so it stays inert and dependency-free when disabled. ### Startup Behavior @@ -141,6 +143,7 @@ com.digitalsanctuary.spring.user All configuration uses `user.*` prefix in application.yml. Key property groups: - `user.security.*` - URIs, default action (allow/deny), bcrypt strength, lockout settings, testHashTime +- `user.security.captcha.*` - Optional CAPTCHA (Cloudflare Turnstile) on unauthenticated email-sending API actions (enabled, provider, allowUnusableProvider, protect.*) - `user.registration.*` - Email verification toggle, OAuth provider toggles - `user.mail.*` - Email sender settings (fromAddress) - `user.audit.*` - Audit logging (logFilePath, flushOnWrite, logEvents, maxQueryResults) diff --git a/CONFIG.md b/CONFIG.md index c149e23c..44d9d365 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -92,6 +92,33 @@ Password-reset and verification emails contain a link back to your application. When neither `appUrl` nor `trustedHosts` is set, links are built from the request host (backward-compatible behavior) and a startup warning is logged. +### CAPTCHA Protection (Cloudflare Turnstile) + +Optional CAPTCHA verification on the framework's unauthenticated, email-sending API actions (`POST /user/registration`, `POST /user/resetPassword`, `POST /user/resendRegistrationToken`). Disabled by default: no CAPTCHA interceptor or provider beans are registered and behavior is unchanged until you opt in. + +- **Enabled (`user.security.captcha.enabled`)**: Master switch. When `false` (default), no CAPTCHA interceptor or provider beans are registered and no requests are checked. +- **Provider (`user.security.captcha.provider`)**: The CAPTCHA provider. Only `turnstile` (Cloudflare Turnstile, via the optional `com.digitalsanctuary:ds-spring-cf-turnstile` dependency) is currently supported. Defaults to `turnstile`. Supply your own `CaptchaService` bean to use a different provider; it takes precedence over the built-in one. +- **Allow Unusable Provider (`user.security.captcha.allow-unusable-provider`)**: Whether to start when the provider reports it cannot verify anything (missing Turnstile secret or site key, absent service bean). Defaults to `false` — such a provider rejects every request to every protected endpoint, so startup fails rather than shipping an outage that looks healthy. Set `true` to boot anyway and take a startup ERROR banner instead. +- **Protect Registration (`user.security.captcha.protect.registration`)**: Require CAPTCHA on `POST /user/registration`. Defaults to `true`. +- **Protect Passwordless Registration (`user.security.captcha.protect.passwordless-registration`)**: Require CAPTCHA on `POST /user/registration/passwordless`. Defaults to `true`. Only reachable if you have added that path to `user.security.unprotectedURIs` and have a WebAuthn credential service, but it creates an account and sends a verification email for an unauthenticated caller just like the standard registration endpoint. +- **Protect Reset Password (`user.security.captcha.protect.reset-password`)**: Require CAPTCHA on `POST /user/resetPassword`. Defaults to `true`. +- **Protect Resend Registration Token (`user.security.captcha.protect.resend-registration-token`)**: Require CAPTCHA on `POST /user/resendRegistrationToken`. Defaults to `true`. + +**Example configuration:** +```yaml +user: + security: + captcha: + enabled: true + provider: turnstile + protect: + registration: true + reset-password: true + resend-registration-token: true +``` + +**Client contract**: these endpoints consume JSON bodies, so the CAPTCHA token must be sent in the `X-Captcha-Token` request header (preferred) or the `cf-turnstile-response` query parameter — it cannot be added as a form field. Rejections return `HTTP 403` with a `JSONResponse` body (`code: 8`), customizable via the `message.captcha.validation-failed` message key. The site key is exposed to MVC pages as the `captchaSiteKey` model attribute. See the README's [CAPTCHA Protection](README.md#captcha-protection-cloudflare-turnstile) section for the full client-side contract, fail-closed semantics, and scope notes (login is not covered). + ### Passwordless Initial Password Step-Up (SUF-02) `POST /user/setPassword` adds an *initial* password to a passwordless (passkey-only) account. Because there is no current credential to verify, the endpoint is gated: diff --git a/README.md b/README.md index e56400c1..724f997f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond - [Account Lockout](#account-lockout) - [Audit Logging](#audit-logging) - [HTMX Support](#htmx-support) + - [CAPTCHA Protection (Cloudflare Turnstile)](#captcha-protection-cloudflare-turnstile) - [User Management](#user-management) - [Registration](#registration) - [Profile Management](#profile-management) @@ -550,6 +551,140 @@ public AuthenticationEntryPoint authenticationEntryPoint() { } ``` +### CAPTCHA Protection (Cloudflare Turnstile) + +The framework can require a CAPTCHA challenge on its unauthenticated, email-sending API actions — `POST /user/registration`, `POST /user/registration/passwordless`, `POST /user/resetPassword`, and `POST /user/resendRegistrationToken` — to block automated abuse (registration spam, password-reset flooding, verification-email bombing). It is **off by default**: with `user.security.captcha.enabled=false` (the default), no CAPTCHA interceptor or provider beans are registered and no requests are checked — behavior is identical to previous releases, and no extra dependency is required. + +**Setup** + +Add the Turnstile library to your consuming application and configure your Cloudflare site key/secret: + +```groovy +implementation 'com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0' +``` + +```yaml +ds: + cf: + turnstile: + sitekey: + secret: +``` + +Then enable CAPTCHA protection in the framework: + +```yaml +user: + security: + captcha: + enabled: true + provider: turnstile + protect: + registration: true + reset-password: true + resend-registration-token: true +``` + +Each `protect.*` flag can be toggled independently, so you can, for example, protect registration and password reset but leave resend-verification-token open. + +**Client contract** + +These endpoints consume JSON request bodies, so the CAPTCHA token cannot be added as a form field — it must be sent as either: + +- The `X-Captcha-Token` request header (preferred), or +- The `cf-turnstile-response` query parameter (fallback) + +On failure the API returns `HTTP 403` with the same `JSONResponse` shape as other API errors: + +```json +{"success":false,"redirectUrl":null,"code":8,"messages":["CAPTCHA verification failed. Please complete the challenge and try again."],"data":null} +``` + +The message text is customizable via the `message.captcha.validation-failed` key in your `messages.properties`. + +**Widget rendering** + +Consuming applications own their own templates. The framework exposes the configured site key as the `captchaSiteKey` model attribute on MVC controllers (the Turnstile library also offers `${@turnstileValidationService.getTurnstileSitekey()}` for use directly in Thymeleaf). A minimal registration page snippet: + +The `data-sitekey` value must be bound by your template engine — `${captchaSiteKey}` in a plain HTML +attribute is not evaluated and Turnstile would receive the literal text. In Thymeleaf: + +```html +
+ + +``` + +**Fail-closed semantics** + +- Enabling CAPTCHA (`enabled=true`) without a resolvable provider — the Turnstile library missing from the classpath, an unrecognized `provider`, or no custom `CaptchaService` bean — fails application startup rather than silently letting requests through unprotected. +- Enabling CAPTCHA with a provider that resolves but cannot verify anything — no Turnstile secret, no site key, or an absent `TurnstileValidationService` bean — also fails startup. Such a provider rejects every registration, reset, and resend while the application looks healthy, so this is surfaced loudly rather than as a 100% rejection rate in production. Set `user.security.captcha.allow-unusable-provider=true` to start anyway and take a startup ERROR banner instead. +- If the provider is unreachable or errors at request time, the request is rejected (fail closed), not allowed through. +- Cloudflare's always-pass test keys (e.g. `1x00000000000000000000AA`) are detected at startup and logged as a prominent `WARN` banner. Never ship test keys to production. + +**Scope** + +- **Login is deliberately not covered.** Login is handled by a Spring Security filter, not an MVC handler, so it falls outside this interceptor-based approach; per-account lockout (`user.security.failedLoginAttempts`) already provides brute-force protection there. If you want CAPTCHA on login too, enable `ds-spring-cf-turnstile`'s own login filter with `ds.cf.turnstile.login.enabled=true`, or use Cloudflare edge-level challenges. +- A custom `CaptchaService` bean (see the `com.digitalsanctuary.spring.user.captcha.CaptchaService` SPI) fully replaces the built-in Turnstile provider if you want a different CAPTCHA vendor. +- Enforcement matches request paths with Spring's default `PathPatternParser`. If your application installs a custom parser via `PathMatchConfigurer#setPatternParser` (for example a case-insensitive one), handler mapping and CAPTCHA enforcement could disagree on exotic path spellings — don't relax path matching on an application that exposes these endpoints. + +**Custom providers** + +Implement `CaptchaService` and register it as a bean; it takes precedence over the built-in Turnstile provider. The SPI mirrors the `RegistrationGuard` shape used elsewhere in the framework: + +```java +@Component +public class HCaptchaService implements CaptchaService { + + @Override + public CaptchaVerification verify(CaptchaContext context) { + try { + return client.siteverify(context.token(), context.remoteIp()) + ? CaptchaVerification.verified() + : CaptchaVerification.rejected("hcaptcha reported the token invalid"); + } catch (IOException e) { + // Report the failure — the framework decides that this rejects the request. + return CaptchaVerification.error("hcaptcha unreachable: " + e.getMessage()); + } + } + + @Override + public Optional siteKey() { + return Optional.of(siteKey); + } +} +``` + +`CaptchaContext` carries the `CaptchaAction` being verified, so providers that bind a token to the challenge it was issued for (reCAPTCHA v3 actions) or apply per-action score thresholds can do so. The three-way `CaptchaVerification` outcome means an implementation never has to decide what a provider outage means: report `error(...)` and the framework fails closed. Throwing is safe too — the framework catches it and rejects the request with the same documented 403 body. Implementations must be thread-safe. + ## User Management ### Registration diff --git a/build.gradle b/build.gradle index 71d33c39..ab27dee0 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,7 @@ dependencies { // Other dependencies (moved to test scope for library) implementation 'org.passay:passay:2.0.0' implementation 'org.apache.commons:commons-text:1.15.0' + compileOnly 'com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0' compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1' compileOnly 'org.springframework.retry:spring-retry:2.0.13' @@ -70,6 +71,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-oauth2-client' testImplementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + testImplementation 'com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.security:spring-security-webauthn' testImplementation 'org.springframework.retry:spring-retry:2.0.13' diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAction.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAction.java new file mode 100644 index 00000000..24b22b09 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAction.java @@ -0,0 +1,56 @@ +package com.digitalsanctuary.spring.user.captcha; + +/** + * The framework API actions that can be CAPTCHA-protected. + * + *

+ * These are the unauthenticated, email-sending endpoints an abuser can drive without an account: + * registration spam, password-reset flooding, and verification-email bombing. Each constant carries + * the context-relative path of its action, which is the single source of truth for interceptor + * registration ({@code CaptchaAutoConfiguration}), request matching + * ({@code CaptchaValidationInterceptor}), and the per-action toggles in + * {@link CaptchaConfigProperties.Protect}. + *

+ * + *

+ * The action is passed to {@link CaptchaService#verify(CaptchaContext)} so providers that bind a + * token to the challenge it was issued for (reCAPTCHA v3 actions) or apply per-action score + * thresholds can do so. Adding a protected action is a matter of adding a constant here and a + * toggle on {@code Protect}. + *

+ */ +public enum CaptchaAction { + + /** {@code POST /user/registration} — new account registration. */ + REGISTRATION("/user/registration"), + + /** + * {@code POST /user/registration/passwordless} — passkey-only account registration. Reachable + * only when a WebAuthn credential management bean is present and the consumer has added the + * path to {@code user.security.unprotectedURIs}, but when it is reachable it creates an account + * and sends a verification email for an unauthenticated caller, exactly like + * {@link #REGISTRATION} and without even requiring a password in the payload. + */ + PASSWORDLESS_REGISTRATION("/user/registration/passwordless"), + + /** {@code POST /user/resetPassword} — request a password-reset email. */ + RESET_PASSWORD("/user/resetPassword"), + + /** {@code POST /user/resendRegistrationToken} — resend the verification email. */ + RESEND_REGISTRATION_TOKEN("/user/resendRegistrationToken"); + + private final String path; + + CaptchaAction(String path) { + this.path = path; + } + + /** + * Returns the context-relative request path of this action. + * + * @return the path, e.g. {@code /user/registration} + */ + public String path() { + return path; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java new file mode 100644 index 00000000..29415653 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java @@ -0,0 +1,125 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.Arrays; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +import tools.jackson.databind.ObjectMapper; + +/** + * Auto-configuration for optional CAPTCHA verification on the framework's unauthenticated, + * email-sending API actions (registration, password reset, resend verification token). + * + *

+ * Unless {@code user.security.captcha.enabled=true}, no interceptor is registered and no provider + * beans are created, so no request is ever inspected and existing consumers see no behavior change. + * Only the {@link CaptchaConfigProperties} binding and {@link CaptchaStartupValidator} are always + * registered, and the validator is a no-op when disabled. The Turnstile provider additionally + * requires {@code com.digitalsanctuary:ds-spring-cf-turnstile} on the classpath; the framework + * itself only depends on it at compile time. + *

+ */ +@AutoConfiguration +@EnableConfigurationProperties(CaptchaConfigProperties.class) +public class CaptchaAutoConfiguration { + + /** + * The startup validator is registered unconditionally — regardless of the enabled flag and of + * whether any provider library is on the classpath — so enabling CAPTCHA without a provider + * fails fast instead of silently not protecting anything. It early-returns when CAPTCHA is + * disabled. + * + * @param captchaConfigProperties the captcha configuration properties + * @param captchaServiceProvider provider for the (possibly absent) captcha service + * @return the startup validator + */ + @Bean + public CaptchaStartupValidator captchaStartupValidator(CaptchaConfigProperties captchaConfigProperties, + ObjectProvider captchaServiceProvider) { + return new CaptchaStartupValidator(captchaConfigProperties, captchaServiceProvider); + } + + /** + * Turnstile provider wiring. Guarded by classpath presence of the Turnstile library so the + * framework runs without it; the adapter bean backs off to any consumer-supplied + * {@link CaptchaService}. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(TurnstileValidationService.class) + @ConditionalOnProperty(name = "user.security.captcha.enabled", havingValue = "true") + static class TurnstileCaptchaConfiguration { + + /** + * The Turnstile-backed captcha service. + * + * @param turnstileServiceProvider provider for the Turnstile validation service bean + * @return the captcha service + */ + @Bean + @ConditionalOnMissingBean(CaptchaService.class) + @ConditionalOnProperty(name = "user.security.captcha.provider", havingValue = "turnstile", + matchIfMissing = true) + public CaptchaService captchaService(ObjectProvider turnstileServiceProvider, + ObjectProvider turnstilePropertiesProvider) { + return new TurnstileCaptchaService(turnstileServiceProvider, turnstilePropertiesProvider); + } + } + + /** + * Interceptor registration, active only when CAPTCHA is enabled. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "user.security.captcha.enabled", havingValue = "true") + static class CaptchaWebConfiguration { + + /** + * The captcha validation interceptor. + * + * @param captchaConfigProperties the captcha configuration properties + * @param captchaServiceProvider provider for the (possibly absent) captcha service + * @param messages the message source for localized rejection messages + * @return the interceptor + */ + @Bean + public CaptchaValidationInterceptor captchaValidationInterceptor( + CaptchaConfigProperties captchaConfigProperties, + ObjectProvider captchaServiceProvider, MessageSource messages, + ObjectProvider objectMapperProvider, ApplicationEventPublisher eventPublisher) { + return new CaptchaValidationInterceptor(captchaConfigProperties, captchaServiceProvider, messages, + objectMapperProvider, eventPublisher); + } + + /** + * Registers the interceptor against exactly the {@link CaptchaAction} paths. Deriving the + * patterns from the enum keeps registration and enforcement from drifting apart — a + * mismatch there is a silent bypass. + * + * @param captchaValidationInterceptor the interceptor to register + * @return the WebMvcConfigurer registering the interceptor + */ + @Bean + public WebMvcConfigurer captchaWebMvcConfigurer(CaptchaValidationInterceptor captchaValidationInterceptor) { + String[] paths = Arrays.stream(CaptchaAction.values()).map(CaptchaAction::path).toArray(String[]::new); + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(captchaValidationInterceptor).addPathPatterns(paths); + } + }; + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java new file mode 100644 index 00000000..393b07f5 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java @@ -0,0 +1,85 @@ +package com.digitalsanctuary.spring.user.captcha; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; + +/** + * Configuration properties for optional CAPTCHA verification on the framework's unauthenticated, + * email-sending API actions. Bound from the {@code user.security.captcha} prefix. + * + *

+ * Disabled by default: with {@code user.security.captcha.enabled=false} the framework registers no + * CAPTCHA interceptor or provider beans, no requests are inspected, and behavior is identical to + * previous releases. This properties class and {@link CaptchaStartupValidator} are always + * registered; the validator early-returns when disabled. + *

+ */ +@Data +@ConfigurationProperties(prefix = "user.security.captcha") +public class CaptchaConfigProperties { + + /** + * Master switch for CAPTCHA verification. When false (the default), no CAPTCHA interceptor or + * provider beans are registered and no requests are checked. Read at startup only: changing + * this field on the bound bean at runtime does not turn interception on or off, because the + * interceptor and provider beans are created conditionally during context refresh. + */ + private boolean enabled = false; + + /** + * The CAPTCHA provider. Only "turnstile" (Cloudflare Turnstile via + * com.digitalsanctuary:ds-spring-cf-turnstile) is currently supported. Consumers may also + * supply their own {@link CaptchaService} bean, which takes precedence. + */ + private String provider = "turnstile"; + + /** + * Whether to start even when the configured provider reports it cannot verify anything (see + * {@link CaptchaService#configurationErrors()}) — for example a missing Turnstile secret or + * site key. False by default: such a provider rejects every request to every protected + * endpoint, so failing startup surfaces the misconfiguration instead of shipping an outage + * that looks healthy. Set true to boot anyway and take the WARN banner instead. + */ + private boolean allowUnusableProvider = false; + + /** Per-action protection toggles, effective only when {@link #enabled} is true. */ + private Protect protect = new Protect(); + + /** + * Per-action CAPTCHA toggles. All three unauthenticated email-sending actions default to + * protected once the master switch is on. + */ + @Data + public static class Protect { + + /** Require CAPTCHA on POST /user/registration. */ + private boolean registration = true; + + /** Require CAPTCHA on POST /user/registration/passwordless. */ + private boolean passwordlessRegistration = true; + + /** Require CAPTCHA on POST /user/resetPassword. */ + private boolean resetPassword = true; + + /** Require CAPTCHA on POST /user/resendRegistrationToken. */ + private boolean resendRegistrationToken = true; + + /** + * Whether the given action requires a CAPTCHA. Unknown actions are treated as protected so + * that adding a {@link CaptchaAction} constant without a matching toggle here fails closed + * rather than silently leaving the new action unprotected. + * + * @param action the action to check; must not be null + * @return true when the action requires a valid CAPTCHA token + */ + public boolean isProtected(CaptchaAction action) { + return switch (action) { + case REGISTRATION -> registration; + case PASSWORDLESS_REGISTRATION -> passwordlessRegistration; + case RESET_PASSWORD -> resetPassword; + case RESEND_REGISTRATION_TOKEN -> resendRegistrationToken; + }; + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaContext.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaContext.java new file mode 100644 index 00000000..2701a14c --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaContext.java @@ -0,0 +1,41 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Everything a {@link CaptchaService} needs to verify one request. + * + *

+ * Passing a context rather than a widening parameter list means new information can be added for + * providers that need it without breaking existing implementations — this is a published library + * SPI, so a signature change after release is a breaking change for every consumer. + *

+ * + * @param action which protected API action is being verified; never null. Providers that bind a + * token to the challenge it was issued for (reCAPTCHA v3 actions) or apply per-action score + * thresholds should use this. + * @param token the CAPTCHA response token supplied by the client; never null or blank (the + * framework rejects missing tokens before calling the provider) + * @param remoteIp the resolved client IP to report to the provider, or null when it could not be + * determined. Resolved once by the framework so provider calls and rejection logs agree on + * who the client is. + * @param request the current request, for providers needing details not surfaced above; never null + */ +public record CaptchaContext(CaptchaAction action, String token, String remoteIp, HttpServletRequest request) { + + /** + * Canonical constructor. + * + * @param action which protected API action is being verified; must not be null + * @param token the CAPTCHA response token; must not be null + * @param remoteIp the resolved client IP, may be null + * @param request the current request; must not be null + */ + public CaptchaContext { + Objects.requireNonNull(action, "action must not be null"); + Objects.requireNonNull(token, "token must not be null"); + Objects.requireNonNull(request, "request must not be null"); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java new file mode 100644 index 00000000..82a18102 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java @@ -0,0 +1,108 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.List; +import java.util.Optional; + +/** + * Provider-neutral CAPTCHA verification SPI. + * + *

+ * The framework ships a Cloudflare Turnstile implementation ({@code TurnstileCaptchaService}), + * auto-configured when {@code user.security.captcha.enabled=true}, the + * {@code com.digitalsanctuary:ds-spring-cf-turnstile} library is on the classpath, and + * {@code user.security.captcha.provider} is {@code turnstile} (the default). Consumers may register + * their own {@code CaptchaService} bean to plug in a different provider; a consumer-supplied bean + * takes precedence over the built-in one. + *

+ * + *

Usage Example

+ * + *
{@code
+ * @Component
+ * public class HCaptchaService implements CaptchaService {
+ *     private final HCaptchaClient client;
+ *
+ *     @Override
+ *     public CaptchaVerification verify(CaptchaContext context) {
+ *         try {
+ *             return client.siteverify(context.token(), context.remoteIp())
+ *                     ? CaptchaVerification.verified()
+ *                     : CaptchaVerification.rejected("hcaptcha reported the token invalid");
+ *         } catch (IOException e) {
+ *             // Report the failure; the framework decides that this rejects the request.
+ *             return CaptchaVerification.error("hcaptcha unreachable: " + e.getMessage());
+ *         }
+ *     }
+ * }
+ * }
+ * + *

+ * Fail-closed: implementations never have to decide what an outage means. Report + * {@link CaptchaVerification#error(String)} when verification could not be completed and the + * framework rejects the request. Throwing is also safe — the framework catches any runtime + * exception from {@link #verify(CaptchaContext)} and treats it as an error — but returning + * {@code error(...)} produces a clearer log. + *

+ * + *

+ * Thread Safety: implementations must be thread-safe as they may be invoked + * concurrently from multiple request threads. + *

+ * + * @see CaptchaContext + * @see CaptchaVerification + */ +public interface CaptchaService { + + /** + * Verifies a CAPTCHA response token. + * + * @param context the request being verified; never null, and its token is never blank + * @return the outcome; never null. Only {@link CaptchaVerification.Outcome#VERIFIED} lets the + * request proceed. + */ + CaptchaVerification verify(CaptchaContext context); + + /** + * Returns the public site key for rendering the CAPTCHA widget. + * + *

+ * Called once per request for MVC page controllers (see {@code CaptchaSiteKeyControllerAdvice}), + * so implementations should return a cached or configured value rather than performing I/O. + *

+ * + * @return the public site key, or empty when none is configured + */ + default Optional siteKey() { + return Optional.empty(); + } + + /** + * Returns human-readable warnings about the current provider configuration (for example, + * always-pass test credentials). Logged at WARN during startup when CAPTCHA is enabled. + * + * @return warnings to log at startup; empty when the configuration looks production-ready + */ + default List configurationWarnings() { + return List.of(); + } + + /** + * Returns reasons this provider cannot verify anything — a missing credential, an absent + * delegate bean, or any other state in which {@link #verify(CaptchaContext)} would reject + * every request. + * + *

+ * Reported separately from {@link #configurationWarnings()} because the consequence is + * different: a misconfigured provider does not degrade the service, it takes every protected + * endpoint offline with a 403 while the application looks healthy. When CAPTCHA is enabled and + * this returns anything, {@code CaptchaStartupValidator} fails startup unless + * {@code user.security.captcha.allow-unusable-provider=true}. + *

+ * + * @return reasons the provider cannot work; empty when it is usable + */ + default List configurationErrors() { + return List.of(); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java new file mode 100644 index 00000000..fcd4d094 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java @@ -0,0 +1,59 @@ +package com.digitalsanctuary.spring.user.captcha; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Exposes the CAPTCHA public site key as the {@code captchaSiteKey} model attribute, so consuming + * applications can render the CAPTCHA widget in their own templates without re-plumbing the key. + * Registered only when {@code user.security.captcha.enabled=true}. + * + *

+ * The {@code annotations = Controller.class} selector is resolved with a meta-annotation search, so + * this advice applies to {@code @Controller} types and to {@code @RestController} types + * (which are meta-annotated {@code @Controller}). Response bodies are unaffected either way — the + * model is ignored for {@code @ResponseBody} handlers — but {@link CaptchaService#siteKey()} is + * called once per request across the whole MVC surface, which is why that method is documented as + * needing to be a cached or configured lookup rather than I/O. + *

+ */ +@Slf4j +@ConditionalOnProperty(name = "user.security.captcha.enabled", havingValue = "true") +@ControllerAdvice(annotations = Controller.class) +@RequiredArgsConstructor +public class CaptchaSiteKeyControllerAdvice { + + private final ObjectProvider captchaServiceProvider; + + /** + * The CAPTCHA public site key, or null when no provider is available, none is configured, or + * the provider fails to supply one. + * + * @return the site key for widget rendering + */ + @ModelAttribute("captchaSiteKey") + public String captchaSiteKey() { + CaptchaService captchaService = captchaServiceProvider.getIfAvailable(); + if (captchaService == null) { + return null; + } + try { + return captchaService.siteKey().orElse(null); + } catch (RuntimeException e) { + // This advice runs on every @Controller request, so a throwing consumer-supplied + // provider would otherwise break every MVC page in the application, not just the + // CAPTCHA-bearing ones. The site key is a display concern, not a security gate: + // degrading to a missing widget is safe (the interceptor still rejects tokenless + // POSTs), breaking all page rendering is not. + log.error("CaptchaService {} threw while supplying the site key. Rendering without it.", + captchaService.getClass().getName(), e); + return null; + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java new file mode 100644 index 00000000..8853f35e --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java @@ -0,0 +1,85 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Validates the CAPTCHA configuration at startup. + * + *

+ * Fails application startup (fail closed) when {@code user.security.captcha.enabled=true} and + * either no {@link CaptchaService} can be resolved — the configured provider's library is not on + * the classpath, or {@code user.security.captcha.provider} names an unknown provider — or the + * resolved provider reports it cannot verify anything via + * {@link CaptchaService#configurationErrors()} (a missing secret or site key, say). Both states + * would otherwise produce an application that starts clean and then rejects every request to every + * protected endpoint. Set {@code user.security.captcha.allow-unusable-provider=true} to downgrade + * the second case to a loud ERROR banner. Also logs provider configuration warnings (such as + * Cloudflare always-pass test keys) so test credentials cannot reach production unnoticed. + *

+ */ +@Slf4j +@RequiredArgsConstructor +public class CaptchaStartupValidator { + + private final CaptchaConfigProperties captchaConfigProperties; + private final ObjectProvider captchaServiceProvider; + + /** + * Validates CAPTCHA configuration as this bean initializes. + * + *

+ * Deliberately {@code @PostConstruct} rather than a {@code ContextRefreshedEvent} listener: a + * consuming application that defines an {@code applicationEventMulticaster} with a + * {@code taskExecutor} publishes context events on worker threads, where a thrown exception is + * discarded and the fail-startup guarantee below would silently become a no-op. Bean + * initialization is not interceptable that way. + *

+ */ + @PostConstruct + public void validateCaptchaConfiguration() { + if (!captchaConfigProperties.isEnabled()) { + return; + } + CaptchaService captchaService = captchaServiceProvider.getIfAvailable(); + if (captchaService == null) { + throw new IllegalStateException("user.security.captcha.enabled=true but no CaptchaService is available" + + " for provider '" + captchaConfigProperties.getProvider() + "'. Add" + + " com.digitalsanctuary:ds-spring-cf-turnstile to the classpath (provider 'turnstile'), supply" + + " your own CaptchaService bean, or set user.security.captcha.enabled=false. Refusing to start" + + " with CAPTCHA silently disabled (fail closed)."); + } + List errors = captchaService.configurationErrors(); + if (!errors.isEmpty() && !captchaConfigProperties.isAllowUnusableProvider()) { + throw new IllegalStateException("user.security.captcha.enabled=true but the '" + + captchaConfigProperties.getProvider() + "' provider cannot verify anything, so every request to" + + " every CAPTCHA-protected endpoint would be rejected: " + String.join(" ", errors) + + " Fix the configuration, set user.security.captcha.enabled=false, or set" + + " user.security.captcha.allow-unusable-provider=true to start anyway."); + } + CaptchaConfigProperties.Protect protect = captchaConfigProperties.getProtect(); + log.info("CAPTCHA protection enabled (provider: {}). Protected actions: registration={}," + + " passwordlessRegistration={}, resetPassword={}, resendRegistrationToken={}", + captchaConfigProperties.getProvider(), protect.isRegistration(), + protect.isPasswordlessRegistration(), protect.isResetPassword(), + protect.isResendRegistrationToken()); + for (String error : errors) { + // Only reachable with allow-unusable-provider=true; the consumer opted into booting + // with a provider that rejects everything, so make it as loud as possible. + log.error("========================================================"); + log.error("CAPTCHA PROVIDER UNUSABLE: {}", error); + log.error("Every CAPTCHA-protected request will be rejected."); + log.error("========================================================"); + } + for (String warning : captchaService.configurationWarnings()) { + log.warn("========================================================"); + log.warn("CAPTCHA CONFIGURATION WARNING: {}", warning); + log.warn("========================================================"); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java new file mode 100644 index 00000000..4bbf83cf --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java @@ -0,0 +1,268 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.io.IOException; +import java.util.EnumMap; +import java.util.Map; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.http.MediaType; +import org.springframework.http.server.PathContainer; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.util.JSONResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Rejects POSTs to the framework's unauthenticated email-sending API actions unless they carry a + * valid CAPTCHA token. Registered by {@link CaptchaAutoConfiguration} only when + * {@code user.security.captcha.enabled=true}, against exactly the {@link CaptchaAction} paths. + * + *

+ * The token is read from the {@value #TOKEN_HEADER} request header, falling back to the + * {@value #TOKEN_PARAMETER} request parameter. The endpoints consume JSON request bodies, so the + * token cannot travel in the body; client code must send it in the header or query string. + *

+ * + *

+ * Rejections are written directly as a {@code JSONResponse}-shaped body (HTTP 403, code + * {@value #ERROR_CODE_CAPTCHA_FAILED}) so the consuming application's client JS renders them the + * same way as other API errors. Fail-closed: a missing token, an unavailable provider, a provider + * error, a provider that throws, or an unrecognized path all reject the request before the handler + * runs, so no email is sent. + *

+ */ +@Slf4j +@RequiredArgsConstructor +public class CaptchaValidationInterceptor implements HandlerInterceptor { + + /** Request header carrying the CAPTCHA response token (checked first). */ + public static final String TOKEN_HEADER = "X-Captcha-Token"; + + /** + * Request parameter carrying the CAPTCHA response token (fallback). Named for Cloudflare + * Turnstile's own field so a Turnstile widget's default form field works unchanged; custom + * {@link CaptchaService} providers receive the token through this same parameter regardless of + * what their vendor calls it. Prefer {@value #TOKEN_HEADER}: query strings are recorded in + * access logs, proxy logs, and {@code Referer} headers, and CAPTCHA tokens should not be. + */ + public static final String TOKEN_PARAMETER = "cf-turnstile-response"; + + /** JSONResponse code for CAPTCHA validation failures. */ + public static final int ERROR_CODE_CAPTCHA_FAILED = 8; + + private static final String MESSAGE_KEY = "message.captcha.validation-failed"; + private static final String DEFAULT_MESSAGE = "CAPTCHA verification failed. Please complete the challenge and try again."; + + private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For"; + private static final String UNKNOWN_FORWARDED_FOR = "unknown"; + + /* + * The action patterns compiled with the same PathPatternParser the InterceptorRegistry uses + * (MappedInterceptor defaults to PathPatternParser.defaultInstance). Enforcement MUST use the + * same pattern engine as registration: PathPattern matches against the parsed request path, + * whose segments are URL-decoded and stripped of matrix parameters, so raw-URI string + * comparison would let variants like "/user/registration;jsessionid=x" or + * "/user/%72egistration" through unprotected while still reaching the handler. + */ + private static final Map ACTION_PATTERNS = buildActionPatterns(); + + private static Map buildActionPatterns() { + Map patterns = new EnumMap<>(CaptchaAction.class); + for (CaptchaAction action : CaptchaAction.values()) { + patterns.put(action, PathPatternParser.defaultInstance.parse(action.path())); + } + return patterns; + } + + private final CaptchaConfigProperties captchaConfigProperties; + private final ObjectProvider captchaServiceProvider; + private final MessageSource messages; + private final ObjectProvider objectMapperProvider; + private final ApplicationEventPublisher eventPublisher; + + /** Fallback used only when the application context has no ObjectMapper bean. */ + private static final ObjectMapper DEFAULT_OBJECT_MAPPER = JsonMapper.builder().build(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws IOException { + if (!"POST".equalsIgnoreCase(request.getMethod())) { + return true; + } + String path = request.getRequestURI(); + String clientIp = resolveClientIp(request); + CaptchaAction action = resolveAction(request); + if (action == null) { + // Unreachable through the real dispatcher: the interceptor is registered against + // exactly the CaptchaAction paths, matched with this same engine. Reached only if a + // request path cannot be parsed, or if registration and enforcement ever disagree. + log.warn("CAPTCHA interceptor invoked for unrecognized path {} from {}. Rejecting (fail closed).", path, + clientIp); + return reject(request, response, "Unrecognized protected path: " + path, clientIp); + } + if (!captchaConfigProperties.getProtect().isProtected(action)) { + return true; + } + String token = resolveToken(request); + if (token == null || token.isBlank()) { + log.warn("CAPTCHA token missing on {} from {}. Rejecting request.", path, clientIp); + return reject(request, response, "CAPTCHA token missing for action " + action, clientIp); + } + CaptchaService captchaService = captchaServiceProvider.getIfAvailable(); + if (captchaService == null) { + log.error("CAPTCHA is enabled but no CaptchaService is available. Failing closed for {}.", path); + return reject(request, response, "No CaptchaService available for action " + action, clientIp); + } + CaptchaVerification verification = verifySafely(captchaService, new CaptchaContext(action, token, clientIp, request)); + if (!verification.isVerified()) { + log.warn("CAPTCHA {} on {} from {}. Rejecting request. Detail: {}", verification.outcome(), path, clientIp, + verification.detail()); + return reject(request, response, + "CAPTCHA " + verification.outcome() + " for action " + action + ": " + verification.detail(), + clientIp); + } + return true; + } + + /** + * Calls the provider and converts any failure into an {@link CaptchaVerification.Outcome#ERROR} + * result. {@link CaptchaService} is a public SPI, so a third-party implementation may throw or + * return null; the framework — not the implementation — enforces that such a failure rejects + * the request and still produces the documented 403 body. + */ + private CaptchaVerification verifySafely(CaptchaService captchaService, CaptchaContext context) { + try { + CaptchaVerification verification = captchaService.verify(context); + if (verification == null) { + log.error("CaptchaService {} returned null for action {}. Failing closed.", + captchaService.getClass().getName(), context.action()); + return CaptchaVerification.error("provider returned null"); + } + return verification; + } catch (RuntimeException e) { + log.error("CaptchaService {} threw during verification of action {}. Failing closed.", + captchaService.getClass().getName(), context.action(), e); + return CaptchaVerification.error("provider threw " + e.getClass().getSimpleName()); + } + } + + /** + * Returns the {@link CaptchaAction} this request targets, or null when the path cannot be + * parsed or matches no known action. Matching uses the same {@code PathPattern} engine the + * interceptor registration uses, so this decision cannot disagree with the registration's. + */ + private CaptchaAction resolveAction(HttpServletRequest request) { + PathContainer path; + try { + path = resolvePathWithinApplication(request); + } catch (RuntimeException e) { + log.warn("Could not parse request path {}. Failing closed.", request.getRequestURI(), e); + return null; + } + for (Map.Entry entry : ACTION_PATTERNS.entrySet()) { + if (entry.getValue().matches(path)) { + return entry.getKey(); + } + } + return null; + } + + /** + * Returns the context-relative request path exactly as Spring's {@code PathPattern} engine saw + * it for handler mapping and interceptor matching: the {@code RequestPath} the + * {@code DispatcherServlet} parsed and cached before invoking interceptors, or an identical + * fresh parse of the request when no cached path exists (e.g. direct unit-test invocation). + */ + private PathContainer resolvePathWithinApplication(HttpServletRequest request) { + if (ServletRequestPathUtils.hasParsedRequestPath(request)) { + return ServletRequestPathUtils.getParsedRequestPath(request).pathWithinApplication(); + } + return ServletRequestPathUtils.parse(request).pathWithinApplication(); + } + + /** + * Resolves the client IP once so the value reported to the CAPTCHA provider and the value in + * rejection logs are the same — otherwise the WARN stream names the proxy while the provider + * sees the real client, and the logs cannot be used to identify an attacker. + * + *

+ * Uses the leftmost {@code X-Forwarded-For} entry when present, falling back to the socket + * address. {@code X-Forwarded-For} is client-supplied and therefore only trustworthy when the + * application sits behind a proxy that overwrites it; the value is advisory to the provider, + * which scores the token itself. + *

+ */ + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER); + if (forwardedFor != null && !forwardedFor.isBlank()) { + String first = forwardedFor.split(",", 2)[0].trim(); + // Some older proxies send the literal "unknown" rather than omitting the header; the + // socket address is more useful than forwarding that placeholder to the provider. + if (!first.isEmpty() && !UNKNOWN_FORWARDED_FOR.equalsIgnoreCase(first)) { + return first; + } + } + return request.getRemoteAddr(); + } + + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader(TOKEN_HEADER); + if (token == null || token.isBlank()) { + token = request.getParameter(TOKEN_PARAMETER); + } + return token; + } + + private boolean reject(HttpServletRequest request, HttpServletResponse response, String reason, String clientIp) + throws IOException { + publishAuditEvent(request, reason, clientIp); + String message = messages.getMessage(MESSAGE_KEY, null, DEFAULT_MESSAGE, request.getLocale()); + // Serialize the real JSONResponse rather than hand-building its shape, so a future field + // added to JSONResponse cannot silently diverge this error from every other API error. + JSONResponse body = JSONResponse.builder().success(false).code(ERROR_CODE_CAPTCHA_FAILED).message(message) + .build(); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapperProvider.getIfAvailable(() -> DEFAULT_OBJECT_MAPPER) + .writeValueAsString(body)); + return false; + } + + /** + * Records the rejection so operators can see CAPTCHA rejection volume the same way they see + * every other rejection in the API, rather than only as WARN log lines. + * + *

+ * Uses {@code getSession(false)}: these requests are unauthenticated and frequently automated, + * so creating a session for each one would let an abuser grow session storage just by being + * rejected. + *

+ */ + private void publishAuditEvent(HttpServletRequest request, String reason, String clientIp) { + try { + HttpSession session = request.getSession(false); + eventPublisher.publishEvent(AuditEvent.builder().source(this).user(null) + .sessionId(session != null ? session.getId() : null).ipAddress(clientIp) + .userAgent(request.getHeader("User-Agent")).action("CaptchaValidation").actionStatus("Failure") + .message(reason).build()); + } catch (RuntimeException e) { + // Auditing must never convert a rejection into a 500 — the request is being denied + // either way, and that outcome matters more than the audit record. + log.error("Failed to publish CAPTCHA rejection audit event.", e); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaVerification.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaVerification.java new file mode 100644 index 00000000..e4e0c021 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaVerification.java @@ -0,0 +1,86 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.Objects; + +/** + * The result of a {@link CaptchaService#verify(CaptchaContext)} call. + * + *

+ * The three-way outcome exists so the framework, not the provider implementation, decides what + * happens when verification could not be completed. A {@code boolean} cannot distinguish "this + * caller failed the challenge" from "the provider was unreachable", which leaves each implementer + * to invent that policy — and the tempting choice during a vendor outage (allow the request so + * signups keep working) silently disables the protection. With {@link Outcome#ERROR} the + * implementation reports what happened and the framework applies the fail-closed rule, so an + * implementation cannot fail open by accident. + *

+ * + * @param outcome what the provider determined; never null + * @param detail optional human-readable detail for logging, null when there is nothing to add + */ +public record CaptchaVerification(Outcome outcome, String detail) { + + /** What the provider determined about a token. */ + public enum Outcome { + + /** The provider positively verified the token. The request proceeds. */ + VERIFIED, + + /** The provider actively rejected the token (invalid, expired, already used). */ + REJECTED, + + /** + * Verification could not be completed (provider unreachable, misconfigured, malformed + * response). Treated as a rejection by the framework. + */ + ERROR + } + + /** + * Canonical constructor. + * + * @param outcome what the provider determined; must not be null + * @param detail optional detail for logging + */ + public CaptchaVerification { + Objects.requireNonNull(outcome, "outcome must not be null"); + } + + /** + * The token was positively verified. + * + * @return a VERIFIED result + */ + public static CaptchaVerification verified() { + return new CaptchaVerification(Outcome.VERIFIED, null); + } + + /** + * The provider actively rejected the token. + * + * @param detail why it was rejected, for logging; may be null + * @return a REJECTED result + */ + public static CaptchaVerification rejected(String detail) { + return new CaptchaVerification(Outcome.REJECTED, detail); + } + + /** + * Verification could not be completed. The framework rejects the request. + * + * @param detail what went wrong, for logging; may be null + * @return an ERROR result + */ + public static CaptchaVerification error(String detail) { + return new CaptchaVerification(Outcome.ERROR, detail); + } + + /** + * Whether the request may proceed. True only for {@link Outcome#VERIFIED}. + * + * @return true if the token was positively verified + */ + public boolean isVerified() { + return outcome == Outcome.VERIFIED; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java new file mode 100644 index 00000000..845962f8 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java @@ -0,0 +1,131 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.ObjectProvider; + +import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * {@link CaptchaService} adapter over the ds-spring-cf-turnstile library's + * {@link TurnstileValidationService} (requires 2.1.0+). + * + *

+ * This is the only framework class that references Turnstile types. It is intentionally NOT a + * scanned component: it is instantiated by {@link CaptchaAutoConfiguration} only when the Turnstile + * library is on the classpath, so the framework loads and runs without it. + *

+ * + *

+ * Fail-closed: if the {@code TurnstileValidationService} bean is unavailable (for example its + * auto-configuration was excluded) or Cloudflare cannot be reached, {@link #verify} reports + * {@link CaptchaVerification.Outcome#ERROR} and the framework rejects the request. + * Test-credential knowledge lives in the Turnstile library + * ({@code TurnstileValidationService#isUsingTestCredentials()}); this adapter only surfaces it. + *

+ */ +@Slf4j +@RequiredArgsConstructor +public class TurnstileCaptchaService implements CaptchaService { + + private final ObjectProvider turnstileServiceProvider; + private final ObjectProvider turnstilePropertiesProvider; + + @Override + public CaptchaVerification verify(CaptchaContext context) { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + log.error("CAPTCHA is enabled but no TurnstileValidationService bean is available. Failing closed."); + return CaptchaVerification.error("no TurnstileValidationService bean available"); + } + try { + // The framework resolves the client IP once (see CaptchaValidationInterceptor) so the + // address reported to Cloudflare matches the one in the rejection logs. + if (turnstileService.validateTurnstileResponse(context.token(), context.remoteIp())) { + return CaptchaVerification.verified(); + } + return CaptchaVerification.rejected("Turnstile reported the token invalid"); + } catch (RuntimeException e) { + log.error("Unexpected error during Turnstile verification. Failing closed.", e); + return CaptchaVerification.error("Turnstile verification threw " + e.getClass().getSimpleName()); + } + } + + @Override + public Optional siteKey() { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + return Optional.empty(); + } + try { + String siteKey = turnstileService.getTurnstileSitekey(); + return (siteKey == null || siteKey.isBlank()) ? Optional.empty() : Optional.of(siteKey); + } catch (RuntimeException e) { + log.error("Error retrieving Turnstile site key.", e); + return Optional.empty(); + } + } + + @Override + public List configurationWarnings() { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + // Reported as an error, not a warning — see configurationErrors(). + return List.of(); + } + try { + if (turnstileService.isUsingTestCredentials()) { + return List.of("Turnstile is configured with Cloudflare test credentials. CAPTCHA validation is" + + " running in test mode and provides NO bot protection. Do not use this in production."); + } + } catch (RuntimeException e) { + log.warn("Error querying Turnstile credential configuration. Provider could not be queried.", e); + return List.of("Turnstile configuration could not be queried. Verify that the TurnstileValidationService" + + " bean is properly configured and accessible."); + } + if (turnstilePropertiesProvider.getIfAvailable() == null) { + // A warning, not an error: a consumer who excludes the Turnstile auto-configuration and + // hand-wires a working TurnstileValidationService legitimately has no properties bean. + // We simply cannot verify their credentials, which is not the same as knowing they are + // broken — failing startup here would block a working configuration. + return List.of("Turnstile is enabled but no TurnstileConfigProperties bean is available, so its secret" + + " key could not be verified at startup. If token validation fails for every request, check" + + " that a secret key is configured."); + } + return List.of(); + } + + @Override + public List configurationErrors() { + if (turnstileServiceProvider.getIfAvailable() == null) { + return List.of("CAPTCHA is enabled with provider 'turnstile' but no TurnstileValidationService bean" + + " was found. Every CAPTCHA-protected request would be rejected. Ensure the" + + " ds-spring-cf-turnstile auto-configuration is active."); + } + List errors = new ArrayList<>(); + TurnstileConfigProperties turnstileProperties = turnstilePropertiesProvider.getIfAvailable(); + if (turnstileProperties != null && isBlank(turnstileProperties.getSecret())) { + // Without a secret every siteverify call fails, so every protected request is rejected + // while the application otherwise looks healthy. + errors.add("Turnstile is enabled but no secret key is configured (ds.cf.turnstile.secret). Token" + + " validation cannot succeed, so every CAPTCHA-protected request would be rejected."); + } + if (siteKey().isEmpty()) { + // Without a site key the consuming app's widget renders nothing, users submit with no + // token, and every protected request is rejected for a challenge never shown. + errors.add("Turnstile is enabled but no site key is configured (ds.cf.turnstile.sitekey). The CAPTCHA" + + " widget cannot render, so every CAPTCHA-protected request would be rejected."); + } + return List.copyOf(errors); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/package-info.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/package-info.java new file mode 100644 index 00000000..383ece72 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/package-info.java @@ -0,0 +1,40 @@ +/** + * Optional CAPTCHA protection for the framework's unauthenticated, email-sending API actions. + * + *

+ * Disabled by default. When {@code user.security.captcha.enabled=true}, a + * {@link com.digitalsanctuary.spring.user.captcha.CaptchaValidationInterceptor} requires a valid + * CAPTCHA token on every {@link com.digitalsanctuary.spring.user.captcha.CaptchaAction} whose + * per-action toggle is on, rejecting anything else with HTTP 403 before the handler runs — so no + * account is created and no email is sent. + *

+ * + *

Extension point

+ * + *

+ * {@link com.digitalsanctuary.spring.user.captcha.CaptchaService} is the provider SPI. The + * framework ships a Cloudflare Turnstile implementation, auto-configured when the optional + * {@code com.digitalsanctuary:ds-spring-cf-turnstile} dependency is present; a consumer-supplied + * {@code CaptchaService} bean replaces it. Implementations report a + * {@link com.digitalsanctuary.spring.user.captcha.CaptchaVerification} rather than a boolean, so + * the framework — not the implementation — decides that an unreachable or misconfigured provider + * rejects the request. + *

+ * + *

Fail-closed

+ * + *

+ * Every uncertain state denies the request: a missing or blank token, no resolvable provider, a + * provider error, a provider that throws or returns null, an unparseable path, and a path matching + * no known action. Startup fails outright when CAPTCHA is enabled but no provider resolves, or when + * the provider reports it cannot verify anything (see + * {@link com.digitalsanctuary.spring.user.captcha.CaptchaService#configurationErrors()}), because + * both states would otherwise produce an application that looks healthy while rejecting every + * protected request. + *

+ * + * @see com.digitalsanctuary.spring.user.captcha.CaptchaService + * @see com.digitalsanctuary.spring.user.captcha.CaptchaAction + * @see com.digitalsanctuary.spring.user.captcha.CaptchaConfigProperties + */ +package com.digitalsanctuary.spring.user.captcha; diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index f5da61f8..4ff463fe 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ com.digitalsanctuary.spring.user.UserConfiguration com.digitalsanctuary.spring.user.audit.AuditMailAutoConfiguration +com.digitalsanctuary.spring.user.captcha.CaptchaAutoConfiguration com.digitalsanctuary.spring.user.security.UserSecurityBeansAutoConfiguration com.digitalsanctuary.spring.user.security.WebSecurityFilterChainAutoConfiguration diff --git a/src/main/resources/messages/dsspringusermessages.properties b/src/main/resources/messages/dsspringusermessages.properties index 74289cc2..1c80803a 100644 --- a/src/main/resources/messages/dsspringusermessages.properties +++ b/src/main/resources/messages/dsspringusermessages.properties @@ -24,6 +24,7 @@ message.reset-password.success=Your password has been successfully reset. You ca message.account.verified=Your account has been successfully verified. message.logout.success=You logged out successfully message.login.success=You logged in successfully +message.captcha.validation-failed=CAPTCHA verification failed. Please complete the challenge and try again. token.message=Your token is: diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java new file mode 100644 index 00000000..98b3f8f5 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java @@ -0,0 +1,205 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + + +@DisplayName("CaptchaAutoConfiguration") +class CaptchaAutoConfigurationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CaptchaAutoConfiguration.class)); + + /** A fully usable Turnstile provider: service bean present, secret and site key configured. */ + @Configuration(proxyBeanMethods = false) + static class TurnstileServiceBeanConfiguration { + @Bean + TurnstileValidationService turnstileValidationService() { + TurnstileValidationService mock = Mockito.mock(TurnstileValidationService.class); + Mockito.when(mock.getTurnstileSitekey()).thenReturn("real-site-key"); + return mock; + } + + @Bean + TurnstileConfigProperties turnstileConfigProperties() { + TurnstileConfigProperties properties = new TurnstileConfigProperties(); + properties.setSecret("real-secret"); + properties.setSitekey("real-site-key"); + return properties; + } + } + + /** Turnstile present but unusable: no secret configured, so nothing can ever validate. */ + @Configuration(proxyBeanMethods = false) + static class UnusableTurnstileConfiguration { + @Bean + TurnstileValidationService turnstileValidationService() { + TurnstileValidationService mock = Mockito.mock(TurnstileValidationService.class); + Mockito.when(mock.getTurnstileSitekey()).thenReturn("real-site-key"); + return mock; + } + + @Bean + TurnstileConfigProperties turnstileConfigProperties() { + TurnstileConfigProperties properties = new TurnstileConfigProperties(); + properties.setSitekey("real-site-key"); + return properties; + } + } + + @Configuration(proxyBeanMethods = false) + static class TestCredentialTurnstileServiceConfiguration { + @Bean + TurnstileValidationService turnstileValidationService() { + TurnstileValidationService mock = Mockito.mock(TurnstileValidationService.class); + Mockito.when(mock.isUsingTestCredentials()).thenReturn(true); + Mockito.when(mock.getTurnstileSitekey()).thenReturn("1x00000000000000000000AA"); + return mock; + } + + @Bean + TurnstileConfigProperties turnstileConfigProperties() { + TurnstileConfigProperties properties = new TurnstileConfigProperties(); + properties.setSecret("1x0000000000000000000000000000000AA"); + properties.setSitekey("1x00000000000000000000AA"); + return properties; + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomCaptchaServiceConfiguration { + @Bean + CaptchaService customCaptchaService() { + return new CaptchaService() { + @Override + public CaptchaVerification verify(CaptchaContext context) { + return CaptchaVerification.verified(); + } + + @Override + public Optional siteKey() { + return Optional.of("custom"); + } + }; + } + } + + @Test + void shouldRegisterNoInterceptorOrProviderBeansWhenDisabled() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(CaptchaService.class); + assertThat(context).doesNotHaveBean(CaptchaValidationInterceptor.class); + // CaptchaConfigProperties and CaptchaStartupValidator do register unconditionally; the + // validator early-returns when disabled. Asserted so the "no beans at all" reading of + // the disabled state doesn't creep back into the docs. + assertThat(context).hasSingleBean(CaptchaStartupValidator.class); + }); + } + + @Test + void shouldStartCleanlyWhenDisabledAndTurnstileClassAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(TurnstileValidationService.class)).run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(CaptchaService.class); + }); + } + + @Test + void shouldRegisterTurnstileCaptchaServiceAndInterceptorWhenEnabled() { + contextRunner.withUserConfiguration(TurnstileServiceBeanConfiguration.class) + .withPropertyValues("user.security.captcha.enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(CaptchaService.class); + assertThat(context.getBean(CaptchaService.class)).isInstanceOf(TurnstileCaptchaService.class); + assertThat(context).hasSingleBean(CaptchaValidationInterceptor.class); + assertThat(context).hasSingleBean(CaptchaStartupValidator.class); + }); + } + + @Test + void shouldFailStartupWhenEnabledAndTurnstileClassAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(TurnstileValidationService.class)) + .withPropertyValues("user.security.captcha.enabled=true") + .run(context -> { + assertThat(context).hasFailed(); + // @PostConstruct validation surfaces wrapped in BeanCreationException. + assertThat(context.getStartupFailure()).rootCause() + .isInstanceOf(IllegalStateException.class).hasMessageContaining("CaptchaService"); + }); + } + + @Test + void shouldFailStartupWhenEnabledWithUnknownProvider() { + contextRunner.withUserConfiguration(TurnstileServiceBeanConfiguration.class) + .withPropertyValues("user.security.captcha.enabled=true", "user.security.captcha.provider=recaptcha") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void shouldPreferConsumerSuppliedCaptchaServiceBean() { + contextRunner + .withUserConfiguration(CustomCaptchaServiceConfiguration.class, + TurnstileServiceBeanConfiguration.class) + .withPropertyValues("user.security.captcha.enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(CaptchaService.class); + assertThat(context.getBean(CaptchaService.class).siteKey()).contains("custom"); + }); + } + + @Test + void shouldFailStartupWhenProviderCannotVerifyAnything() { + // The likeliest production misconfiguration: the library is present and a CaptchaService + // resolves, but no secret is set. Without this check the app starts clean and then rejects + // 100% of registrations, resets, and resends with no indication why. + contextRunner.withUserConfiguration(UnusableTurnstileConfiguration.class) + .withPropertyValues("user.security.captcha.enabled=true") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .isInstanceOf(IllegalStateException.class).hasMessageContaining("secret"); + }); + } + + @Test + void shouldStartWithUnusableProviderWhenExplicitlyAllowed() { + contextRunner.withUserConfiguration(UnusableTurnstileConfiguration.class) + .withPropertyValues("user.security.captcha.enabled=true", + "user.security.captcha.allow-unusable-provider=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(CaptchaService.class); + }); + } + + @Test + void shouldSurfaceTestCredentialWarningsAtStartup() { + // The validator logs provider warnings; the warning content is proven by the adapter + // test. Here we prove the wiring: a provider reporting test credentials surfaces a + // warning through the auto-configured CaptchaService, and startup still succeeds. + contextRunner.withUserConfiguration(TestCredentialTurnstileServiceConfiguration.class) + .withPropertyValues("user.security.captcha.enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + List warnings = context.getBean(CaptchaService.class).configurationWarnings(); + assertThat(warnings).anySatisfy(warning -> assertThat(warning).contains("test")); + }); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java new file mode 100644 index 00000000..4cbcbcc4 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java @@ -0,0 +1,68 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("CaptchaConfigProperties binding") +class CaptchaConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(PropertiesTestConfiguration.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(CaptchaConfigProperties.class) + static class PropertiesTestConfiguration { + } + + @Test + void shouldDefaultToDisabledTurnstileWithEmailActionsProtected() { + contextRunner.run(context -> { + CaptchaConfigProperties properties = context.getBean(CaptchaConfigProperties.class); + assertThat(properties.isEnabled()).isFalse(); + assertThat(properties.getProvider()).isEqualTo("turnstile"); + assertThat(properties.isAllowUnusableProvider()).isFalse(); + assertThat(properties.getProtect().isRegistration()).isTrue(); + assertThat(properties.getProtect().isPasswordlessRegistration()).isTrue(); + assertThat(properties.getProtect().isResetPassword()).isTrue(); + assertThat(properties.getProtect().isResendRegistrationToken()).isTrue(); + }); + } + + @Test + void shouldProtectEveryCaptchaActionByDefault() { + // Guards against a CaptchaAction constant being added without a matching Protect toggle, + // which would leave the new action unprotected by default rather than protected. + contextRunner.run(context -> { + CaptchaConfigProperties.Protect protect = + context.getBean(CaptchaConfigProperties.class).getProtect(); + for (CaptchaAction action : CaptchaAction.values()) { + assertThat(protect.isProtected(action)).as("default protection for %s", action).isTrue(); + } + }); + } + + @Test + void shouldBindKebabCasePropertiesWhenConfigured() { + contextRunner + .withPropertyValues("user.security.captcha.enabled=true", "user.security.captcha.provider=turnstile", + "user.security.captcha.allow-unusable-provider=true", + "user.security.captcha.protect.registration=false", + "user.security.captcha.protect.passwordless-registration=false", + "user.security.captcha.protect.reset-password=false", + "user.security.captcha.protect.resend-registration-token=false") + .run(context -> { + CaptchaConfigProperties properties = context.getBean(CaptchaConfigProperties.class); + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.isAllowUnusableProvider()).isTrue(); + assertThat(properties.getProtect().isRegistration()).isFalse(); + assertThat(properties.getProtect().isPasswordlessRegistration()).isFalse(); + assertThat(properties.getProtect().isResetPassword()).isFalse(); + assertThat(properties.getProtect().isResendRegistrationToken()).isFalse(); + }); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java new file mode 100644 index 00000000..bf52b7c8 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java @@ -0,0 +1,419 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import com.digitalsanctuary.spring.user.test.config.DatabaseTestConfiguration; +import com.digitalsanctuary.spring.user.test.config.MockMailConfiguration; +import com.digitalsanctuary.spring.user.test.config.OAuth2TestConfiguration; +import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Full-context integration tests proving issue #346 acceptance criteria: with CAPTCHA enabled, the + * three unauthenticated email-sending API actions (registration, resetPassword, + * resendRegistrationToken) reject requests that don't carry a valid CAPTCHA token, and send no email + * while doing so, while a request carrying a valid token is allowed through unchanged. + * + *

+ * Modeled on {@link com.digitalsanctuary.spring.user.api.UserApiTest}: a manual composite of the five + * standard test configurations (not {@code @IntegrationTest}) plus a stub {@link CaptchaService}, its + * own isolated H2 database so it doesn't race other integration test classes' {@code deleteAll()} / + * committed rows, and {@code @Execution(SAME_THREAD)} because the shared + * {@link MockMailConfiguration.MockJavaMailSender} capture lists are class-scoped state and JUnit runs + * test methods within a class concurrently by default. + *

+ */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Execution(ExecutionMode.SAME_THREAD) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:captchaprotectiontest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "user.security.captcha.enabled=true", + // Required for the "sends no email" assertions to mean anything: this defaults to false + // (RegistrationListener), so without it the registration path sends no verification email + // whether or not CAPTCHA rejects, and every assertNoEmailSent() would pass vacuously. + "user.registration.sendVerificationEmail=true", + // Lets StubCaptchaConfiguration replace BaseTestConfiguration's no-op event publisher; see + // realEventPublisher below. + "spring.main.allow-bean-definition-overriding=true", + // The passwordless registration endpoint is only reachable once a consumer opens it (its + // own javadoc says so), which is exactly the configuration in which it needs CAPTCHA. Set + // here rather than in the shared application.properties so no other test class's security + // posture changes. + // /user/savePassword is listed so shouldNotInterceptUnprotectedEndpointWhenCaptchaEnabled + // reaches the MVC layer; it is deliberately NOT CAPTCHA-protected (token-gated, sends no + // email), which is exactly what that test pins. + // /user/nonexistent is opened so shouldReturnNotFoundForUnknownUserPathWhenCaptchaEnabled + // reaches the DispatcherServlet (otherwise Spring Security 302s it to login first). + "user.security.unprotectedURIs=/,/index.html,/css/*,/js/*,/img/*,/register.html,/user/registration," + + "/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword," + + "/user/savePassword,/user/nonexistent,/user/login" +}) +@Import({BaseTestConfiguration.class, DatabaseTestConfiguration.class, SecurityTestConfiguration.class, + OAuth2TestConfiguration.class, MockMailConfiguration.class, + CaptchaProtectionIntegrationTest.StubCaptchaConfiguration.class}) +@DisplayName("CAPTCHA protection integration") +class CaptchaProtectionIntegrationTest { + + static final String VALID_TOKEN = "valid-captcha-token"; + + @TestConfiguration + static class StubCaptchaConfiguration { + + /** + * Replaces {@link BaseTestConfiguration}'s {@code Mockito.spy(ApplicationEventPublisher.class)}, + * which silently swallows every published event. With that no-op publisher in place + * {@code RegistrationListener} never runs, so no verification email is ever dispatched and + * this class's "sends no email" assertions would hold no matter what the interceptor did. + * Overrides by bean name (hence {@code allow-bean-definition-overriding} above); the real + * publisher is the {@link ApplicationContext} itself. + */ + @Bean + @Primary + ApplicationEventPublisher testEventPublisher(ApplicationContext applicationContext) { + return applicationContext; + } + + @Bean + @Primary + CaptchaService stubCaptchaService() { + return new CaptchaService() { + @Override + public CaptchaVerification verify(CaptchaContext context) { + return VALID_TOKEN.equals(context.token()) ? CaptchaVerification.verified() + : CaptchaVerification.rejected("stub rejected token"); + } + + @Override + public Optional siteKey() { + return Optional.of("stub-site-key"); + } + }; + } + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Autowired + private VerificationTokenRepository verificationTokenRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private JavaMailSender mailSender; + + /** + * The bounded executor {@code MailService} dispatches {@code @Async("dsMailExecutor")} sends on + * (in this class, the registration verification email sent by + * {@link #shouldRegisterWhenTokenValid()}). Drained in {@link #setUp()} so a straggling send + * from a previous test method cannot land in the shared + * {@link MockMailConfiguration.MockJavaMailSender} capture after it's cleared and pollute a + * later reject-path "no email sent" assertion. + */ + @Autowired + @Qualifier("dsMailExecutor") + private ThreadPoolTaskExecutor dsMailExecutor; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + private TransactionTemplate txTemplate; + private String testEmail; + + @BeforeEach + void setUp() { + txTemplate = new TransactionTemplate(transactionManager); + // Unique email per test method; @Execution(SAME_THREAD) serializes methods, so the + // shared mail capture can be cleared here without racing another method. + testEmail = "captcha.tester+" + System.nanoTime() + "@example.com"; + // Drain any in-flight/queued async send left over from a previous test method BEFORE + // clearing the capture, so a straggler can't land after clear() and pollute this method's + // "no email sent" assertion. junit-platform.properties randomizes method order, so a + // preceding success-path test (shouldRegisterWhenTokenValid) may not have finished its + // async send by the time this method starts. + drainMailExecutor(); + mockMailSender().clear(); + deleteTestUser(testEmail); + } + + @AfterEach + void tearDown() { + // Registration dispatches the verification email asynchronously, and that async work + // creates a VerificationToken row. Draining first prevents a token being inserted between + // deleteByUser and the user delete, which would fail cleanup on the FK constraint. + drainMailExecutor(); + deleteTestUser(testEmail); + } + + private MockMailConfiguration.MockJavaMailSender mockMailSender() { + return (MockMailConfiguration.MockJavaMailSender) mailSender; + } + + /** + * Waits until {@code dsMailExecutor} has no active or queued tasks, i.e. any async + * {@code MailService} send submitted by an earlier test method has fully completed. + */ + private void drainMailExecutor() { + Awaitility.await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(25)) + .until(() -> dsMailExecutor.getActiveCount() == 0 && dsMailExecutor.getThreadPoolExecutor().getQueue().isEmpty()); + } + + /** + * Hard-deletes the test user and any associated tokens (tokens first, FK order). This test is + * not @Transactional, so cleanup runs in its own committed transaction — same pattern as + * UserApiTest. + */ + private void deleteTestUser(String email) { + txTemplate.executeWithoutResult(status -> { + User user = userRepository.findByEmail(email); + if (user != null) { + passwordResetTokenRepository.deleteByUser(user); + verificationTokenRepository.deleteByUser(user); + userRepository.delete(user); + } + }); + } + + /** + * Asserts no email was dispatched. {@code getSentPreparators()} is the load-bearing check: + * {@code MailService} sends exclusively via {@code send(MimeMessagePreparator)}, so the MIME and + * simple lists are never populated by production code and asserting only those would pass no + * matter what. {@link #shouldRegisterWhenTokenValid()} is the positive control proving this + * capture actually observes a real send. + */ + private void assertNoEmailSent() { + assertThat(mockMailSender().getSentPreparators()).isEmpty(); + assertThat(mockMailSender().getSentMimeMessages()).isEmpty(); + assertThat(mockMailSender().getSentSimpleMessages()).isEmpty(); + } + + /** + * Waits for exactly one dispatched email, then asserts no more arrive. Sends are + * {@code @Async}, so the assertion has to await rather than read the capture immediately. + */ + private void assertOneEmailSent() { + Awaitility.await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(25)) + .until(() -> mockMailSender().getSentPreparators().size() == 1); + drainMailExecutor(); + assertThat(mockMailSender().getSentPreparators()).hasSize(1); + } + + private String registrationJson() { + return objectMapper.writeValueAsString(Map.of("firstName", "Captcha", "lastName", "Tester", "email", + testEmail, "password", "StrongPassw0rd!x", "matchingPassword", "StrongPassw0rd!x")); + } + + @Test + void shouldRejectRegistrationWithoutTokenAndSendNoEmail() throws Exception { + mockMvc.perform(post("/user/registration").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content(registrationJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)) + .andExpect(jsonPath("$.messages[0]").exists()); + + assertThat(userService.findUserByEmail(testEmail)).isNull(); + assertNoEmailSent(); + } + + @Test + void shouldRejectRegistrationWithInvalidToken() throws Exception { + mockMvc.perform(post("/user/registration").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .header(CaptchaValidationInterceptor.TOKEN_HEADER, "wrong-token").content(registrationJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); + + assertThat(userService.findUserByEmail(testEmail)).isNull(); + assertNoEmailSent(); + } + + @Test + void shouldRegisterWhenTokenValid() throws Exception { + mockMvc.perform(post("/user/registration").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .header(CaptchaValidationInterceptor.TOKEN_HEADER, VALID_TOKEN).content(registrationJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + User created = userService.findUserByEmail(testEmail); + assertThat(created).isNotNull(); + // Positive control for the whole class: proves the mail capture observes a real send, so + // the assertNoEmailSent() calls on the reject paths are meaningful rather than vacuous. + assertOneEmailSent(); + } + + @Test + void shouldRejectRegistrationWithMatrixParametersWithoutToken() throws Exception { + // Regression for the matcher-mismatch bypass. Through the full stack, Spring Security's + // default StrictHttpFirewall rejects any URL containing a semicolon before dispatch + // (400, empty body, no resolved exception), so this variant never reaches the handler. + // The interceptor-level defense for a consumer who relaxes the firewall (PathPattern + // routing strips matrix parameters, so the request would then reach the handler) is + // proven by CaptchaValidationInterceptorTest.shouldRejectWhenPathCarriesMatrixParameters, + // which asserts the interceptor itself 403s this path. + mockMvc.perform(post("/user/registration;jsessionid=abc").with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(registrationJson())) + .andExpect(status().isBadRequest()); + + assertThat(userService.findUserByEmail(testEmail)).isNull(); + assertNoEmailSent(); + } + + @Test + void shouldRejectRegistrationWithPercentEncodedPathWithoutToken() throws Exception { + // Regression for the matcher-mismatch bypass: PathPattern routing URL-decodes segments, so + // /user/%72egistration reaches the registration handler. URI.create keeps the raw encoding + // (the String overload would re-encode the percent sign). + mockMvc.perform(post(URI.create("/user/%72egistration")).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(registrationJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); + + assertThat(userService.findUserByEmail(testEmail)).isNull(); + assertNoEmailSent(); + } + + @Test + void shouldRejectResetPasswordWithoutTokenAndSendNoEmail() throws Exception { + mockMvc.perform(post("/user/resetPassword").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("email", testEmail)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); + assertNoEmailSent(); + } + + @Test + void shouldAcceptTokenFromQueryParameterThroughFullStack() throws Exception { + // The cf-turnstile-response query parameter is a documented transport, but a unit test + // using MockHttpServletRequest.setParameter cannot distinguish query string from form body. + // This proves it works through real parameter parsing on a JSON POST. + mockMvc.perform(post("/user/registration?" + CaptchaValidationInterceptor.TOKEN_PARAMETER + "=" + VALID_TOKEN) + .with(csrf()).contentType(MediaType.APPLICATION_JSON).content(registrationJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + assertThat(userService.findUserByEmail(testEmail)).isNotNull(); + } + + @Test + void shouldRejectPasswordlessRegistrationWithoutTokenAndSendNoEmail() throws Exception { + // This endpoint also creates an account and sends a verification email for an + // unauthenticated caller, so leaving it uncovered would give an abuser a cheaper path + // (no password in the payload) to the exact abuse CAPTCHA is here to stop. + mockMvc.perform(post("/user/registration/passwordless").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("email", testEmail, "firstName", "Captcha", + "lastName", "Tester")))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); + + assertThat(userService.findUserByEmail(testEmail)).isNull(); + assertNoEmailSent(); + } + + @Test + void shouldRejectResendRegistrationTokenWithoutToken() throws Exception { + mockMvc.perform(post("/user/resendRegistrationToken").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content(registrationJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); + assertNoEmailSent(); + } + + @Test + void shouldAllowResetPasswordWhenTokenValid() throws Exception { + mockMvc.perform(post("/user/resetPassword").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .header(CaptchaValidationInterceptor.TOKEN_HEADER, VALID_TOKEN) + .content(objectMapper.writeValueAsString(Map.of("email", testEmail)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + void shouldNotInterceptUnprotectedEndpointWhenCaptchaEnabled() throws Exception { + // Pins the interceptor's registration scope: /user/savePassword is unauthenticated and + // deliberately not CAPTCHA-protected (it is gated by the emailed reset token, and sends no + // email). If registration were ever broadened (e.g. to /user/**), this tokenless POST + // would get the CAPTCHA 403 instead of reaching the handler's bean validation (400). + mockMvc.perform(post("/user/savePassword").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturnNotFoundForUnknownUserPathWhenCaptchaEnabled() throws Exception { + // The interceptor's unmatched-path branch fails closed (403) when invoked, so if the + // registration patterns ever matched more than the CaptchaAction paths, this would turn + // from a plain 404 into a CAPTCHA rejection. + mockMvc.perform(post("/user/nonexistent").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isNotFound()); + } + + @Test + void shouldRegisterSiteKeyControllerAdviceWhenCaptchaEnabled() { + // The advice is registered by component scan plus @ConditionalOnProperty, not by + // CaptchaAutoConfiguration, so no context-runner test can see it; this full context is + // the only place its wiring is real. If the class moved out of the scanned package or the + // condition changed, consumers' templates would silently lose the captchaSiteKey attribute. + CaptchaSiteKeyControllerAdvice advice = applicationContext.getBean(CaptchaSiteKeyControllerAdvice.class); + assertThat(advice.captchaSiteKey()).isEqualTo("stub-site-key"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java new file mode 100644 index 00000000..ad30973f --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java @@ -0,0 +1,82 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CaptchaSiteKeyControllerAdvice") +class CaptchaSiteKeyControllerAdviceTest { + + @Mock + private ObjectProvider captchaServiceProvider; + + @Mock + private CaptchaService captchaService; + + @Controller + static class TestPageController { + @GetMapping("/captcha-advice-test-page") + public String page() { + return "test"; + } + } + + @Test + void shouldExposeSiteKeyModelAttributeWhenServiceAvailable() throws Exception { + when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); + when(captchaService.siteKey()).thenReturn(Optional.of("the-site-key")); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestPageController()) + .setControllerAdvice(new CaptchaSiteKeyControllerAdvice(captchaServiceProvider)).build(); + + mockMvc.perform(get("/captcha-advice-test-page")) + .andExpect(status().isOk()) + .andExpect(model().attribute("captchaSiteKey", "the-site-key")); + } + + @Test + void shouldExposeNullSiteKeyWhenServiceUnavailable() throws Exception { + when(captchaServiceProvider.getIfAvailable()).thenReturn(null); + CaptchaSiteKeyControllerAdvice advice = new CaptchaSiteKeyControllerAdvice(captchaServiceProvider); + + assertThat(advice.captchaSiteKey()).isNull(); + } + + @Test + void shouldExposeNullSiteKeyWhenProviderHasNoneConfigured() throws Exception { + when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); + when(captchaService.siteKey()).thenReturn(Optional.empty()); + CaptchaSiteKeyControllerAdvice advice = new CaptchaSiteKeyControllerAdvice(captchaServiceProvider); + + assertThat(advice.captchaSiteKey()).isNull(); + } + + @Test + void shouldRenderPageWithoutSiteKeyWhenProviderThrows() throws Exception { + // This advice runs on every @Controller request. A throwing consumer-supplied provider + // must degrade to a missing widget, not break rendering of every MVC page in the app. + when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); + when(captchaService.siteKey()).thenThrow(new IllegalStateException("provider exploded")); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestPageController()) + .setControllerAdvice(new CaptchaSiteKeyControllerAdvice(captchaServiceProvider)).build(); + + mockMvc.perform(get("/captcha-advice-test-page")) + .andExpect(status().isOk()) + .andExpect(model().attribute("captchaSiteKey", (Object) null)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidatorTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidatorTest.java new file mode 100644 index 00000000..523639bd --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidatorTest.java @@ -0,0 +1,137 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CaptchaStartupValidator") +class CaptchaStartupValidatorTest { + + @Mock + private ObjectProvider captchaServiceProvider; + + private CaptchaConfigProperties properties; + + @BeforeEach + void setUp() { + properties = new CaptchaConfigProperties(); + } + + private CaptchaStartupValidator validator() { + return new CaptchaStartupValidator(properties, captchaServiceProvider); + } + + /** A provider that works: no errors, no warnings. */ + private CaptchaService usableService() { + return context -> CaptchaVerification.verified(); + } + + private CaptchaService serviceReporting(List errors, List warnings) { + return new CaptchaService() { + @Override + public CaptchaVerification verify(CaptchaContext context) { + return CaptchaVerification.verified(); + } + + @Override + public Optional siteKey() { + return Optional.of("site-key"); + } + + @Override + public List configurationErrors() { + return errors; + } + + @Override + public List configurationWarnings() { + return warnings; + } + }; + } + + @Test + void shouldDoNothingWhenCaptchaDisabled() { + properties.setEnabled(false); + // lenient(): the disabled path must never consult the provider, so this stub going unused + // is the expected outcome, not a test smell. + lenient().when(captchaServiceProvider.getIfAvailable()).thenReturn(null); + + assertThatCode(() -> validator().validateCaptchaConfiguration()).doesNotThrowAnyException(); + } + + @Test + void shouldFailStartupWhenEnabledWithNoResolvableService() { + properties.setEnabled(true); + when(captchaServiceProvider.getIfAvailable()).thenReturn(null); + + assertThatThrownBy(() -> validator().validateCaptchaConfiguration()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no CaptchaService is available"); + } + + @Test + void shouldFailStartupWhenProviderReportsConfigurationErrors() { + properties.setEnabled(true); + when(captchaServiceProvider.getIfAvailable()) + .thenReturn(serviceReporting(List.of("no secret key configured"), List.of())); + + assertThatThrownBy(() -> validator().validateCaptchaConfiguration()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no secret key configured") + .hasMessageContaining("allow-unusable-provider"); + } + + @Test + void shouldStartWhenUnusableProviderExplicitlyAllowed() { + properties.setEnabled(true); + properties.setAllowUnusableProvider(true); + when(captchaServiceProvider.getIfAvailable()) + .thenReturn(serviceReporting(List.of("no secret key configured"), List.of())); + + assertThatCode(() -> validator().validateCaptchaConfiguration()).doesNotThrowAnyException(); + } + + @Test + void shouldStartAndSurfaceWarningsWhenProviderIsUsable() { + properties.setEnabled(true); + when(captchaServiceProvider.getIfAvailable()) + .thenReturn(serviceReporting(List.of(), List.of("using Cloudflare test credentials"))); + + assertThatCode(() -> validator().validateCaptchaConfiguration()).doesNotThrowAnyException(); + } + + @Test + void shouldStartWhenProviderIsUsableAndSilent() { + properties.setEnabled(true); + when(captchaServiceProvider.getIfAvailable()).thenReturn(usableService()); + + assertThatCode(() -> validator().validateCaptchaConfiguration()).doesNotThrowAnyException(); + } + + @Test + void shouldNotConsultProviderWhenDisabledEvenIfMisconfigured() { + // A disabled CAPTCHA must never fail startup over provider configuration. lenient(): the + // stub going unused IS the behavior under test. + properties.setEnabled(false); + lenient().when(captchaServiceProvider.getIfAvailable()) + .thenReturn(serviceReporting(List.of("no secret key configured"), List.of())); + + assertThatCode(() -> validator().validateCaptchaConfiguration()).doesNotThrowAnyException(); + assertThat(properties.isEnabled()).isFalse(); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaToggleIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaToggleIntegrationTest.java new file mode 100644 index 00000000..c6f7e087 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaToggleIntegrationTest.java @@ -0,0 +1,187 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import com.digitalsanctuary.spring.user.test.config.DatabaseTestConfiguration; +import com.digitalsanctuary.spring.user.test.config.MockMailConfiguration; +import com.digitalsanctuary.spring.user.test.config.OAuth2TestConfiguration; +import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Full-context integration test proving issue #346 acceptance criterion 4: per-action CAPTCHA + * toggles are independent. With CAPTCHA enabled overall but the registration action's toggle turned + * off ({@code user.security.captcha.protect.registration=false}), registration must be allowed + * without a token while the still-protected reset-password action continues to reject. + * + *

+ * The stub {@link CaptchaService} always rejects from {@link CaptchaService#verify(CaptchaContext)}, + * so no token is ever valid in this class — a passing registration request here can only be + * explained by the toggle taking registration out of the protected set entirely, not by a token + * happening to validate. + *

+ * + *

+ * Modeled on {@link CaptchaProtectionIntegrationTest} / {@code UserApiTest}: manual composite of the + * five standard test configurations (not {@code @IntegrationTest}), its own isolated H2 database so + * it doesn't race other integration test classes, and {@code @Execution(SAME_THREAD)} because the + * shared {@link MockMailConfiguration.MockJavaMailSender} capture lists are class-scoped state. + *

+ */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Execution(ExecutionMode.SAME_THREAD) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:captchatoggletest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "user.security.captcha.enabled=true", + "user.security.captcha.protect.registration=false" +}) +@Import({BaseTestConfiguration.class, DatabaseTestConfiguration.class, SecurityTestConfiguration.class, + OAuth2TestConfiguration.class, MockMailConfiguration.class, + CaptchaToggleIntegrationTest.StubCaptchaConfiguration.class}) +@DisplayName("CAPTCHA per-action toggle integration") +class CaptchaToggleIntegrationTest { + + @TestConfiguration + static class StubCaptchaConfiguration { + @Bean + @Primary + CaptchaService stubCaptchaService() { + return new CaptchaService() { + @Override + public CaptchaVerification verify(CaptchaContext context) { + // No token is ever valid in this class - the only way a protected action can + // succeed without one is if its toggle takes it out of the protected set. + return CaptchaVerification.rejected("stub rejects every token"); + } + + @Override + public Optional siteKey() { + return Optional.of("stub-site-key"); + } + }; + } + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Autowired + private VerificationTokenRepository verificationTokenRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private JavaMailSender mailSender; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + private TransactionTemplate txTemplate; + private String testEmail; + + @BeforeEach + void setUp() { + txTemplate = new TransactionTemplate(transactionManager); + // Unique email per test method; @Execution(SAME_THREAD) serializes methods, so the + // shared mail capture can be cleared here without racing another method. + testEmail = "captcha.toggle+" + System.nanoTime() + "@example.com"; + mockMailSender().clear(); + deleteTestUser(testEmail); + } + + @AfterEach + void tearDown() { + deleteTestUser(testEmail); + } + + private MockMailConfiguration.MockJavaMailSender mockMailSender() { + return (MockMailConfiguration.MockJavaMailSender) mailSender; + } + + /** + * Hard-deletes the test user and any associated tokens (tokens first, FK order). This test is + * not @Transactional, so cleanup runs in its own committed transaction — same pattern as + * UserApiTest. + */ + private void deleteTestUser(String email) { + txTemplate.executeWithoutResult(status -> { + User user = userRepository.findByEmail(email); + if (user != null) { + passwordResetTokenRepository.deleteByUser(user); + verificationTokenRepository.deleteByUser(user); + userRepository.delete(user); + } + }); + } + + private String registrationJson() { + return objectMapper.writeValueAsString(Map.of("firstName", "Captcha", "lastName", "Toggle", "email", + testEmail, "password", "StrongPassw0rd!x", "matchingPassword", "StrongPassw0rd!x")); + } + + @Test + void shouldAllowRegistrationWithoutTokenWhenRegistrationToggleOff() throws Exception { + mockMvc.perform(post("/user/registration").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content(registrationJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + assertThat(userService.findUserByEmail(testEmail)).isNotNull(); + } + + @Test + void shouldStillRejectResetPasswordWithoutTokenWhenOnlyRegistrationToggleOff() throws Exception { + mockMvc.perform(post("/user/resetPassword").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("email", testEmail)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java new file mode 100644 index 00000000..e2fdaca6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java @@ -0,0 +1,453 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Locale; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import com.digitalsanctuary.spring.user.audit.AuditEvent; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CaptchaValidationInterceptor") +class CaptchaValidationInterceptorTest { + + @Mock + private ObjectProvider captchaServiceProvider; + + @Mock + private CaptchaService captchaService; + + @Mock + private MessageSource messages; + + @Mock + private ObjectProvider objectMapperProvider; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + private CaptchaConfigProperties properties; + private CaptchaValidationInterceptor interceptor; + + @BeforeEach + void setUp() { + properties = new CaptchaConfigProperties(); + properties.setEnabled(true); + interceptor = new CaptchaValidationInterceptor(properties, captchaServiceProvider, messages, + objectMapperProvider, eventPublisher); + // lenient(): shared happy-path stubs; pass-through tests legitimately never consume them + // (e.g. a non-POST request touches none of these collaborators). + lenient().when(objectMapperProvider.getIfAvailable(any())).thenReturn(objectMapper); + lenient().when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); + lenient().when(messages.getMessage(eq("message.captcha.validation-failed"), isNull(), anyString(), + any(Locale.class))).thenAnswer(invocation -> invocation.getArgument(2)); + } + + private MockHttpServletRequest postTo(String path) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", path); + request.setRequestURI(path); + return request; + } + + private void assertRejectedWithCaptchaJson(MockHttpServletResponse response) throws Exception { + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentType()).startsWith("application/json"); + JsonNode body = objectMapper.readTree(response.getContentAsString()); + assertThat(body.get("success").asBoolean()).isFalse(); + assertThat(body.get("code").asInt()).isEqualTo(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED); + } + + @Test + void shouldPublishAuditEventWhenRejecting() throws Exception { + // Rejection volume is the signal that tells an operator the protection is working, so it + // must be auditable the same way every other API rejection is. + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.setRemoteAddr("192.0.2.55"); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isFalse(); + + // AuditEvent extends ApplicationEvent, so the call binds to publishEvent(ApplicationEvent), + // not the publishEvent(Object) overload. + ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(AuditEvent.class); + AuditEvent event = (AuditEvent) captor.getValue(); + assertThat(event.getAction()).isEqualTo("CaptchaValidation"); + assertThat(event.getActionStatus()).isEqualTo("Failure"); + assertThat(event.getIpAddress()).isEqualTo("192.0.2.55"); + } + + @Test + void shouldNotCreateSessionWhenRejecting() throws Exception { + // These requests are unauthenticated and frequently automated; minting a session per + // rejection would let an abuser grow session storage just by being rejected. + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isFalse(); + assertThat(request.getSession(false)).isNull(); + } + + @Test + void shouldStillRejectWhenAuditPublishingFails() throws Exception { + // Auditing must never turn a rejection into a 500 — the denial matters more than the record. + doThrow(new IllegalStateException("publisher down")).when(eventPublisher) + .publishEvent(any(ApplicationEvent.class)); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldPassThroughWhenRequestIsNotPost() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", CaptchaAction.REGISTRATION.path()); + request.setRequestURI(CaptchaAction.REGISTRATION.path()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldPassThroughWhenActionToggleDisabled() throws Exception { + properties.getProtect().setRegistration(false); + + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldStillProtectOtherActionsWhenOneToggleDisabled() throws Exception { + properties.getProtect().setRegistration(false); + + MockHttpServletRequest request = postTo(CaptchaAction.RESET_PASSWORD.path()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldProtectPasswordlessRegistrationSeparatelyFromRegistration() throws Exception { + // /user/registration is an exact PathPattern and does not match the passwordless sub-path, + // so the two need distinct actions; turning one off must not turn the other off. + properties.getProtect().setRegistration(false); + + MockHttpServletRequest request = postTo(CaptchaAction.PASSWORDLESS_REGISTRATION.path()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldPassThroughWhenPasswordlessRegistrationToggleDisabled() throws Exception { + properties.getProtect().setPasswordlessRegistration(false); + + MockHttpServletRequest request = postTo(CaptchaAction.PASSWORDLESS_REGISTRATION.path()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldResolvePasswordlessRegistrationActionWhenTokenValid() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.PASSWORDLESS_REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().action()).isEqualTo(CaptchaAction.PASSWORDLESS_REGISTRATION); + } + + @Test + void shouldPassThroughWhenResendTokenToggleDisabled() throws Exception { + properties.getProtect().setResendRegistrationToken(false); + + MockHttpServletRequest request = postTo(CaptchaAction.RESEND_REGISTRATION_TOKEN.path()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldPassThroughWhenResetPasswordToggleDisabled() throws Exception { + properties.getProtect().setResetPassword(false); + + MockHttpServletRequest request = postTo(CaptchaAction.RESET_PASSWORD.path()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldRejectWithJsonResponseWhenTokenMissing() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.RESET_PASSWORD.path()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean proceed = interceptor.preHandle(request, response, new Object()); + + assertThat(proceed).isFalse(); + assertRejectedWithCaptchaJson(response); + JsonNode body = objectMapper.readTree(response.getContentAsString()); + assertThat(body.get("messages").get(0).asText()).contains("CAPTCHA"); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldRejectWhenTokenIsBlank() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, " "); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldFallBackToParameterWhenHeaderIsBlank() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, " "); + request.setParameter(CaptchaValidationInterceptor.TOKEN_PARAMETER, "param-token"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().token()).isEqualTo("param-token"); + } + + @Test + void shouldRejectWhenProviderRejectsToken() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.RESEND_REGISTRATION_TOKEN.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "bad-token"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.rejected("invalid")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().token()).isEqualTo("bad-token"); + assertThat(captor.getValue().action()).isEqualTo(CaptchaAction.RESEND_REGISTRATION_TOKEN); + } + + @Test + void shouldRejectWhenProviderReportsError() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.error("provider unreachable")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldRejectWithDocumentedBodyWhenProviderThrows() throws Exception { + // CaptchaService is a public SPI: a third-party implementation may throw. The framework, + // not the implementation, must keep that fail-closed AND keep the documented 403 contract. + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + when(captchaService.verify(any())).thenThrow(new IllegalStateException("provider blew up")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldRejectWithDocumentedBodyWhenProviderReturnsNull() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + when(captchaService.verify(any())).thenReturn(null); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldPassThroughWhenHeaderTokenValid() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().action()).isEqualTo(CaptchaAction.REGISTRATION); + assertThat(captor.getValue().token()).isEqualTo("good-token"); + } + + @Test + void shouldFallBackToRequestParameterWhenHeaderAbsent() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.setParameter(CaptchaValidationInterceptor.TOKEN_PARAMETER, "param-token"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().token()).isEqualTo("param-token"); + } + + @Test + void shouldReportForwardedClientIpWhenPresent() throws Exception { + // The IP sent to the provider and the one in rejection logs must be the same value, so it + // is resolved once by the interceptor rather than separately by each provider. + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + request.addHeader("X-Forwarded-For", "203.0.113.7, 198.51.100.1"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().remoteIp()).isEqualTo("203.0.113.7"); + } + + @Test + void shouldIgnoreLiteralUnknownForwardedForValue() throws Exception { + // Some older proxies send "unknown" instead of omitting the header; forwarding that + // placeholder to the provider is worse than using the socket address. + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + request.addHeader("X-Forwarded-For", "unknown"); + request.setRemoteAddr("192.0.2.55"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().remoteIp()).isEqualTo("192.0.2.55"); + } + + @Test + void shouldFallBackToRemoteAddrWhenNoForwardedHeader() throws Exception { + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + request.setRemoteAddr("192.0.2.55"); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.verified()); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CaptchaContext.class); + verify(captchaService).verify(captor.capture()); + assertThat(captor.getValue().remoteIp()).isEqualTo("192.0.2.55"); + } + + @Test + void shouldRejectWhenCaptchaServiceUnavailable() throws Exception { + when(captchaServiceProvider.getIfAvailable()).thenReturn(null); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } + + @Test + void shouldWriteValidJsonWhenMessageContainsQuotes() throws Exception { + when(messages.getMessage(eq("message.captcha.validation-failed"), isNull(), anyString(), any(Locale.class))) + .thenReturn("Die \"CAPTCHA\"-Prüfung ist fehlgeschlagen."); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + interceptor.preHandle(request, response, new Object()); + + JsonNode body = objectMapper.readTree(response.getContentAsString()); + assertThat(body.get("messages").get(0).asText()).isEqualTo("Die \"CAPTCHA\"-Prüfung ist fehlgeschlagen."); + } + + @Test + void shouldRejectWhenPathCarriesMatrixParameters() throws Exception { + // PathPattern matching (used to register this interceptor) strips matrix parameters per + // segment, so this request reaches preHandle; enforcement must strip them the same way. + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path() + ";jsessionid=abc"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldRejectWhenPathIsPercentEncoded() throws Exception { + // PathPattern matching URL-decodes segments before comparing, so /user/%72egistration + // reaches preHandle as a registration request; enforcement must decode the same way. + MockHttpServletRequest request = postTo("/user/%72egistration"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldRejectWhenPathMatchesNoKnownAction() throws Exception { + // Last-line defense against registration and enforcement disagreeing (e.g. a consumer + // installing a custom PathPatternParser via PathMatchConfigurer). Unreachable through the + // real dispatcher; must fail closed rather than wave the request through. + MockHttpServletRequest request = postTo("/user/somethingElse"); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + verify(captchaService, never()).verify(any()); + } + + @Test + void shouldHandleContextPathWhenResolvingAction() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", + "/app" + CaptchaAction.REGISTRATION.path()); + request.setContextPath("/app"); + request.setRequestURI("/app" + CaptchaAction.REGISTRATION.path()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertRejectedWithCaptchaJson(response); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationExclusionTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationExclusionTest.java new file mode 100644 index 00000000..bd0a8754 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationExclusionTest.java @@ -0,0 +1,39 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.digitalsanctuary.spring.user.test.app.TestApplication; + +/** + * Guards the shared test-scope exclusion of the Turnstile auto-configuration. + * + *

+ * {@code ds-spring-cf-turnstile} is on the test classpath, so without the exclusion in + * {@code application-test.properties} its auto-configuration registers a login filter for every + * test in the suite. That property is a whole-value assignment, so any future test that sets its + * own {@code spring.autoconfigure.exclude} silently replaces it. This test fails loudly if that + * happens, instead of leaving unrelated tests to fail in confusing ways. + *

+ */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +@DisplayName("Turnstile auto-configuration exclusion") +class TurnstileAutoConfigurationExclusionTest { + + @Value("${spring.autoconfigure.exclude:}") + private String excluded; + + @Test + void shouldExcludeTurnstileAutoConfigurationInTestScope() { + assertThat(Arrays.stream(excluded.split(",")).map(String::trim)) + .contains("com.digitalsanctuary.cf.turnstile.TurnstileConfiguration"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationWiringTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationWiringTest.java new file mode 100644 index 00000000..ee5a90e4 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationWiringTest.java @@ -0,0 +1,82 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; + +import com.digitalsanctuary.cf.turnstile.TurnstileConfiguration; + +/** + * Wires the captcha auto-configuration against the real ds-spring-cf-turnstile + * auto-configuration instead of a mocked {@code TurnstileValidationService}. + * + *

+ * Every other captcha test excludes {@link TurnstileConfiguration} (see + * {@code application-test.properties}) and stubs or mocks the Turnstile beans, so a bean-name or + * wiring change in a future library release would compile cleanly and pass the whole suite while + * breaking every real consumer. This test pins the actual bean graph: the library's + * auto-configuration produces the beans the adapter resolves via {@code ObjectProvider}. Cloudflare's + * documented always-pass test credentials are used purely as configuration values — no network call + * happens because {@code verify} is never invoked. + *

+ */ +@DisplayName("Captcha wiring against the real Turnstile auto-configuration") +class TurnstileAutoConfigurationWiringTest { + + /** Cloudflare's documented always-pass test site key. */ + private static final String TEST_SITE_KEY = "1x00000000000000000000AA"; + + /** Cloudflare's documented always-pass test secret key. */ + private static final String TEST_SECRET_KEY = "1x0000000000000000000000000000000AA"; + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TurnstileConfiguration.class, CaptchaAutoConfiguration.class)) + .withPropertyValues("user.security.captcha.enabled=true", + "ds.cf.turnstile.sitekey=" + TEST_SITE_KEY, + "ds.cf.turnstile.secret=" + TEST_SECRET_KEY); + + @Test + void shouldResolveTurnstileAdapterAgainstRealLibraryBeansWhenEnabled() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(CaptchaService.class); + assertThat(context.getBean(CaptchaService.class)).isInstanceOf(TurnstileCaptchaService.class); + assertThat(context).hasSingleBean(CaptchaValidationInterceptor.class); + }); + } + + @Test + void shouldReportNoConfigurationErrorsWhenRealLibraryFullyConfigured() { + contextRunner.run(context -> { + CaptchaService captchaService = context.getBean(CaptchaService.class); + assertThat(captchaService.configurationErrors()).isEmpty(); + assertThat(captchaService.siteKey()).contains(TEST_SITE_KEY); + }); + } + + @Test + void shouldSurfaceTestCredentialWarningThroughRealLibraryWhenTestKeysConfigured() { + contextRunner.run(context -> { + assertThat(context.getBean(CaptchaService.class).configurationWarnings()) + .anySatisfy(warning -> assertThat(warning).contains("test")); + }); + } + + @Test + void shouldReportConfigurationErrorThroughRealLibraryWhenSecretMissing() { + new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(TurnstileConfiguration.class, CaptchaAutoConfiguration.class)) + .withPropertyValues("user.security.captcha.enabled=true", + "user.security.captcha.allow-unusable-provider=true", + "ds.cf.turnstile.sitekey=" + TEST_SITE_KEY) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(CaptchaService.class).configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("secret")); + }); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java new file mode 100644 index 00000000..ef622507 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java @@ -0,0 +1,208 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockHttpServletRequest; + +import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TurnstileCaptchaService adapter") +class TurnstileCaptchaServiceTest { + + private static final String CLIENT_IP = "203.0.113.7"; + + @Mock + private ObjectProvider turnstileServiceProvider; + + @Mock + private TurnstileValidationService turnstileValidationService; + + @Mock + private ObjectProvider turnstilePropertiesProvider; + + private TurnstileCaptchaService captchaService; + + @BeforeEach + void setUp() { + captchaService = new TurnstileCaptchaService(turnstileServiceProvider, turnstilePropertiesProvider); + // lenient(): only the configurationErrors tests read the properties bean; verify/siteKey + // tests legitimately never touch it. + lenient().when(turnstilePropertiesProvider.getIfAvailable()).thenReturn(usableProperties()); + } + + private TurnstileConfigProperties usableProperties() { + TurnstileConfigProperties properties = new TurnstileConfigProperties(); + properties.setSecret("real-secret"); + properties.setSitekey("real-site-key"); + return properties; + } + + private CaptchaContext contextFor(String token) { + return new CaptchaContext(CaptchaAction.REGISTRATION, token, CLIENT_IP, new MockHttpServletRequest()); + } + + @Test + void shouldReportVerifiedWhenTurnstileAcceptsToken() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.validateTurnstileResponse("tok-123", CLIENT_IP)).thenReturn(true); + + assertThat(captchaService.verify(contextFor("tok-123")).isVerified()).isTrue(); + // The framework resolves the client IP; the adapter must forward that value rather than + // re-deriving one, so provider calls and rejection logs name the same client. + verify(turnstileValidationService).validateTurnstileResponse("tok-123", CLIENT_IP); + } + + @Test + void shouldReportRejectedWhenTurnstileRejectsToken() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.validateTurnstileResponse("bad-token", CLIENT_IP)).thenReturn(false); + + CaptchaVerification result = captchaService.verify(contextFor("bad-token")); + + assertThat(result.isVerified()).isFalse(); + assertThat(result.outcome()).isEqualTo(CaptchaVerification.Outcome.REJECTED); + } + + @Test + void shouldReportErrorWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + + CaptchaVerification result = captchaService.verify(contextFor("tok-123")); + + assertThat(result.isVerified()).isFalse(); + assertThat(result.outcome()).isEqualTo(CaptchaVerification.Outcome.ERROR); + } + + @Test + void shouldReportErrorWhenValidateTurnstileResponseThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.validateTurnstileResponse("tok-123", CLIENT_IP)) + .thenThrow(new RuntimeException("Validation service unavailable")); + + CaptchaVerification result = captchaService.verify(contextFor("tok-123")); + + assertThat(result.isVerified()).isFalse(); + assertThat(result.outcome()).isEqualTo(CaptchaVerification.Outcome.ERROR); + } + + @Test + void shouldExposeSitekeyFromTurnstileService() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + + assertThat(captchaService.siteKey()).contains("real-site-key"); + } + + @Test + void shouldWarnWhenCloudflareTestCredentialsConfigured() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(true); + + List warnings = captchaService.configurationWarnings(); + + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0)).contains("test"); + } + + @Test + void shouldReportErrorWhenTurnstileServiceBeanMissingAtStartup() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + + assertThat(captchaService.configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("TurnstileValidationService")); + } + + @Test + void shouldReportErrorWhenSecretMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + TurnstileConfigProperties noSecret = new TurnstileConfigProperties(); + noSecret.setSitekey("real-site-key"); + when(turnstilePropertiesProvider.getIfAvailable()).thenReturn(noSecret); + + assertThat(captchaService.configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("secret")); + } + + @Test + void shouldReportErrorWhenSiteKeyMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn(" "); + + assertThat(captchaService.configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("site key")); + } + + @Test + void shouldWarnRatherThanErrorWhenPropertiesBeanAbsent() { + // A consumer who excludes the Turnstile auto-configuration and hand-wires a working + // TurnstileValidationService has no properties bean. We cannot verify their secret, but + // that is not evidence it is broken, so this must not fail startup. + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); + when(turnstilePropertiesProvider.getIfAvailable()).thenReturn(null); + + assertThat(captchaService.configurationErrors()).isEmpty(); + assertThat(captchaService.configurationWarnings()) + .anySatisfy(warning -> assertThat(warning).contains("could not be verified")); + } + + @Test + void shouldReportNoErrorsWhenFullyConfigured() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + + assertThat(captchaService.configurationErrors()).isEmpty(); + } + + @Test + void shouldReturnNoWarningsWhenRealCredentialsConfigured() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); + + assertThat(captchaService.configurationWarnings()).isEmpty(); + } + + @Test + void shouldReturnEmptySiteKeyWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + + assertThat(captchaService.siteKey()).isEmpty(); + } + + @Test + void shouldReturnEmptySiteKeyWhenGetTurnstileSitekeyThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()) + .thenThrow(new RuntimeException("Could not fetch site key")); + + assertThat(captchaService.siteKey()).isEmpty(); + } + + @Test + void shouldWarnWhenIsUsingTestCredentialsThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.isUsingTestCredentials()) + .thenThrow(new RuntimeException("Could not query credentials")); + + List warnings = captchaService.configurationWarnings(); + + assertThat(warnings).isNotEmpty(); + assertThat(warnings.get(0)).contains("could not be queried"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/config/MockMailConfiguration.java b/src/test/java/com/digitalsanctuary/spring/user/test/config/MockMailConfiguration.java index a108b362..fe2f44e6 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/config/MockMailConfiguration.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/config/MockMailConfiguration.java @@ -105,6 +105,17 @@ public List getSentMimeMessages() { return new ArrayList<>(sentMimeMessages); } + /** + * Get all sent MIME message preparators. This is the list production code actually + * populates: {@code MailService} dispatches every email through + * {@code send(MimeMessagePreparator)}, so a test asserting "no email was sent" must check + * this list. Asserting only {@link #getSentMimeMessages()} / {@link #getSentSimpleMessages()} + * is vacuously true, since nothing in main ever calls those overloads. + */ + public List getSentPreparators() { + return new ArrayList<>(sentPreparators); + } + /** * Clear all captured messages. */ diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index e2f8ef01..bed1535f 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -55,4 +55,15 @@ spring.security.oauth2.client.registration.test.redirect-uri={baseUrl}/login/oau spring.security.oauth2.client.provider.test.authorization-uri=http://localhost:8080/oauth2/authorize spring.security.oauth2.client.provider.test.token-uri=http://localhost:8080/oauth2/token spring.security.oauth2.client.provider.test.user-info-uri=http://localhost:8080/userinfo -spring.security.oauth2.client.provider.test.user-name-attribute=sub \ No newline at end of file +spring.security.oauth2.client.provider.test.user-name-attribute=sub + +# ds-spring-cf-turnstile auto-registers TurnstileValidationService and a login +# TurnstileCaptchaFilter when on the classpath. Exclude it so existing tests are +# unaffected; captcha tests use a stub CaptchaService or mock the Turnstile +# service directly. +# +# This is a whole-value assignment: any test class or properties file that sets +# spring.autoconfigure.exclude replaces this entry rather than adding to it, which +# would silently re-enable the Turnstile login filter suite-wide. Keep this entry in +# any such override. TurnstileAutoConfigurationExclusionTest fails if it is lost. +spring.autoconfigure.exclude=com.digitalsanctuary.cf.turnstile.TurnstileConfiguration