From 3add38399de76bbfb9a80df2facaab95fd019707 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:03:02 -0600 Subject: [PATCH 01/14] feat: add optional ds-spring-cf-turnstile dependency and captcha config properties (#346) --- build.gradle | 2 + .../user/captcha/CaptchaConfigProperties.java | 52 +++++++++++++++++++ .../captcha/CaptchaConfigPropertiesTest.java | 49 +++++++++++++++++ .../resources/application-test.properties | 8 ++- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java diff --git a/build.gradle b/build.gradle index 71d33c3..ab27dee 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/CaptchaConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java new file mode 100644 index 0000000..8125a1f --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java @@ -0,0 +1,52 @@ +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 beans and behavior is identical to previous releases. + *

+ */ +@Data +@ConfigurationProperties(prefix = "user.security.captcha") +public class CaptchaConfigProperties { + + /** + * Master switch for CAPTCHA verification. When false (the default), no CAPTCHA beans are + * registered and no requests are checked. + */ + 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"; + + /** 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/resetPassword. */ + private boolean resetPassword = true; + + /** Require CAPTCHA on POST /user/resendRegistrationToken. */ + private boolean resendRegistrationToken = true; + } +} 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 0000000..ae7e89c --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java @@ -0,0 +1,49 @@ +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.getProtect().isRegistration()).isTrue(); + assertThat(properties.getProtect().isResetPassword()).isTrue(); + assertThat(properties.getProtect().isResendRegistrationToken()).isTrue(); + }); + } + + @Test + void shouldBindKebabCasePropertiesWhenConfigured() { + contextRunner + .withPropertyValues("user.security.captcha.enabled=true", "user.security.captcha.provider=turnstile", + "user.security.captcha.protect.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.getProtect().isRegistration()).isFalse(); + assertThat(properties.getProtect().isResetPassword()).isFalse(); + assertThat(properties.getProtect().isResendRegistrationToken()).isFalse(); + }); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index e2f8ef0..9a98b35 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -55,4 +55,10 @@ 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. +spring.autoconfigure.exclude=com.digitalsanctuary.cf.turnstile.TurnstileConfiguration \ No newline at end of file From 60b276b42e0c3fdbe7f31b661ce8b115220fc3ab Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:06:46 -0600 Subject: [PATCH 02/14] feat: add CaptchaService SPI and Turnstile adapter (#346) --- .../spring/user/captcha/CaptchaService.java | 46 +++++++ .../user/captcha/TurnstileCaptchaService.java | 67 +++++++++++ .../captcha/TurnstileCaptchaServiceTest.java | 112 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java 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 0000000..334c8b0 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java @@ -0,0 +1,46 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.List; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Provider-neutral CAPTCHA verification SPI. + * + *

+ * The framework ships a Cloudflare Turnstile implementation ({@link TurnstileCaptchaService}), + * auto-configured when {@code com.digitalsanctuary:ds-spring-cf-turnstile} is on the classpath and + * {@code user.security.captcha.provider=turnstile}. 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. + *

+ */ +public interface CaptchaService { + + /** + * Verifies a CAPTCHA response token. Implementations MUST fail closed: any error (missing + * configuration, provider unreachable, invalid token) returns {@code false}. + * + * @param token the CAPTCHA response token supplied by the client + * @param request the current request, for client IP extraction + * @return true only if the provider positively verified the token + */ + boolean verify(String token, HttpServletRequest request); + + /** + * Returns the public site key for rendering the CAPTCHA widget, or null if not configured. + * + * @return the public site key, or null + */ + String getSiteKey(); + + /** + * 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(); + } +} 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 0000000..083c46e --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java @@ -0,0 +1,67 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; + +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +import jakarta.servlet.http.HttpServletRequest; +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} returns false. + * 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; + + @Override + public boolean verify(String token, HttpServletRequest request) { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + log.error("CAPTCHA is enabled but no TurnstileValidationService bean is available. Failing closed."); + return false; + } + String clientIp = turnstileService.getClientIpAddress(request); + return turnstileService.validateTurnstileResponse(token, clientIp); + } + + @Override + public String getSiteKey() { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + return turnstileService != null ? turnstileService.getTurnstileSitekey() : null; + } + + @Override + public List configurationWarnings() { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + return List.of("CAPTCHA is enabled with provider 'turnstile' but no TurnstileValidationService bean" + + " was found. All CAPTCHA-protected requests will be rejected (fail closed). Ensure the" + + " ds-spring-cf-turnstile auto-configuration is active."); + } + 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."); + } + return List.of(); + } +} 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 0000000..b1ffe0b --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java @@ -0,0 +1,112 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockHttpServletRequest; + +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("TurnstileCaptchaService adapter") +class TurnstileCaptchaServiceTest { + + @Mock + private ObjectProvider turnstileServiceProvider; + + @Mock + private TurnstileValidationService turnstileValidationService; + + private TurnstileCaptchaService captchaService; + + @BeforeEach + void setUp() { + captchaService = new TurnstileCaptchaService(turnstileServiceProvider); + } + + @Test + void shouldDelegateToTurnstileWithClientIpWhenServiceAvailable() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); + when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")).thenReturn(true); + + boolean result = captchaService.verify("tok-123", request); + + assertThat(result).isTrue(); + verify(turnstileValidationService).validateTurnstileResponse("tok-123", "203.0.113.7"); + } + + @Test + void shouldFailClosedWhenTurnstileValidationFails() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); + when(turnstileValidationService.validateTurnstileResponse("bad-token", "203.0.113.7")).thenReturn(false); + + assertThat(captchaService.verify("bad-token", request)).isFalse(); + } + + @Test + void shouldFailClosedWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + + assertThat(captchaService.verify("tok-123", new MockHttpServletRequest())).isFalse(); + } + + @Test + void shouldExposeSitekeyFromTurnstileService() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + + assertThat(captchaService.getSiteKey()).isEqualTo("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 shouldWarnWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + + assertThat(captchaService.configurationWarnings()) + .anySatisfy(warning -> assertThat(warning).contains("fail closed")); + } + + @Test + void shouldReturnNoWarningsWhenRealCredentialsConfigured() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); + + assertThat(captchaService.configurationWarnings()).isEmpty(); + } + + @Test + void shouldReturnNullSiteKeyWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + + assertThat(captchaService.getSiteKey()).isNull(); + } +} From 2c42d396b6153fde432a8e2a1cf0c2b923f97114 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:13:12 -0600 Subject: [PATCH 03/14] feat: fail closed on unexpected Turnstile errors in captcha adapter (#346) --- .../user/captcha/TurnstileCaptchaService.java | 31 +++++++++++--- .../captcha/TurnstileCaptchaServiceTest.java | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java index 083c46e..784bcc9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java @@ -40,14 +40,27 @@ public boolean verify(String token, HttpServletRequest request) { log.error("CAPTCHA is enabled but no TurnstileValidationService bean is available. Failing closed."); return false; } - String clientIp = turnstileService.getClientIpAddress(request); - return turnstileService.validateTurnstileResponse(token, clientIp); + try { + String clientIp = turnstileService.getClientIpAddress(request); + return turnstileService.validateTurnstileResponse(token, clientIp); + } catch (RuntimeException e) { + log.error("Unexpected error during Turnstile verification. Failing closed.", e); + return false; + } } @Override public String getSiteKey() { TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); - return turnstileService != null ? turnstileService.getTurnstileSitekey() : null; + if (turnstileService == null) { + return null; + } + try { + return turnstileService.getTurnstileSitekey(); + } catch (RuntimeException e) { + log.error("Error retrieving Turnstile site key. Failing closed.", e); + return null; + } } @Override @@ -58,9 +71,15 @@ public List configurationWarnings() { + " was found. All CAPTCHA-protected requests will be rejected (fail closed). Ensure the" + " ds-spring-cf-turnstile auto-configuration is active."); } - 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."); + 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."); } return List.of(); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java index b1ffe0b..6b67c65 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java @@ -109,4 +109,46 @@ void shouldReturnNullSiteKeyWhenTurnstileServiceBeanMissing() { assertThat(captchaService.getSiteKey()).isNull(); } + + @Test + void shouldFailClosedWhenGetClientIpAddressThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)) + .thenThrow(new RuntimeException("IP extraction failed")); + + assertThat(captchaService.verify("tok-123", request)).isFalse(); + } + + @Test + void shouldFailClosedWhenValidateTurnstileResponseThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); + when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")) + .thenThrow(new RuntimeException("Validation service unavailable")); + + assertThat(captchaService.verify("tok-123", request)).isFalse(); + } + + @Test + void shouldReturnNullSiteKeyWhenGetTurnstileSitekeyThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()) + .thenThrow(new RuntimeException("Could not fetch site key")); + + assertThat(captchaService.getSiteKey()).isNull(); + } + + @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"); + } } From 59e0d86a66830d27fd02107cebf375c86dd57555 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:16:43 -0600 Subject: [PATCH 04/14] feat: add captcha validation interceptor for email-sending API actions (#346) --- .../captcha/CaptchaValidationInterceptor.java | 117 ++++++++++++ .../messages/dsspringusermessages.properties | 1 + .../CaptchaValidationInterceptorTest.java | 168 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java 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 0000000..080a2de --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java @@ -0,0 +1,117 @@ +package com.digitalsanctuary.spring.user.captcha; + +import java.io.IOException; + +import org.apache.commons.text.StringEscapeUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.MessageSource; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 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 three protected 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, or a failed + * validation all reject the request before the handler runs, so no email is sent. + *

+ */ +@Slf4j +@RequiredArgsConstructor +public class CaptchaValidationInterceptor implements HandlerInterceptor { + + /** Path of the registration API action. */ + public static final String REGISTRATION_PATH = "/user/registration"; + + /** Path of the password-reset API action. */ + public static final String RESET_PASSWORD_PATH = "/user/resetPassword"; + + /** Path of the resend-verification-token API action. */ + public static final String RESEND_TOKEN_PATH = "/user/resendRegistrationToken"; + + /** 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). */ + 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 final CaptchaConfigProperties captchaConfigProperties; + private final ObjectProvider captchaServiceProvider; + private final MessageSource messages; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws IOException { + if (!"POST".equalsIgnoreCase(request.getMethod())) { + return true; + } + String path = request.getRequestURI().substring(request.getContextPath().length()); + if (!isActionProtected(path)) { + return true; + } + String token = resolveToken(request); + if (token == null || token.isBlank()) { + log.warn("CAPTCHA token missing on {} from {}. Rejecting request.", path, request.getRemoteAddr()); + return reject(request, response); + } + 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); + } + if (!captchaService.verify(token, request)) { + log.warn("CAPTCHA validation failed on {} from {}. Rejecting request.", path, request.getRemoteAddr()); + return reject(request, response); + } + return true; + } + + private boolean isActionProtected(String path) { + CaptchaConfigProperties.Protect protect = captchaConfigProperties.getProtect(); + return switch (path) { + case REGISTRATION_PATH -> protect.isRegistration(); + case RESET_PASSWORD_PATH -> protect.isResetPassword(); + case RESEND_TOKEN_PATH -> protect.isResendRegistrationToken(); + default -> false; + }; + } + + 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) throws IOException { + String message = messages.getMessage(MESSAGE_KEY, null, DEFAULT_MESSAGE, request.getLocale()); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"success\":false,\"redirectUrl\":null,\"code\":" + ERROR_CODE_CAPTCHA_FAILED + + ",\"messages\":[\"" + StringEscapeUtils.escapeJson(message) + "\"],\"data\":null}"); + return false; + } +} diff --git a/src/main/resources/messages/dsspringusermessages.properties b/src/main/resources/messages/dsspringusermessages.properties index 74289cc..1c80803 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/CaptchaValidationInterceptorTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java new file mode 100644 index 0000000..2e6f671 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java @@ -0,0 +1,168 @@ +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.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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.MessageSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("CaptchaValidationInterceptor") +class CaptchaValidationInterceptorTest { + + @Mock + private ObjectProvider captchaServiceProvider; + + @Mock + private CaptchaService captchaService; + + @Mock + private MessageSource messages; + + 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); + when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); + 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; + } + + @Test + void shouldPassThroughWhenRequestIsNotPost() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", + CaptchaValidationInterceptor.REGISTRATION_PATH); + request.setRequestURI(CaptchaValidationInterceptor.REGISTRATION_PATH); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(anyString(), any()); + } + + @Test + void shouldPassThroughWhenActionToggleDisabled() throws Exception { + properties.getProtect().setRegistration(false); + + MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.REGISTRATION_PATH); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService, never()).verify(anyString(), any()); + } + + @Test + void shouldRejectWithJsonResponseWhenTokenMissing() throws Exception { + MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.RESET_PASSWORD_PATH); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean proceed = interceptor.preHandle(request, response, new Object()); + + assertThat(proceed).isFalse(); + 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); + assertThat(body.get("messages").get(0).asText()).contains("CAPTCHA"); + verify(captchaService, never()).verify(anyString(), any()); + } + + @Test + void shouldRejectWhenTokenInvalid() throws Exception { + MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.RESEND_TOKEN_PATH); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "bad-token"); + when(captchaService.verify(eq("bad-token"), any())).thenReturn(false); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void shouldPassThroughWhenHeaderTokenValid() throws Exception { + MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.REGISTRATION_PATH); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + when(captchaService.verify(eq("good-token"), any())).thenReturn(true); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + } + + @Test + void shouldFallBackToRequestParameterWhenHeaderAbsent() throws Exception { + MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.REGISTRATION_PATH); + request.setParameter(CaptchaValidationInterceptor.TOKEN_PARAMETER, "param-token"); + when(captchaService.verify(eq("param-token"), any())).thenReturn(true); + + assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); + verify(captchaService).verify(eq("param-token"), any()); + } + + @Test + void shouldRejectWhenCaptchaServiceUnavailable() throws Exception { + when(captchaServiceProvider.getIfAvailable()).thenReturn(null); + MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.REGISTRATION_PATH); + request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertThat(response.getStatus()).isEqualTo(403); + } + + @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(CaptchaValidationInterceptor.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 shouldHandleContextPathWhenResolvingAction() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", + "/app" + CaptchaValidationInterceptor.REGISTRATION_PATH); + request.setContextPath("/app"); + request.setRequestURI("/app" + CaptchaValidationInterceptor.REGISTRATION_PATH); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertThat(response.getStatus()).isEqualTo(403); + } +} From f7c8d3b79e3232ae4191dac60f48f7b333f1ea14 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:26:05 -0600 Subject: [PATCH 05/14] feat: auto-configure captcha with fail-closed startup validation (#346) --- .../captcha/CaptchaAutoConfiguration.java | 112 +++++++++++++++ .../user/captcha/CaptchaStartupValidator.java | 56 ++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../captcha/CaptchaAutoConfigurationTest.java | 136 ++++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java 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 0000000..f914bb0 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java @@ -0,0 +1,112 @@ +package com.digitalsanctuary.spring.user.captcha; + +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.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.service.TurnstileValidationService; + +/** + * Auto-configuration for optional CAPTCHA verification on the framework's unauthenticated, + * email-sending API actions (registration, password reset, resend verification token). + * + *

+ * Entirely inert unless {@code user.security.captcha.enabled=true}: no interceptor is registered + * and no provider beans are created, so existing consumers see no behavior change. 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 { + + /** + * Startup validation runs whenever the library is present, so enabling CAPTCHA without a + * provider fails fast instead of silently not protecting anything. + * + * @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) { + return new TurnstileCaptchaService(turnstileServiceProvider); + } + } + + /** + * 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) { + return new CaptchaValidationInterceptor(captchaConfigProperties, captchaServiceProvider, messages); + } + + /** + * Registers the interceptor against exactly the three protected API paths. + * + * @param captchaValidationInterceptor the interceptor to register + * @return the WebMvcConfigurer registering the interceptor + */ + @Bean + public WebMvcConfigurer captchaWebMvcConfigurer(CaptchaValidationInterceptor captchaValidationInterceptor) { + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(captchaValidationInterceptor).addPathPatterns( + CaptchaValidationInterceptor.REGISTRATION_PATH, + CaptchaValidationInterceptor.RESET_PASSWORD_PATH, + CaptchaValidationInterceptor.RESEND_TOKEN_PATH); + } + }; + } + } +} 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 0000000..56299b9 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java @@ -0,0 +1,56 @@ +package com.digitalsanctuary.spring.user.captcha; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +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} but no + * {@link CaptchaService} can be resolved — for example, the configured provider's library is not on + * the classpath, or {@code user.security.captcha.provider} names an unknown provider. Also logs any + * 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 once the context is fully refreshed. + * + * @param event the context refreshed event + */ + @EventListener(ContextRefreshedEvent.class) + public void validateCaptchaConfiguration(ContextRefreshedEvent event) { + 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)."); + } + CaptchaConfigProperties.Protect protect = captchaConfigProperties.getProtect(); + log.info("CAPTCHA protection enabled (provider: {}). Protected actions: registration={}," + + " resetPassword={}, resendRegistrationToken={}", captchaConfigProperties.getProvider(), + protect.isRegistration(), protect.isResetPassword(), protect.isResendRegistrationToken()); + for (String warning : captchaService.configurationWarnings()) { + log.warn("========================================================"); + log.warn("CAPTCHA CONFIGURATION WARNING: {}", warning); + log.warn("========================================================"); + } + } +} 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 f5da61f..4ff463f 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/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 0000000..135cbda --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java @@ -0,0 +1,136 @@ +package com.digitalsanctuary.spring.user.captcha; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +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.service.TurnstileValidationService; + +import jakarta.servlet.http.HttpServletRequest; + +@DisplayName("CaptchaAutoConfiguration") +class CaptchaAutoConfigurationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CaptchaAutoConfiguration.class)); + + @Configuration(proxyBeanMethods = false) + static class TurnstileServiceBeanConfiguration { + @Bean + TurnstileValidationService turnstileValidationService() { + return Mockito.mock(TurnstileValidationService.class); + } + } + + @Configuration(proxyBeanMethods = false) + static class TestCredentialTurnstileServiceConfiguration { + @Bean + TurnstileValidationService turnstileValidationService() { + TurnstileValidationService mock = Mockito.mock(TurnstileValidationService.class); + Mockito.when(mock.isUsingTestCredentials()).thenReturn(true); + return mock; + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomCaptchaServiceConfiguration { + @Bean + CaptchaService customCaptchaService() { + return new CaptchaService() { + @Override + public boolean verify(String token, HttpServletRequest request) { + return true; + } + + @Override + public String getSiteKey() { + return "custom"; + } + }; + } + } + + @Test + void shouldRegisterNoCaptchaBeansWhenDisabled() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(CaptchaService.class); + assertThat(context).doesNotHaveBean(CaptchaValidationInterceptor.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(); + assertThat(context.getStartupFailure()).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).getSiteKey()).isEqualTo("custom"); + }); + } + + @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")); + }); + } +} From b62c5d8c4081556e75d17f9eefede687ef4f212c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:32:42 -0600 Subject: [PATCH 06/14] test: integration coverage for captcha protection and per-action toggles (#346) --- .../CaptchaProtectionIntegrationTest.java | 228 ++++++++++++++++++ .../captcha/CaptchaToggleIntegrationTest.java | 187 ++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaToggleIntegrationTest.java 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 0000000..c98bef5 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java @@ -0,0 +1,228 @@ +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 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 jakarta.servlet.http.HttpServletRequest; +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" +}) +@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 { + @Bean + @Primary + CaptchaService stubCaptchaService() { + return new CaptchaService() { + @Override + public boolean verify(String token, HttpServletRequest request) { + return VALID_TOKEN.equals(token); + } + + @Override + public String getSiteKey() { + return "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.tester+" + 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 void assertNoEmailSent() { + assertThat(mockMailSender().getSentMimeMessages()).isEmpty(); + assertThat(mockMailSender().getSentSimpleMessages()).isEmpty(); + } + + 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(); + } + + @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(); + } + + @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 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)); + } + + @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)); + } +} 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 0000000..f024afc --- /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 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 jakarta.servlet.http.HttpServletRequest; +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 returns {@code false} from {@link CaptchaService#verify}, + * 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 boolean verify(String token, HttpServletRequest request) { + // 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 false; + } + + @Override + public String getSiteKey() { + return "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)); + } +} From 4d26b21506e669a2c14c3de31a7fb813542b0f73 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:44:38 -0600 Subject: [PATCH 07/14] test: drain async mail executor before captcha reject-path assertions (#346) --- .../CaptchaProtectionIntegrationTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java index c98bef5..968e69c 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java @@ -6,8 +6,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.Duration; import java.util.Map; +import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -15,6 +17,7 @@ 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; @@ -23,6 +26,7 @@ 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; @@ -116,6 +120,17 @@ public String getSiteKey() { @Autowired private JavaMailSender mailSender; + /** + * The bounded executor {@code MailService} dispatches {@code @Async("dsMailExecutor")} sends on + * (e.g. the resetPassword success test's {@code sendForgotPasswordVerificationEmail} call). + * 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; @@ -127,6 +142,12 @@ void setUp() { // 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 (e.g. shouldAllowResetPasswordWhenTokenValid) may not have + // finished its async send by the time this method starts. + drainMailExecutor(); mockMailSender().clear(); deleteTestUser(testEmail); } @@ -140,6 +161,15 @@ 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 From 5c02b3fd958fd9d56aab46d999df5d91af66680e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:49:00 -0600 Subject: [PATCH 08/14] feat: expose captchaSiteKey model attribute for consumer templates (#346) --- .../CaptchaSiteKeyControllerAdvice.java | 34 +++++++++++ .../CaptchaSiteKeyControllerAdviceTest.java | 57 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java 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 0000000..e657d98 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java @@ -0,0 +1,34 @@ +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; + +/** + * Exposes the CAPTCHA public site key to all MVC page controllers 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}, and + * targeted at {@code @Controller} classes so REST responses are unaffected. + */ +@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. + * + * @return the site key for widget rendering + */ + @ModelAttribute("captchaSiteKey") + public String captchaSiteKey() { + CaptchaService captchaService = captchaServiceProvider.getIfAvailable(); + return captchaService != null ? captchaService.getSiteKey() : null; + } +} 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 0000000..a50d850 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java @@ -0,0 +1,57 @@ +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 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.getSiteKey()).thenReturn("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(); + } +} From ae8c84f5e99a5017805350dd9a5bc18e65e94d18 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:53:46 -0600 Subject: [PATCH 09/14] docs: document captcha configuration and client contract (#346) --- CLAUDE.md | 1 + CONFIG.md | 25 ++++++++++++++++ README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a61fc5e..fa022bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,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, 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 c149e23..6a4af2b 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -92,6 +92,31 @@ 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 beans are registered and behavior is unchanged until you opt in. + +- **Enabled (`user.security.captcha.enabled`)**: Master switch. When `false` (default), no CAPTCHA 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. +- **Protect Registration (`user.security.captcha.protect.registration`)**: Require CAPTCHA on `POST /user/registration`. Defaults to `true`. +- **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 and passwordless registration are 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 e56400c..3cc3184 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,93 @@ public AuthenticationEntryPoint authenticationEntryPoint() { } ``` +### CAPTCHA Protection (Cloudflare Turnstile) + +The framework can require a CAPTCHA challenge on its three unauthenticated, email-sending API actions — `POST /user/registration`, `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 beans are registered, behavior is byte-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 all `@Controller`-annotated MVC page controllers (the Turnstile library also offers `${@turnstileValidationService.getTurnstileSitekey()}` for use directly in Thymeleaf). A minimal registration page snippet: + +```html +
+ + +``` + +**Fail-closed semantics** + +- Enabling CAPTCHA (`enabled=true`) without a usable 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. +- 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. +- `POST /user/registration/passwordless` is not covered by this feature. +- 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. + ## User Management ### Registration From 92a85b8c3a09c78a95c93880eeb0164bf2456615 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 13:59:40 -0600 Subject: [PATCH 10/14] docs: correct disabled-state bean registration wording (#346) --- CONFIG.md | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index 6a4af2b..bdedec4 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -94,9 +94,9 @@ When neither `appUrl` nor `trustedHosts` is set, links are built from the reques ### 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 beans are registered and behavior is unchanged until you opt in. +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 beans are registered and no requests are checked. +- **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. - **Protect Registration (`user.security.captcha.protect.registration`)**: Require CAPTCHA on `POST /user/registration`. Defaults to `true`. - **Protect Reset Password (`user.security.captcha.protect.reset-password`)**: Require CAPTCHA on `POST /user/resetPassword`. Defaults to `true`. diff --git a/README.md b/README.md index 3cc3184..6ff81f4 100644 --- a/README.md +++ b/README.md @@ -553,7 +553,7 @@ public AuthenticationEntryPoint authenticationEntryPoint() { ### CAPTCHA Protection (Cloudflare Turnstile) -The framework can require a CAPTCHA challenge on its three unauthenticated, email-sending API actions — `POST /user/registration`, `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 beans are registered, behavior is byte-identical to previous releases, and no extra dependency is required. +The framework can require a CAPTCHA challenge on its three unauthenticated, email-sending API actions — `POST /user/registration`, `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** From 3ca70268e3e4de248678bfedf5f17355de774dae Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 14:17:59 -0600 Subject: [PATCH 11/14] fix: enforce captcha on path variants to close matcher-mismatch bypass (#346) The interceptor was registered via PathPattern (which URL-decodes segments and strips matrix parameters) but enforced via an exact string switch on the raw request URI. Requests like POST /user/%72egistration or /user/registration;a=b matched the registration pattern, ran the interceptor, fell through the switch's default -> false, and reached the email-sending handler with no CAPTCHA. preHandle now matches the same parsed RequestPath the dispatcher cached for handler mapping (falling back to an identical fresh parse) against PathPatterns compiled with the same default parser the registration uses, so enforcement cannot disagree with registration. Unparseable or unmatched paths on an invoked interceptor fail closed as protected. Verified empirically: the percent-encoded variant routes to the handler through the real dispatcher (was 200 pre-fix, now 403); the matrix-param variant is rejected pre-dispatch by StrictHttpFirewall (400) and is additionally 403'd by the interceptor if a consumer relaxes the firewall. --- .../captcha/CaptchaValidationInterceptor.java | 66 ++++++++++++++++--- .../CaptchaProtectionIntegrationTest.java | 32 +++++++++ .../CaptchaValidationInterceptorTest.java | 24 +++++++ 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java index 080a2de..e9c22aa 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java @@ -6,7 +6,11 @@ import org.springframework.beans.factory.ObjectProvider; 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 jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -56,6 +60,18 @@ public class CaptchaValidationInterceptor implements HandlerInterceptor { 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."; + /* + * 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 PathPattern REGISTRATION_PATTERN = PathPatternParser.defaultInstance.parse(REGISTRATION_PATH); + private static final PathPattern RESET_PASSWORD_PATTERN = PathPatternParser.defaultInstance.parse(RESET_PASSWORD_PATH); + private static final PathPattern RESEND_TOKEN_PATTERN = PathPatternParser.defaultInstance.parse(RESEND_TOKEN_PATH); + private final CaptchaConfigProperties captchaConfigProperties; private final ObjectProvider captchaServiceProvider; private final MessageSource messages; @@ -66,8 +82,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons if (!"POST".equalsIgnoreCase(request.getMethod())) { return true; } - String path = request.getRequestURI().substring(request.getContextPath().length()); - if (!isActionProtected(path)) { + String path = request.getRequestURI(); + if (!isActionProtected(request)) { return true; } String token = resolveToken(request); @@ -87,14 +103,46 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - private boolean isActionProtected(String path) { + /** + * Decides whether this POST targets a protected action, matching the context-relative request + * path with the same {@code PathPattern} engine the interceptor registration uses, so this + * decision cannot disagree with the registration's. Fail-closed on both edge cases: a request + * path that cannot be parsed, or one that matches none of the action patterns even though this + * interceptor was invoked for it (the interceptor is registered against exactly these + * patterns, so through the real dispatcher that cannot happen), is treated as protected. + */ + private boolean isActionProtected(HttpServletRequest request) { + PathContainer path; + try { + path = resolvePathWithinApplication(request); + } catch (RuntimeException e) { + log.warn("Could not parse request path {}. Failing closed.", request.getRequestURI(), e); + return true; + } CaptchaConfigProperties.Protect protect = captchaConfigProperties.getProtect(); - return switch (path) { - case REGISTRATION_PATH -> protect.isRegistration(); - case RESET_PASSWORD_PATH -> protect.isResetPassword(); - case RESEND_TOKEN_PATH -> protect.isResendRegistrationToken(); - default -> false; - }; + if (REGISTRATION_PATTERN.matches(path)) { + return protect.isRegistration(); + } + if (RESET_PASSWORD_PATTERN.matches(path)) { + return protect.isResetPassword(); + } + if (RESEND_TOKEN_PATTERN.matches(path)) { + return protect.isResendRegistrationToken(); + } + return true; + } + + /** + * 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(); } private String resolveToken(HttpServletRequest request) { diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java index 968e69c..2f82536 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java @@ -6,6 +6,7 @@ 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; @@ -230,6 +231,37 @@ void shouldRegisterWhenTokenValid() throws Exception { assertThat(created).isNotNull(); } + @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) diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java index 2e6f671..ae6b652 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java @@ -154,6 +154,30 @@ void shouldWriteValidJsonWhenMessageContainsQuotes() throws Exception { 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(CaptchaValidationInterceptor.REGISTRATION_PATH + ";jsessionid=abc"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); + assertThat(response.getStatus()).isEqualTo(403); + verify(captchaService, never()).verify(anyString(), 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(); + assertThat(response.getStatus()).isEqualTo(403); + verify(captchaService, never()).verify(anyString(), any()); + } + @Test void shouldHandleContextPathWhenResolvingAction() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("POST", From 36c647a729e5673cab8a2cc86f5cc8a484ee5ce6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 9 Aug 2026 14:17:59 -0600 Subject: [PATCH 12/14] style: reindent captcha SPI/adapter to 4 spaces and drop unused import (#346) --- .../spring/user/captcha/CaptchaService.java | 48 ++-- .../user/captcha/TurnstileCaptchaService.java | 98 ++++----- .../captcha/TurnstileCaptchaServiceTest.java | 207 +++++++++--------- 3 files changed, 176 insertions(+), 177 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java index 334c8b0..9c611e9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java @@ -17,30 +17,30 @@ */ public interface CaptchaService { - /** - * Verifies a CAPTCHA response token. Implementations MUST fail closed: any error (missing - * configuration, provider unreachable, invalid token) returns {@code false}. - * - * @param token the CAPTCHA response token supplied by the client - * @param request the current request, for client IP extraction - * @return true only if the provider positively verified the token - */ - boolean verify(String token, HttpServletRequest request); + /** + * Verifies a CAPTCHA response token. Implementations MUST fail closed: any error (missing + * configuration, provider unreachable, invalid token) returns {@code false}. + * + * @param token the CAPTCHA response token supplied by the client + * @param request the current request, for client IP extraction + * @return true only if the provider positively verified the token + */ + boolean verify(String token, HttpServletRequest request); - /** - * Returns the public site key for rendering the CAPTCHA widget, or null if not configured. - * - * @return the public site key, or null - */ - String getSiteKey(); + /** + * Returns the public site key for rendering the CAPTCHA widget, or null if not configured. + * + * @return the public site key, or null + */ + String getSiteKey(); - /** - * 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 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(); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java index 784bcc9..9e7f85e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java @@ -31,56 +31,56 @@ @RequiredArgsConstructor public class TurnstileCaptchaService implements CaptchaService { - private final ObjectProvider turnstileServiceProvider; + private final ObjectProvider turnstileServiceProvider; - @Override - public boolean verify(String token, HttpServletRequest request) { - TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); - if (turnstileService == null) { - log.error("CAPTCHA is enabled but no TurnstileValidationService bean is available. Failing closed."); - return false; - } - try { - String clientIp = turnstileService.getClientIpAddress(request); - return turnstileService.validateTurnstileResponse(token, clientIp); - } catch (RuntimeException e) { - log.error("Unexpected error during Turnstile verification. Failing closed.", e); - return false; - } - } + @Override + public boolean verify(String token, HttpServletRequest request) { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + log.error("CAPTCHA is enabled but no TurnstileValidationService bean is available. Failing closed."); + return false; + } + try { + String clientIp = turnstileService.getClientIpAddress(request); + return turnstileService.validateTurnstileResponse(token, clientIp); + } catch (RuntimeException e) { + log.error("Unexpected error during Turnstile verification. Failing closed.", e); + return false; + } + } - @Override - public String getSiteKey() { - TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); - if (turnstileService == null) { - return null; - } - try { - return turnstileService.getTurnstileSitekey(); - } catch (RuntimeException e) { - log.error("Error retrieving Turnstile site key. Failing closed.", e); - return null; - } - } + @Override + public String getSiteKey() { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + return null; + } + try { + return turnstileService.getTurnstileSitekey(); + } catch (RuntimeException e) { + log.error("Error retrieving Turnstile site key. Failing closed.", e); + return null; + } + } - @Override - public List configurationWarnings() { - TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); - if (turnstileService == null) { - return List.of("CAPTCHA is enabled with provider 'turnstile' but no TurnstileValidationService bean" - + " was found. All CAPTCHA-protected requests will be rejected (fail closed). Ensure the" - + " ds-spring-cf-turnstile auto-configuration is active."); - } - 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."); - } - return List.of(); - } + @Override + public List configurationWarnings() { + TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); + if (turnstileService == null) { + return List.of("CAPTCHA is enabled with provider 'turnstile' but no TurnstileValidationService bean" + + " was found. All CAPTCHA-protected requests will be rejected (fail closed). Ensure the" + + " ds-spring-cf-turnstile auto-configuration is active."); + } + 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."); + } + return List.of(); + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java index 6b67c65..d7a1844 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java @@ -1,7 +1,6 @@ package com.digitalsanctuary.spring.user.captcha; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,130 +24,130 @@ @DisplayName("TurnstileCaptchaService adapter") class TurnstileCaptchaServiceTest { - @Mock - private ObjectProvider turnstileServiceProvider; + @Mock + private ObjectProvider turnstileServiceProvider; - @Mock - private TurnstileValidationService turnstileValidationService; + @Mock + private TurnstileValidationService turnstileValidationService; - private TurnstileCaptchaService captchaService; + private TurnstileCaptchaService captchaService; - @BeforeEach - void setUp() { - captchaService = new TurnstileCaptchaService(turnstileServiceProvider); - } + @BeforeEach + void setUp() { + captchaService = new TurnstileCaptchaService(turnstileServiceProvider); + } - @Test - void shouldDelegateToTurnstileWithClientIpWhenServiceAvailable() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); - when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")).thenReturn(true); - - boolean result = captchaService.verify("tok-123", request); + @Test + void shouldDelegateToTurnstileWithClientIpWhenServiceAvailable() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); + when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")).thenReturn(true); + + boolean result = captchaService.verify("tok-123", request); - assertThat(result).isTrue(); - verify(turnstileValidationService).validateTurnstileResponse("tok-123", "203.0.113.7"); - } + assertThat(result).isTrue(); + verify(turnstileValidationService).validateTurnstileResponse("tok-123", "203.0.113.7"); + } - @Test - void shouldFailClosedWhenTurnstileValidationFails() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); - when(turnstileValidationService.validateTurnstileResponse("bad-token", "203.0.113.7")).thenReturn(false); + @Test + void shouldFailClosedWhenTurnstileValidationFails() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); + when(turnstileValidationService.validateTurnstileResponse("bad-token", "203.0.113.7")).thenReturn(false); - assertThat(captchaService.verify("bad-token", request)).isFalse(); - } + assertThat(captchaService.verify("bad-token", request)).isFalse(); + } - @Test - void shouldFailClosedWhenTurnstileServiceBeanMissing() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + @Test + void shouldFailClosedWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); - assertThat(captchaService.verify("tok-123", new MockHttpServletRequest())).isFalse(); - } + assertThat(captchaService.verify("tok-123", new MockHttpServletRequest())).isFalse(); + } - @Test - void shouldExposeSitekeyFromTurnstileService() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + @Test + void shouldExposeSitekeyFromTurnstileService() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); - assertThat(captchaService.getSiteKey()).isEqualTo("real-site-key"); - } + assertThat(captchaService.getSiteKey()).isEqualTo("real-site-key"); + } - @Test - void shouldWarnWhenCloudflareTestCredentialsConfigured() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - when(turnstileValidationService.isUsingTestCredentials()).thenReturn(true); + @Test + void shouldWarnWhenCloudflareTestCredentialsConfigured() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(true); - List warnings = captchaService.configurationWarnings(); + List warnings = captchaService.configurationWarnings(); - assertThat(warnings).hasSize(1); - assertThat(warnings.get(0)).contains("test"); - } + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0)).contains("test"); + } - @Test - void shouldWarnWhenTurnstileServiceBeanMissing() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + @Test + void shouldWarnWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); - assertThat(captchaService.configurationWarnings()) - .anySatisfy(warning -> assertThat(warning).contains("fail closed")); - } + assertThat(captchaService.configurationWarnings()) + .anySatisfy(warning -> assertThat(warning).contains("fail closed")); + } - @Test - void shouldReturnNoWarningsWhenRealCredentialsConfigured() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); + @Test + void shouldReturnNoWarningsWhenRealCredentialsConfigured() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); - assertThat(captchaService.configurationWarnings()).isEmpty(); - } + assertThat(captchaService.configurationWarnings()).isEmpty(); + } - @Test - void shouldReturnNullSiteKeyWhenTurnstileServiceBeanMissing() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + @Test + void shouldReturnNullSiteKeyWhenTurnstileServiceBeanMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); - assertThat(captchaService.getSiteKey()).isNull(); - } + assertThat(captchaService.getSiteKey()).isNull(); + } - @Test - void shouldFailClosedWhenGetClientIpAddressThrows() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)) - .thenThrow(new RuntimeException("IP extraction failed")); + @Test + void shouldFailClosedWhenGetClientIpAddressThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)) + .thenThrow(new RuntimeException("IP extraction failed")); - assertThat(captchaService.verify("tok-123", request)).isFalse(); - } + assertThat(captchaService.verify("tok-123", request)).isFalse(); + } - @Test - void shouldFailClosedWhenValidateTurnstileResponseThrows() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); - when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")) - .thenThrow(new RuntimeException("Validation service unavailable")); - - assertThat(captchaService.verify("tok-123", request)).isFalse(); - } - - @Test - void shouldReturnNullSiteKeyWhenGetTurnstileSitekeyThrows() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - when(turnstileValidationService.getTurnstileSitekey()) - .thenThrow(new RuntimeException("Could not fetch site key")); - - assertThat(captchaService.getSiteKey()).isNull(); - } - - @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"); - } + @Test + void shouldFailClosedWhenValidateTurnstileResponseThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + MockHttpServletRequest request = new MockHttpServletRequest(); + when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); + when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")) + .thenThrow(new RuntimeException("Validation service unavailable")); + + assertThat(captchaService.verify("tok-123", request)).isFalse(); + } + + @Test + void shouldReturnNullSiteKeyWhenGetTurnstileSitekeyThrows() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()) + .thenThrow(new RuntimeException("Could not fetch site key")); + + assertThat(captchaService.getSiteKey()).isNull(); + } + + @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"); + } } From 45378ad739e089f04eeaa2e38bf2338fad314f98 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 11 Aug 2026 22:19:25 -0600 Subject: [PATCH 13/14] refactor: redesign captcha SPI and harden per code review (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the multi-agent PR review of #347. SPI redesign: - CaptchaService.verify now takes a CaptchaContext (action, token, framework-resolved client IP, request) and returns a three-way CaptchaVerification (VERIFIED/REJECTED/ERROR) instead of a boolean, so a provider outage is distinguishable from a bot and the framework, not the implementation, owns the fail-closed rule. Throwing or null-returning providers are caught and rejected with the documented 403 body. - Protected paths live on the CaptchaAction enum — single source of truth for registration, enforcement, and per-action toggles; unknown actions fail closed. Scope and behavior: - POST /user/registration/passwordless is now CAPTCHA-protected (protect.passwordless-registration, default true) — it sends a verification email for an unauthenticated caller like /registration. - configurationErrors() SPI hook + startup gate: a resolvable provider that cannot verify anything (missing Turnstile secret or site key, absent service bean) now fails startup instead of booting into a 100% rejection rate; user.security.captcha.allow-unusable-provider=true downgrades that to an ERROR banner. - Startup validation moved to @PostConstruct so an async event multicaster cannot swallow the fail-fast exception. - Rejections publish AuditEvents, log the X-Forwarded-For client IP (matching what the provider is told), and serialize the real JSONResponse instead of a hand-built string. - CaptchaSiteKeyControllerAdvice tolerates a throwing consumer provider instead of breaking every MVC page. Tests: - Per-action toggle coverage for every action, over-blocking probes (unprotected /user/savePassword and unknown paths stay untouched), advice wiring pinned in the full context, and a new wiring test that runs the real ds-spring-cf-turnstile auto-configuration instead of mocks (no network — verify is never called). - Class-wide lenient Mockito strictness replaced with per-stub lenient(), which surfaced and removed one dead stub. Docs: README (Thymeleaf-correct widget snippet, token reset on failure, custom-provider guide), CONFIG.md, CLAUDE.md updated to match. --- CLAUDE.md | 8 +- CONFIG.md | 4 +- README.md | 72 +++- .../spring/user/captcha/CaptchaAction.java | 56 +++ .../captcha/CaptchaAutoConfiguration.java | 43 ++- .../user/captcha/CaptchaConfigProperties.java | 39 +- .../spring/user/captcha/CaptchaContext.java | 41 +++ .../spring/user/captcha/CaptchaService.java | 94 ++++- .../CaptchaSiteKeyControllerAdvice.java | 37 +- .../user/captcha/CaptchaStartupValidator.java | 55 ++- .../captcha/CaptchaValidationInterceptor.java | 193 +++++++--- .../user/captcha/CaptchaVerification.java | 86 +++++ .../user/captcha/TurnstileCaptchaService.java | 75 +++- .../spring/user/captcha/package-info.java | 40 +++ .../captcha/CaptchaAutoConfigurationTest.java | 89 ++++- .../captcha/CaptchaConfigPropertiesTest.java | 19 + .../CaptchaProtectionIntegrationTest.java | 153 +++++++- .../CaptchaSiteKeyControllerAdviceTest.java | 27 +- .../captcha/CaptchaStartupValidatorTest.java | 137 +++++++ .../captcha/CaptchaToggleIntegrationTest.java | 12 +- .../CaptchaValidationInterceptorTest.java | 339 ++++++++++++++++-- ...rnstileAutoConfigurationExclusionTest.java | 39 ++ .../TurnstileAutoConfigurationWiringTest.java | 82 +++++ .../captcha/TurnstileCaptchaServiceTest.java | 139 ++++--- .../test/config/MockMailConfiguration.java | 11 + .../resources/application-test.properties | 7 +- 26 files changed, 1656 insertions(+), 241 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAction.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaContext.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaVerification.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/captcha/package-info.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidatorTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationExclusionTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileAutoConfigurationWiringTest.java diff --git a/CLAUDE.md b/CLAUDE.md index fa022bf..5f7a7ec 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,7 +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, protect.*) +- `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 bdedec4..44d9d36 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -98,7 +98,9 @@ Optional CAPTCHA verification on the framework's unauthenticated, email-sending - **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`. @@ -115,7 +117,7 @@ user: 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 and passwordless registration are not covered). +**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) diff --git a/README.md b/README.md index 6ff81f4..ddf7b8b 100644 --- a/README.md +++ b/README.md @@ -553,7 +553,7 @@ public AuthenticationEntryPoint authenticationEntryPoint() { ### CAPTCHA Protection (Cloudflare Turnstile) -The framework can require a CAPTCHA challenge on its three unauthenticated, email-sending API actions — `POST /user/registration`, `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. +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** @@ -604,10 +604,13 @@ The message text is customizable via the `message.captcha.validation-failed` key **Widget rendering** -Consuming applications own their own templates. The framework exposes the configured site key as the `captchaSiteKey` model attribute on all `@Controller`-annotated MVC page controllers (the Turnstile library also offers `${@turnstileValidationService.getTurnstileSitekey()}` for use directly in Thymeleaf). A minimal registration page snippet: +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 usable 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 (`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. -- `POST /user/registration/passwordless` is not covered by this feature. - 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. +**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/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 0000000..24b22b0 --- /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 index f914bb0..2941565 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfiguration.java @@ -1,28 +1,36 @@ 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). * *

- * Entirely inert unless {@code user.security.captcha.enabled=true}: no interceptor is registered - * and no provider beans are created, so existing consumers see no behavior change. 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. + * 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 @@ -30,8 +38,10 @@ public class CaptchaAutoConfiguration { /** - * Startup validation runs whenever the library is present, so enabling CAPTCHA without a - * provider fails fast instead of silently not protecting anything. + * 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 @@ -63,8 +73,9 @@ static class TurnstileCaptchaConfiguration { @ConditionalOnMissingBean(CaptchaService.class) @ConditionalOnProperty(name = "user.security.captcha.provider", havingValue = "turnstile", matchIfMissing = true) - public CaptchaService captchaService(ObjectProvider turnstileServiceProvider) { - return new TurnstileCaptchaService(turnstileServiceProvider); + public CaptchaService captchaService(ObjectProvider turnstileServiceProvider, + ObjectProvider turnstilePropertiesProvider) { + return new TurnstileCaptchaService(turnstileServiceProvider, turnstilePropertiesProvider); } } @@ -86,25 +97,27 @@ static class CaptchaWebConfiguration { @Bean public CaptchaValidationInterceptor captchaValidationInterceptor( CaptchaConfigProperties captchaConfigProperties, - ObjectProvider captchaServiceProvider, MessageSource messages) { - return new CaptchaValidationInterceptor(captchaConfigProperties, captchaServiceProvider, messages); + ObjectProvider captchaServiceProvider, MessageSource messages, + ObjectProvider objectMapperProvider, ApplicationEventPublisher eventPublisher) { + return new CaptchaValidationInterceptor(captchaConfigProperties, captchaServiceProvider, messages, + objectMapperProvider, eventPublisher); } /** - * Registers the interceptor against exactly the three protected API paths. + * 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( - CaptchaValidationInterceptor.REGISTRATION_PATH, - CaptchaValidationInterceptor.RESET_PASSWORD_PATH, - CaptchaValidationInterceptor.RESEND_TOKEN_PATH); + 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 index 8125a1f..393b07f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigProperties.java @@ -10,7 +10,9 @@ * *

* Disabled by default: with {@code user.security.captcha.enabled=false} the framework registers no - * CAPTCHA beans and behavior is identical to previous releases. + * 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 @@ -18,8 +20,10 @@ public class CaptchaConfigProperties { /** - * Master switch for CAPTCHA verification. When false (the default), no CAPTCHA beans are - * registered and no requests are checked. + * 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; @@ -30,6 +34,15 @@ public class CaptchaConfigProperties { */ 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(); @@ -43,10 +56,30 @@ 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 0000000..2701a14 --- /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 index 9c611e9..82a1810 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaService.java @@ -1,38 +1,81 @@ package com.digitalsanctuary.spring.user.captcha; import java.util.List; - -import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; /** * Provider-neutral CAPTCHA verification SPI. * *

- * The framework ships a Cloudflare Turnstile implementation ({@link TurnstileCaptchaService}), - * auto-configured when {@code com.digitalsanctuary:ds-spring-cf-turnstile} is on the classpath and - * {@code user.security.captcha.provider=turnstile}. 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. + * 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. Implementations MUST fail closed: any error (missing - * configuration, provider unreachable, invalid token) returns {@code false}. + * Verifies a CAPTCHA response token. * - * @param token the CAPTCHA response token supplied by the client - * @param request the current request, for client IP extraction - * @return true only if the provider positively verified the 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. */ - boolean verify(String token, HttpServletRequest request); + CaptchaVerification verify(CaptchaContext context); /** - * Returns the public site key for rendering the CAPTCHA widget, or null if not configured. + * 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 null + * @return the public site key, or empty when none is configured */ - String getSiteKey(); + default Optional siteKey() { + return Optional.empty(); + } /** * Returns human-readable warnings about the current provider configuration (for example, @@ -43,4 +86,23 @@ public interface CaptchaService { 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 index e657d98..fcd4d09 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdvice.java @@ -7,13 +7,23 @@ import org.springframework.web.bind.annotation.ModelAttribute; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** - * Exposes the CAPTCHA public site key to all MVC page controllers 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}, and - * targeted at {@code @Controller} classes so REST responses are unaffected. + * 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 @@ -22,13 +32,28 @@ public class CaptchaSiteKeyControllerAdvice { private final ObjectProvider captchaServiceProvider; /** - * The CAPTCHA public site key, or null when no provider is available. + * 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(); - return captchaService != null ? captchaService.getSiteKey() : null; + 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 index 56299b9..8853f35 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaStartupValidator.java @@ -1,9 +1,10 @@ package com.digitalsanctuary.spring.user.captcha; +import java.util.List; + import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -11,11 +12,15 @@ * Validates the CAPTCHA configuration at startup. * *

- * Fails application startup (fail closed) when {@code user.security.captcha.enabled=true} but no - * {@link CaptchaService} can be resolved — for example, the configured provider's library is not on - * the classpath, or {@code user.security.captcha.provider} names an unknown provider. Also logs any - * provider configuration warnings (such as Cloudflare always-pass test keys) so test credentials - * cannot reach production unnoticed. + * 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 @@ -26,12 +31,18 @@ public class CaptchaStartupValidator { private final ObjectProvider captchaServiceProvider; /** - * Validates CAPTCHA configuration once the context is fully refreshed. + * Validates CAPTCHA configuration as this bean initializes. * - * @param event the context refreshed event + *

+ * 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. + *

*/ - @EventListener(ContextRefreshedEvent.class) - public void validateCaptchaConfiguration(ContextRefreshedEvent event) { + @PostConstruct + public void validateCaptchaConfiguration() { if (!captchaConfigProperties.isEnabled()) { return; } @@ -43,10 +54,28 @@ public void validateCaptchaConfiguration(ContextRefreshedEvent event) { + " 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={}," - + " resetPassword={}, resendRegistrationToken={}", captchaConfigProperties.getProvider(), - protect.isRegistration(), protect.isResetPassword(), protect.isResendRegistrationToken()); + + " 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); diff --git a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java index e9c22aa..4bbf83c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptor.java @@ -1,9 +1,11 @@ package com.digitalsanctuary.spring.user.captcha; import java.io.IOException; +import java.util.EnumMap; +import java.util.Map; -import org.apache.commons.text.StringEscapeUtils; 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; @@ -12,15 +14,21 @@ 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 three protected paths. + * {@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 @@ -31,27 +39,25 @@ *

* 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, or a failed - * validation all reject the request before the handler runs, so no email is sent. + * 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 { - /** Path of the registration API action. */ - public static final String REGISTRATION_PATH = "/user/registration"; - - /** Path of the password-reset API action. */ - public static final String RESET_PASSWORD_PATH = "/user/resetPassword"; - - /** Path of the resend-verification-token API action. */ - public static final String RESEND_TOKEN_PATH = "/user/resendRegistrationToken"; - /** 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). */ + /** + * 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. */ @@ -60,6 +66,9 @@ public class CaptchaValidationInterceptor implements HandlerInterceptor { 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 @@ -68,13 +77,24 @@ public class CaptchaValidationInterceptor implements HandlerInterceptor { * comparison would let variants like "/user/registration;jsessionid=x" or * "/user/%72egistration" through unprotected while still reaching the handler. */ - private static final PathPattern REGISTRATION_PATTERN = PathPatternParser.defaultInstance.parse(REGISTRATION_PATH); - private static final PathPattern RESET_PASSWORD_PATTERN = PathPatternParser.defaultInstance.parse(RESET_PASSWORD_PATH); - private static final PathPattern RESEND_TOKEN_PATTERN = PathPatternParser.defaultInstance.parse(RESEND_TOKEN_PATH); + 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) @@ -83,53 +103,81 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } String path = request.getRequestURI(); - if (!isActionProtected(request)) { + 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, request.getRemoteAddr()); - return reject(request, response); + 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); + return reject(request, response, "No CaptchaService available for action " + action, clientIp); } - if (!captchaService.verify(token, request)) { - log.warn("CAPTCHA validation failed on {} from {}. Rejecting request.", path, request.getRemoteAddr()); - return reject(request, response); + 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; } /** - * Decides whether this POST targets a protected action, matching the context-relative request - * path with the same {@code PathPattern} engine the interceptor registration uses, so this - * decision cannot disagree with the registration's. Fail-closed on both edge cases: a request - * path that cannot be parsed, or one that matches none of the action patterns even though this - * interceptor was invoked for it (the interceptor is registered against exactly these - * patterns, so through the real dispatcher that cannot happen), is treated as protected. + * 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 boolean isActionProtected(HttpServletRequest request) { + 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 true; - } - CaptchaConfigProperties.Protect protect = captchaConfigProperties.getProtect(); - if (REGISTRATION_PATTERN.matches(path)) { - return protect.isRegistration(); + return null; } - if (RESET_PASSWORD_PATTERN.matches(path)) { - return protect.isResetPassword(); + for (Map.Entry entry : ACTION_PATTERNS.entrySet()) { + if (entry.getValue().matches(path)) { + return entry.getKey(); + } } - if (RESEND_TOKEN_PATTERN.matches(path)) { - return protect.isResendRegistrationToken(); - } - return true; + return null; } /** @@ -145,6 +193,31 @@ private PathContainer resolvePathWithinApplication(HttpServletRequest request) { 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()) { @@ -153,13 +226,43 @@ private String resolveToken(HttpServletRequest request) { return token; } - private boolean reject(HttpServletRequest request, HttpServletResponse response) throws IOException { + 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("{\"success\":false,\"redirectUrl\":null,\"code\":" + ERROR_CODE_CAPTCHA_FAILED - + ",\"messages\":[\"" + StringEscapeUtils.escapeJson(message) + "\"],\"data\":null}"); + 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 0000000..e4e0c02 --- /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 index 9e7f85e..845962f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaService.java @@ -1,12 +1,14 @@ 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 jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +24,8 @@ * *

* Fail-closed: if the {@code TurnstileValidationService} bean is unavailable (for example its - * auto-configuration was excluded) or Cloudflare cannot be reached, {@link #verify} returns false. + * 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. *

@@ -32,34 +35,40 @@ public class TurnstileCaptchaService implements CaptchaService { private final ObjectProvider turnstileServiceProvider; + private final ObjectProvider turnstilePropertiesProvider; @Override - public boolean verify(String token, HttpServletRequest request) { + 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 false; + return CaptchaVerification.error("no TurnstileValidationService bean available"); } try { - String clientIp = turnstileService.getClientIpAddress(request); - return turnstileService.validateTurnstileResponse(token, clientIp); + // 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 false; + return CaptchaVerification.error("Turnstile verification threw " + e.getClass().getSimpleName()); } } @Override - public String getSiteKey() { + public Optional siteKey() { TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); if (turnstileService == null) { - return null; + return Optional.empty(); } try { - return turnstileService.getTurnstileSitekey(); + String siteKey = turnstileService.getTurnstileSitekey(); + return (siteKey == null || siteKey.isBlank()) ? Optional.empty() : Optional.of(siteKey); } catch (RuntimeException e) { - log.error("Error retrieving Turnstile site key. Failing closed.", e); - return null; + log.error("Error retrieving Turnstile site key.", e); + return Optional.empty(); } } @@ -67,9 +76,8 @@ public String getSiteKey() { public List configurationWarnings() { TurnstileValidationService turnstileService = turnstileServiceProvider.getIfAvailable(); if (turnstileService == null) { - return List.of("CAPTCHA is enabled with provider 'turnstile' but no TurnstileValidationService bean" - + " was found. All CAPTCHA-protected requests will be rejected (fail closed). Ensure the" - + " ds-spring-cf-turnstile auto-configuration is active."); + // Reported as an error, not a warning — see configurationErrors(). + return List.of(); } try { if (turnstileService.isUsingTestCredentials()) { @@ -81,6 +89,43 @@ public List configurationWarnings() { 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 0000000..383ece7 --- /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/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java index 135cbda..98b3f8f 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaAutoConfigurationTest.java @@ -3,6 +3,7 @@ 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; @@ -13,9 +14,9 @@ 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; -import jakarta.servlet.http.HttpServletRequest; @DisplayName("CaptchaAutoConfiguration") class CaptchaAutoConfigurationTest { @@ -23,11 +24,40 @@ 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() { - return Mockito.mock(TurnstileValidationService.class); + 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; } } @@ -37,8 +67,17 @@ static class TestCredentialTurnstileServiceConfiguration { 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) @@ -47,24 +86,28 @@ static class CustomCaptchaServiceConfiguration { CaptchaService customCaptchaService() { return new CaptchaService() { @Override - public boolean verify(String token, HttpServletRequest request) { - return true; + public CaptchaVerification verify(CaptchaContext context) { + return CaptchaVerification.verified(); } @Override - public String getSiteKey() { - return "custom"; + public Optional siteKey() { + return Optional.of("custom"); } }; } } @Test - void shouldRegisterNoCaptchaBeansWhenDisabled() { + 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); }); } @@ -95,8 +138,9 @@ void shouldFailStartupWhenEnabledAndTurnstileClassAbsent() { .withPropertyValues("user.security.captcha.enabled=true") .run(context -> { assertThat(context).hasFailed(); - assertThat(context.getStartupFailure()).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("CaptchaService"); + // @PostConstruct validation surfaces wrapped in BeanCreationException. + assertThat(context.getStartupFailure()).rootCause() + .isInstanceOf(IllegalStateException.class).hasMessageContaining("CaptchaService"); }); } @@ -116,7 +160,32 @@ void shouldPreferConsumerSuppliedCaptchaServiceBean() { .run(context -> { assertThat(context).hasNotFailed(); assertThat(context).hasSingleBean(CaptchaService.class); - assertThat(context.getBean(CaptchaService.class).getSiteKey()).isEqualTo("custom"); + 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); }); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java index ae7e89c..4cbcbcc 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaConfigPropertiesTest.java @@ -25,23 +25,42 @@ void shouldDefaultToDisabledTurnstileWithEmailActionsProtected() { 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 index 2f82536..bf52b7c 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaProtectionIntegrationTest.java @@ -9,6 +9,7 @@ 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; @@ -22,6 +23,8 @@ 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; @@ -46,7 +49,6 @@ import com.digitalsanctuary.spring.user.test.config.OAuth2TestConfiguration; import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; -import jakarta.servlet.http.HttpServletRequest; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -71,7 +73,26 @@ @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" + "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, @@ -83,18 +104,34 @@ class CaptchaProtectionIntegrationTest { @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 boolean verify(String token, HttpServletRequest request) { - return VALID_TOKEN.equals(token); + public CaptchaVerification verify(CaptchaContext context) { + return VALID_TOKEN.equals(context.token()) ? CaptchaVerification.verified() + : CaptchaVerification.rejected("stub rejected token"); } @Override - public String getSiteKey() { - return "stub-site-key"; + public Optional siteKey() { + return Optional.of("stub-site-key"); } }; } @@ -103,6 +140,9 @@ public String getSiteKey() { @Autowired private MockMvc mockMvc; + @Autowired + private ApplicationContext applicationContext; + @Autowired private UserService userService; @@ -123,10 +163,11 @@ public String getSiteKey() { /** * The bounded executor {@code MailService} dispatches {@code @Async("dsMailExecutor")} sends on - * (e.g. the resetPassword success test's {@code sendForgotPasswordVerificationEmail} call). - * 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. + * (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") @@ -146,8 +187,8 @@ void setUp() { // 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 (e.g. shouldAllowResetPasswordWhenTokenValid) may not have - // finished its async send by the time this method starts. + // preceding success-path test (shouldRegisterWhenTokenValid) may not have finished its + // async send by the time this method starts. drainMailExecutor(); mockMailSender().clear(); deleteTestUser(testEmail); @@ -155,6 +196,10 @@ void setUp() { @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); } @@ -187,11 +232,30 @@ private void deleteTestUser(String email) { }); } + /** + * 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")); @@ -218,6 +282,7 @@ void shouldRejectRegistrationWithInvalidToken() throws Exception { .andExpect(jsonPath("$.code").value(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED)); assertThat(userService.findUserByEmail(testEmail)).isNull(); + assertNoEmailSent(); } @Test @@ -229,6 +294,9 @@ void shouldRegisterWhenTokenValid() throws Exception { 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 @@ -271,12 +339,42 @@ void shouldRejectResetPasswordWithoutTokenAndSendNoEmail() throws Exception { 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 @@ -287,4 +385,35 @@ void shouldAllowResetPasswordWhenTokenValid() throws Exception { .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 index a50d850..ad30973 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaSiteKeyControllerAdviceTest.java @@ -6,6 +6,8 @@ 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; @@ -38,7 +40,7 @@ public String page() { @Test void shouldExposeSiteKeyModelAttributeWhenServiceAvailable() throws Exception { when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); - when(captchaService.getSiteKey()).thenReturn("the-site-key"); + when(captchaService.siteKey()).thenReturn(Optional.of("the-site-key")); MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestPageController()) .setControllerAdvice(new CaptchaSiteKeyControllerAdvice(captchaServiceProvider)).build(); @@ -54,4 +56,27 @@ void shouldExposeNullSiteKeyWhenServiceUnavailable() throws Exception { 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 0000000..523639b --- /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 index f024afc..c6f7e08 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaToggleIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaToggleIntegrationTest.java @@ -7,6 +7,7 @@ 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; @@ -41,7 +42,6 @@ import com.digitalsanctuary.spring.user.test.config.OAuth2TestConfiguration; import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; -import jakarta.servlet.http.HttpServletRequest; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -52,7 +52,7 @@ * without a token while the still-protected reset-password action continues to reject. * *

- * The stub {@link CaptchaService} always returns {@code false} from {@link CaptchaService#verify}, + * 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. @@ -87,15 +87,15 @@ static class StubCaptchaConfiguration { CaptchaService stubCaptchaService() { return new CaptchaService() { @Override - public boolean verify(String token, HttpServletRequest request) { + 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 false; + return CaptchaVerification.rejected("stub rejects every token"); } @Override - public String getSiteKey() { - return "stub-site-key"; + public Optional siteKey() { + return Optional.of("stub-site-key"); } }; } diff --git a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java index ae6b652..e2fdaca 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/CaptchaValidationInterceptorTest.java @@ -5,6 +5,8 @@ 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; @@ -15,21 +17,23 @@ 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.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; 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) -@MockitoSettings(strictness = Strictness.LENIENT) @DisplayName("CaptchaValidationInterceptor") class CaptchaValidationInterceptorTest { @@ -42,6 +46,12 @@ class CaptchaValidationInterceptorTest { @Mock private MessageSource messages; + @Mock + private ObjectProvider objectMapperProvider; + + @Mock + private ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper = JsonMapper.builder().build(); private CaptchaConfigProperties properties; @@ -51,10 +61,14 @@ class CaptchaValidationInterceptorTest { void setUp() { properties = new CaptchaConfigProperties(); properties.setEnabled(true); - interceptor = new CaptchaValidationInterceptor(properties, captchaServiceProvider, messages); - when(captchaServiceProvider.getIfAvailable()).thenReturn(captchaService); - when(messages.getMessage(eq("message.captcha.validation-failed"), isNull(), anyString(), any(Locale.class))) - .thenAnswer(invocation -> invocation.getArgument(2)); + 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) { @@ -63,89 +77,322 @@ private MockHttpServletRequest postTo(String 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", - CaptchaValidationInterceptor.REGISTRATION_PATH); - request.setRequestURI(CaptchaValidationInterceptor.REGISTRATION_PATH); + 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(anyString(), any()); + verify(captchaService, never()).verify(any()); } @Test void shouldPassThroughWhenActionToggleDisabled() throws Exception { properties.getProtect().setRegistration(false); - MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.REGISTRATION_PATH); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); assertThat(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())).isTrue(); - verify(captchaService, never()).verify(anyString(), any()); + 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(CaptchaValidationInterceptor.RESET_PASSWORD_PATH); + MockHttpServletRequest request = postTo(CaptchaAction.RESET_PASSWORD.path()); MockHttpServletResponse response = new MockHttpServletResponse(); boolean proceed = interceptor.preHandle(request, response, new Object()); assertThat(proceed).isFalse(); - assertThat(response.getStatus()).isEqualTo(403); - assertThat(response.getContentType()).startsWith("application/json"); + assertRejectedWithCaptchaJson(response); JsonNode body = objectMapper.readTree(response.getContentAsString()); - assertThat(body.get("success").asBoolean()).isFalse(); - assertThat(body.get("code").asInt()).isEqualTo(CaptchaValidationInterceptor.ERROR_CODE_CAPTCHA_FAILED); assertThat(body.get("messages").get(0).asText()).contains("CAPTCHA"); - verify(captchaService, never()).verify(anyString(), any()); + verify(captchaService, never()).verify(any()); } @Test - void shouldRejectWhenTokenInvalid() throws Exception { - MockHttpServletRequest request = postTo(CaptchaValidationInterceptor.RESEND_TOKEN_PATH); + 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(eq("bad-token"), any())).thenReturn(false); + when(captchaService.verify(any())).thenReturn(CaptchaVerification.rejected("invalid")); MockHttpServletResponse response = new MockHttpServletResponse(); assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); - assertThat(response.getStatus()).isEqualTo(403); + 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(CaptchaValidationInterceptor.REGISTRATION_PATH); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); request.addHeader(CaptchaValidationInterceptor.TOKEN_HEADER, "good-token"); - when(captchaService.verify(eq("good-token"), any())).thenReturn(true); + 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(CaptchaValidationInterceptor.REGISTRATION_PATH); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); request.setParameter(CaptchaValidationInterceptor.TOKEN_PARAMETER, "param-token"); - when(captchaService.verify(eq("param-token"), any())).thenReturn(true); + 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(); - verify(captchaService).verify(eq("param-token"), any()); + + 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(CaptchaValidationInterceptor.REGISTRATION_PATH); + 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(); - assertThat(response.getStatus()).isEqualTo(403); + 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(CaptchaValidationInterceptor.REGISTRATION_PATH); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path()); MockHttpServletResponse response = new MockHttpServletResponse(); interceptor.preHandle(request, response, new Object()); @@ -158,12 +405,12 @@ void shouldWriteValidJsonWhenMessageContainsQuotes() throws Exception { 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(CaptchaValidationInterceptor.REGISTRATION_PATH + ";jsessionid=abc"); + MockHttpServletRequest request = postTo(CaptchaAction.REGISTRATION.path() + ";jsessionid=abc"); MockHttpServletResponse response = new MockHttpServletResponse(); assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); - assertThat(response.getStatus()).isEqualTo(403); - verify(captchaService, never()).verify(anyString(), any()); + assertRejectedWithCaptchaJson(response); + verify(captchaService, never()).verify(any()); } @Test @@ -174,19 +421,33 @@ void shouldRejectWhenPathIsPercentEncoded() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); - assertThat(response.getStatus()).isEqualTo(403); - verify(captchaService, never()).verify(anyString(), any()); + 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" + CaptchaValidationInterceptor.REGISTRATION_PATH); + "/app" + CaptchaAction.REGISTRATION.path()); request.setContextPath("/app"); - request.setRequestURI("/app" + CaptchaValidationInterceptor.REGISTRATION_PATH); + request.setRequestURI("/app" + CaptchaAction.REGISTRATION.path()); MockHttpServletResponse response = new MockHttpServletResponse(); assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); - assertThat(response.getStatus()).isEqualTo(403); + 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 0000000..bd0a875 --- /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 0000000..ee5a90e --- /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 index d7a1844..ef62250 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/captcha/TurnstileCaptchaServiceTest.java @@ -1,6 +1,7 @@ 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; @@ -12,59 +13,90 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; 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) -@MockitoSettings(strictness = Strictness.LENIENT) @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); + 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 shouldDelegateToTurnstileWithClientIpWhenServiceAvailable() { + void shouldReportVerifiedWhenTurnstileAcceptsToken() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); - when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")).thenReturn(true); - - boolean result = captchaService.verify("tok-123", request); + when(turnstileValidationService.validateTurnstileResponse("tok-123", CLIENT_IP)).thenReturn(true); - assertThat(result).isTrue(); - verify(turnstileValidationService).validateTurnstileResponse("tok-123", "203.0.113.7"); + 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 shouldFailClosedWhenTurnstileValidationFails() { + void shouldReportRejectedWhenTurnstileRejectsToken() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); - when(turnstileValidationService.validateTurnstileResponse("bad-token", "203.0.113.7")).thenReturn(false); + when(turnstileValidationService.validateTurnstileResponse("bad-token", CLIENT_IP)).thenReturn(false); + + CaptchaVerification result = captchaService.verify(contextFor("bad-token")); - assertThat(captchaService.verify("bad-token", request)).isFalse(); + assertThat(result.isVerified()).isFalse(); + assertThat(result.outcome()).isEqualTo(CaptchaVerification.Outcome.REJECTED); } @Test - void shouldFailClosedWhenTurnstileServiceBeanMissing() { + void shouldReportErrorWhenTurnstileServiceBeanMissing() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); - assertThat(captchaService.verify("tok-123", new MockHttpServletRequest())).isFalse(); + 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 @@ -72,7 +104,7 @@ void shouldExposeSitekeyFromTurnstileService() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); - assertThat(captchaService.getSiteKey()).isEqualTo("real-site-key"); + assertThat(captchaService.siteKey()).contains("real-site-key"); } @Test @@ -87,56 +119,79 @@ void shouldWarnWhenCloudflareTestCredentialsConfigured() { } @Test - void shouldWarnWhenTurnstileServiceBeanMissing() { + void shouldReportErrorWhenTurnstileServiceBeanMissingAtStartup() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); - assertThat(captchaService.configurationWarnings()) - .anySatisfy(warning -> assertThat(warning).contains("fail closed")); + assertThat(captchaService.configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("TurnstileValidationService")); } @Test - void shouldReturnNoWarningsWhenRealCredentialsConfigured() { + void shouldReportErrorWhenSecretMissing() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + TurnstileConfigProperties noSecret = new TurnstileConfigProperties(); + noSecret.setSitekey("real-site-key"); + when(turnstilePropertiesProvider.getIfAvailable()).thenReturn(noSecret); - assertThat(captchaService.configurationWarnings()).isEmpty(); + assertThat(captchaService.configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("secret")); } @Test - void shouldReturnNullSiteKeyWhenTurnstileServiceBeanMissing() { - when(turnstileServiceProvider.getIfAvailable()).thenReturn(null); + void shouldReportErrorWhenSiteKeyMissing() { + when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn(" "); - assertThat(captchaService.getSiteKey()).isNull(); + assertThat(captchaService.configurationErrors()) + .anySatisfy(error -> assertThat(error).contains("site key")); } @Test - void shouldFailClosedWhenGetClientIpAddressThrows() { + 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); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)) - .thenThrow(new RuntimeException("IP extraction failed")); + when(turnstileValidationService.getTurnstileSitekey()).thenReturn("real-site-key"); + when(turnstileValidationService.isUsingTestCredentials()).thenReturn(false); + when(turnstilePropertiesProvider.getIfAvailable()).thenReturn(null); - assertThat(captchaService.verify("tok-123", request)).isFalse(); + assertThat(captchaService.configurationErrors()).isEmpty(); + assertThat(captchaService.configurationWarnings()) + .anySatisfy(warning -> assertThat(warning).contains("could not be verified")); } @Test - void shouldFailClosedWhenValidateTurnstileResponseThrows() { + void shouldReportNoErrorsWhenFullyConfigured() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); - MockHttpServletRequest request = new MockHttpServletRequest(); - when(turnstileValidationService.getClientIpAddress(request)).thenReturn("203.0.113.7"); - when(turnstileValidationService.validateTurnstileResponse("tok-123", "203.0.113.7")) - .thenThrow(new RuntimeException("Validation service unavailable")); + 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.verify("tok-123", request)).isFalse(); + assertThat(captchaService.siteKey()).isEmpty(); } @Test - void shouldReturnNullSiteKeyWhenGetTurnstileSitekeyThrows() { + void shouldReturnEmptySiteKeyWhenGetTurnstileSitekeyThrows() { when(turnstileServiceProvider.getIfAvailable()).thenReturn(turnstileValidationService); when(turnstileValidationService.getTurnstileSitekey()) .thenThrow(new RuntimeException("Could not fetch site key")); - assertThat(captchaService.getSiteKey()).isNull(); + assertThat(captchaService.siteKey()).isEmpty(); } @Test 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 a108b36..fe2f44e 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 9a98b35..bed1535 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -61,4 +61,9 @@ spring.security.oauth2.client.provider.test.user-name-attribute=sub # TurnstileCaptchaFilter when on the classpath. Exclude it so existing tests are # unaffected; captcha tests use a stub CaptchaService or mock the Turnstile # service directly. -spring.autoconfigure.exclude=com.digitalsanctuary.cf.turnstile.TurnstileConfiguration \ No newline at end of file +# +# 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 From 9e776872f247117696f9af9298acfbccf9d01f09 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 11 Aug 2026 22:20:15 -0600 Subject: [PATCH 14/14] docs: note custom PathPatternParser caveat in CAPTCHA scope (#346) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ddf7b8b..724f997 100644 --- a/README.md +++ b/README.md @@ -654,6 +654,7 @@ attribute is not evaluated and Turnstile would receive the literal text. In Thym - **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**