From 888822c7eb35c1f9b9a4bccde67335d2df87f96e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 13:04:32 -0600 Subject: [PATCH 01/55] ci: add Gradle build/test workflow (JDK 17 & 21) and CodeQL --- .github/workflows/build.yml | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4a8d858 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: Build & Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + jdk: [ '17', '21' ] + steps: + - uses: actions/checkout@v4 + - name: Set up JDK ${{ matrix.jdk }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.jdk }} + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Build and test + run: ./gradlew check --no-daemon + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-jdk${{ matrix.jdk }} + path: build/reports/tests/ + + codeql: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: github/codeql-action/init@v3 + with: + languages: java-kotlin + queries: security-extended + - name: Build + run: ./gradlew compileJava --no-daemon + - uses: github/codeql-action/analyze@v3 From 2ce825f314706fd30fc9be745ea205f81f8c73cc Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 13:09:52 -0600 Subject: [PATCH 02/55] test: fix LogoutSuccessServiceTest strict-stubbing failures from Spring Security 7 Referer read --- .../user/service/LogoutSuccessServiceTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java index 030ca25..0721b91 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java @@ -239,7 +239,7 @@ class IPAddressExtractionTests { void shouldExtractIpFromXForwardedForHeader() throws IOException, ServletException { // Given String forwardedIp = "203.0.113.195"; - when(request.getHeader("X-Forwarded-For")).thenReturn(forwardedIp); + lenient().when(request.getHeader("X-Forwarded-For")).thenReturn(forwardedIp); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); @@ -259,8 +259,8 @@ void shouldExtractIpFromXForwardedForHeader() throws IOException, ServletExcepti void shouldExtractIpFromXRealIpHeader() throws IOException, ServletException { // Given String realIp = "198.51.100.178"; - when(request.getHeader("X-Forwarded-For")).thenReturn(null); - when(request.getHeader("X-Real-IP")).thenReturn(realIp); + lenient().when(request.getHeader("X-Forwarded-For")).thenReturn(null); + lenient().when(request.getHeader("X-Real-IP")).thenReturn(realIp); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); @@ -280,9 +280,9 @@ void shouldExtractIpFromXRealIpHeader() throws IOException, ServletException { void shouldFallBackToRemoteAddress() throws IOException, ServletException { // Given String remoteAddr = "192.0.2.146"; - when(request.getHeader("X-Forwarded-For")).thenReturn(null); - when(request.getHeader("X-Real-IP")).thenReturn(null); - when(request.getHeader("CF-Connecting-IP")).thenReturn(null); + lenient().when(request.getHeader("X-Forwarded-For")).thenReturn(null); + lenient().when(request.getHeader("X-Real-IP")).thenReturn(null); + lenient().when(request.getHeader("CF-Connecting-IP")).thenReturn(null); when(request.getRemoteAddr()).thenReturn(remoteAddr); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); @@ -331,7 +331,7 @@ void shouldCreateAuditEventWithAllUserDetails() throws IOException, ServletExcep @DisplayName("Should handle null user agent gracefully") void shouldHandleNullUserAgentGracefully() throws IOException, ServletException { // Given - when(request.getHeader("User-Agent")).thenReturn(null); + lenient().when(request.getHeader("User-Agent")).thenReturn(null); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); From 9a9d35a401b76e10724be8ad9972c31a7b546612 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 13:16:07 -0600 Subject: [PATCH 03/55] fix(security): wire SessionRegistry into filter chain so session invalidation works (H1) --- .../user/security/WebSecurityConfig.java | 8 ++- .../SessionInvalidationIntegrationTest.java | 64 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 5f5b52a..36a30d6 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -143,7 +143,7 @@ public class WebSecurityConfig { * @throws Exception if there is an issue creating the SecurityFilterChain */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { log.debug("WebSecurityConfig.configure: user.security.defaultAction: {}", getDefaultAction()); log.debug("WebSecurityConfig.configure: unprotectedURIs: {}", Arrays.toString(getUnprotectedURIsArray())); List unprotectedURIs = getUnprotectedURIsList(); @@ -163,6 +163,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.logout(logout -> logout.logoutUrl(logoutActionURI).logoutSuccessUrl(logoutSuccessURI).invalidateHttpSession(true) .deleteCookies("JSESSIONID")); + // Register sessions in the SessionRegistry so SessionInvalidationService and concurrent-session + // features actually work. maximumSessions(-1) = unlimited concurrent sessions, but still tracked + // in the registry. The SessionRegistry is injected (rather than calling the local bean method) so + // consumers and tests can override it via a @Primary / @ConditionalOnMissingBean bean. + http.sessionManagement(session -> session.maximumSessions(-1).sessionRegistry(sessionRegistry)); + // If we have URIs to disable CSRF validation on, do so here String[] baseDisableCSRFURIs = getDisableCSRFURIsArray(); List csrfIgnoreList = new ArrayList<>(Arrays.asList(baseDisableCSRFURIs)); diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java new file mode 100644 index 0000000..1564600 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java @@ -0,0 +1,64 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; + +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.test.web.servlet.MockMvc; + +@SecurityTest +@Import(SessionInvalidationIntegrationTest.RealAuthenticationProviderConfig.class) +class SessionInvalidationIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + SessionRegistry sessionRegistry; + + @Test + void registryIsPopulatedAfterLogin() throws Exception { + // SecurityTestConfiguration provides user@test.com / "password" + var result = mockMvc.perform(formLogin("/user/login").user("username", "user@test.com").password("password")) + .andExpect(authenticated()) + .andReturn(); + HttpSession session = result.getRequest().getSession(false); + assertThat(session).isNotNull(); + + // The registry must now know about at least one principal/session + assertThat(sessionRegistry.getAllPrincipals()).isNotEmpty(); + } + + /** + * Provides a real {@link DaoAuthenticationProvider} so that form login with a + * {@code UsernamePasswordAuthenticationToken} can succeed in the security test slice. The default + * {@code SecurityTestConfiguration} only registers a {@code TestingAuthenticationProvider}, which does not + * support username/password tokens. The delegating password encoder understands the {@code {bcrypt}} prefix + * used by the pre-built test users. + */ + @TestConfiguration + static class RealAuthenticationProviderConfig { + + @Bean + @Primary + AuthenticationManager testFormLoginAuthenticationManager(UserDetailsService userDetailsService) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + return new ProviderManager(provider); + } + } +} From d8acd43b54906778d510f9e50d05cdf3725ad17c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 13:23:45 -0600 Subject: [PATCH 04/55] fix(security): invalidate sessions on password change/reset --- .../spring/user/service/UserService.java | 2 ++ .../spring/user/service/UserServiceTest.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 18867dd..4679509 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -464,6 +464,8 @@ public void changeUserPassword(final User user, final String password) { user.setPassword(encodedPassword); userRepository.save(user); savePasswordHistory(user, encodedPassword); + // Terminate all existing sessions so a reset/change forces re-auth everywhere (OWASP). + sessionInvalidationService.invalidateUserSessions(user); } /** diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index 540e153..43b30e0 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -189,6 +189,20 @@ void changeUserPassword_encodesAndSavesNewPassword() { verify(userRepository).save(testUser); } + @Test + void changeUserPassword_invalidatesExistingSessions() { + // Given + String newPassword = "newTestPassword"; + when(passwordEncoder.encode(newPassword)).thenReturn("encodedNewPassword"); + when(userRepository.save(any(User.class))).thenReturn(testUser); + + // When + userService.changeUserPassword(testUser, newPassword); + + // Then + verify(sessionInvalidationService).invalidateUserSessions(testUser); + } + // Additional tests for comprehensive coverage @Test @DisplayName("saveRegisteredUser - saves and returns user") From 92ad1cc72734a5728388351f41d2f229f168ccc6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 13:37:19 -0600 Subject: [PATCH 05/55] fix(security): enforce enabled/locked account status on OAuth2/OIDC/WebAuthn login (H3) --- .../user/service/LoginHelperService.java | 29 +++++ .../user/service/LoginHelperServiceTest.java | 119 ++++++++++++++---- 2 files changed, 125 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java index c85c809..bf939cf 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java @@ -3,6 +3,8 @@ import java.util.Collection; import java.util.Date; import java.util.Map; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; @@ -60,6 +62,9 @@ public DSUserDetails userLoginHelper(User dbUser, Map attributes // Check if the user account is locked, but should be unlocked now, and unlock it dbUser = loginAttemptService.checkIfUserShouldBeUnlocked(dbUser); + // Enforce account status for all authentication paths (form, OAuth2, OIDC, WebAuthn) + assertAccountUsable(dbUser); + Collection authorities = authorityService.getAuthoritiesFromUser(dbUser); return new DSUserDetails(dbUser, authorities, attributes); } @@ -96,7 +101,31 @@ public DSUserDetails userLoginHelper(User dbUser, OidcUserInfo oidcUserInfo, Oid // Check if the user account is locked, but should be unlocked now, and unlock it dbUser = loginAttemptService.checkIfUserShouldBeUnlocked(dbUser); + // Enforce account status for all authentication paths (form, OAuth2, OIDC, WebAuthn) + assertAccountUsable(dbUser); + Collection authorities = authorityService.getAuthoritiesFromUser(dbUser); return new DSUserDetails(dbUser, oidcUserInfo, oidcIdToken, authorities, attributes); } + + /** + * Verifies that the given user account is in a usable state for authentication. This enforces account status + * ({@code locked}/{@code enabled}) for every authentication path that flows through this helper, including + * OAuth2, OIDC, and WebAuthn (which load the user via {@link DSUserDetailsService}). Locked status is checked + * before disabled status so a locked account surfaces a {@link LockedException} even if it is also disabled. + * + * @param user the user to validate (after any auto-unlock has been applied) + * @throws LockedException if the account is locked + * @throws DisabledException if the account is disabled + */ + private void assertAccountUsable(User user) { + if (user.isLocked()) { + log.debug("Rejecting authentication for locked account: {}", user.getEmail()); + throw new LockedException("Account is locked: " + user.getEmail()); + } + if (!user.isEnabled()) { + log.debug("Rejecting authentication for disabled account: {}", user.getEmail()); + throw new DisabledException("Account is disabled: " + user.getEmail()); + } + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java index 73de706..6606068 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java @@ -1,8 +1,11 @@ package com.digitalsanctuary.spring.user.service; 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.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; @@ -21,6 +24,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -121,6 +126,7 @@ void shouldCheckUserUnlockStatus() { unlockedUser.setEmail(testUser.getEmail()); unlockedUser.setLocked(false); unlockedUser.setLockedDate(null); + unlockedUser.setEnabled(true); when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(unlockedUser); doReturn(testAuthorities).when(authorityService).getAuthoritiesFromUser(unlockedUser); @@ -170,8 +176,8 @@ void shouldHandleUserWithNoAuthorities() { } @Test - @DisplayName("Should handle locked user that remains locked") - void shouldHandleLockedUserThatRemainsLocked() { + @DisplayName("Should reject locked user that remains locked") + void shouldRejectLockedUserThatRemainsLocked() { // Given testUser.setLocked(true); testUser.setFailedLoginAttempts(5); @@ -179,35 +185,20 @@ void shouldHandleLockedUserThatRemainsLocked() { testUser.setLockedDate(lockedDate); when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(testUser); // User remains locked - doReturn(testAuthorities).when(authorityService).getAuthoritiesFromUser(testUser); - - // When - DSUserDetails result = loginHelperService.userLoginHelper(testUser); - // Then - assertThat(result).isNotNull(); - assertThat(result.getUser().isLocked()).isTrue(); - assertThat(result.isAccountNonLocked()).isFalse(); - assertThat(result.getUser().getFailedLoginAttempts()).isEqualTo(5); + // When / Then - a still-locked account must not be allowed to authenticate + assertThatThrownBy(() -> loginHelperService.userLoginHelper(testUser)).isInstanceOf(LockedException.class); } @Test - @DisplayName("Should handle disabled user correctly") - void shouldHandleDisabledUser() { + @DisplayName("Should reject disabled user") + void shouldRejectDisabledUser() { // Given testUser.setEnabled(false); when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(testUser); - doReturn(testAuthorities).when(authorityService).getAuthoritiesFromUser(testUser); - // When - DSUserDetails result = loginHelperService.userLoginHelper(testUser); - - // Then - assertThat(result).isNotNull(); - assertThat(result.isEnabled()).isFalse(); - assertThat(result.getUser().isEnabled()).isFalse(); - // Even disabled users should get their authorities - assertThat(result.getAuthorities()).isNotEmpty(); + // When / Then - a disabled account must not be allowed to authenticate + assertThatThrownBy(() -> loginHelperService.userLoginHelper(testUser)).isInstanceOf(DisabledException.class); } @Test @@ -540,4 +531,86 @@ void shouldFallBackToIdTokenClaimsWhenOidcAttributesNull() { assertThat(result.getAttributes()).containsEntry("email", "oidc@example.com"); } } + + @Nested + @DisplayName("Account Status Enforcement Tests (H3)") + class AccountStatusEnforcementTests { + + @Test + @DisplayName("Should reject disabled account on OAuth2 login") + void rejectsDisabledAccountOnOAuthLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(false); + user.setLocked(false); + lenient().when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, (Map) null)).isInstanceOf(DisabledException.class); + } + + @Test + @DisplayName("Should reject locked account on OAuth2 login") + void rejectsLockedAccountOnOAuthLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(true); + user.setLocked(true); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, (Map) null)).isInstanceOf(LockedException.class); + } + + @Test + @DisplayName("Should reject disabled account on OIDC login") + void rejectsDisabledAccountOnOidcLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(false); + user.setLocked(false); + OidcIdToken idToken = new OidcIdToken("token", Instant.now(), Instant.now().plusSeconds(3600), Map.of("sub", "s")); + OidcUserInfo userInfo = new OidcUserInfo(Map.of("sub", "s")); + lenient().when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, userInfo, idToken)).isInstanceOf(DisabledException.class); + } + + @Test + @DisplayName("Should reject locked account on OIDC login") + void rejectsLockedAccountOnOidcLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(true); + user.setLocked(true); + OidcIdToken idToken = new OidcIdToken("token", Instant.now(), Instant.now().plusSeconds(3600), Map.of("sub", "s")); + OidcUserInfo userInfo = new OidcUserInfo(Map.of("sub", "s")); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, userInfo, idToken)).isInstanceOf(LockedException.class); + } + + @Test + @DisplayName("Should prefer LockedException when both locked and disabled") + void prefersLockedExceptionWhenLockedAndDisabled() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(false); + user.setLocked(true); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, (Map) null)).isInstanceOf(LockedException.class); + } + + @Test + @DisplayName("Should allow enabled and unlocked account") + void allowsEnabledUnlockedAccount() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(true); + user.setLocked(false); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + when(authorityService.getAuthoritiesFromUser(any(User.class))).thenReturn(Collections.emptyList()); + + assertThatCode(() -> loginHelperService.userLoginHelper(user, (Map) null)).doesNotThrowAnyException(); + } + } } From f031fe8372c1c6988a60498464e77a79a97b01a4 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 13:49:52 -0600 Subject: [PATCH 06/55] fix(security): enable Spring Security factor merging so MFA login works (H4) --- .../user/security/MfaConfiguration.java | 78 +++++++++- .../security/MfaLoginIntegrationTest.java | 135 ++++++++++++++++++ 2 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java index bbf18f9..e226770 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -14,6 +15,7 @@ import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; import org.springframework.security.authorization.RequiredFactor; import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,13 +23,43 @@ * Configuration that registers {@link MfaConfigProperties} and provides MFA-related beans. *

* This configuration is always active because {@code WebSecurityConfig} requires {@link MfaConfigProperties} regardless - * of whether MFA is enabled. The {@code DefaultAuthorizationManagerFactory} bean is only created when MFA is enabled. + * of whether MFA is enabled. The MFA beans are only created when MFA is enabled. + *

+ * + *

Spike conclusion (Spring Security 7.0.5 factor merging, H4)

+ *

+ * Multi-factor login has two distinct sides in SS7 and this framework was only wiring one of them: + *

+ *
    + *
  1. Enforcement — the {@link DefaultAuthorizationManagerFactory} produced by + * {@link #mfaAuthorizationManagerFactory()} sets an {@link AllRequiredFactorsAuthorizationManager} (AND semantics) so + * {@code .authenticated()} additionally requires every configured factor authority. This was already present + * and correct, scoped to the property-driven subset (so a {@code PASSWORD}-only deployment is not locked out).
  2. + *
  3. Merging — the missing half. SS7 merges factor authorities across login steps inside + * {@code org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter}: when its + * {@code mfaEnabled} flag is {@code true} and an already-authenticated context exists for the same principal, the new + * authentication result is rebuilt via {@code authenticationResult.toBuilder().authorities(...)} to additively carry the + * authorities of the existing authentication. That merged result is then passed through {@code successfulAuthentication} + * to the success handler. Without {@code mfaEnabled=true}, completing a second factor REPLACES the authentication + * (dropping the first factor) and the user can never satisfy "all required factors" — the H4 lockout.
  4. + *
+ *

+ * The {@code mfaEnabled} flag is normally flipped by {@code @EnableMultiFactorAuthentication}, whose + * {@code MultiFactorAuthenticationSelector} imports {@code AuthorizationManagerFactoryConfiguration} (a second, static + * {@link DefaultAuthorizationManagerFactory} bean built from the annotation's STATIC {@code authorities()} superset) and + * {@code EnableMfaFiltersConfiguration} (an {@code EnableMfaFiltersPostProcessor} that calls + * {@code setMfaEnabled(true)} on every authentication processing filter). We deliberately do NOT use the annotation here + * for two reasons: (a) its {@code authorities()} is a static superset, but AllRequiredFactors is AND-enforcement, so a + * superset would lock out subset deployments (e.g. {@code PASSWORD}-only); and (b) its factory bean is registered + * by-type with no {@code @ConditionalOnMissingBean}, which would collide with our property-driven factory — + * {@code AuthorizeHttpRequestsConfigurer} resolves the factory via {@code getBeanProvider(...).getIfAvailable()}, which + * returns {@code null} on ambiguity and silently falls back to a non-enforcing default, disabling factor enforcement. *

*

- * When enabled, the {@code DefaultAuthorizationManagerFactory} is configured with an - * {@link AllRequiredFactorsAuthorizationManager} that makes {@code .authenticated()} in - * {@code authorizeHttpRequests} additionally require all configured factor authorities. Spring Security 7's built-in - * infrastructure handles enforcement and session management automatically. + * Therefore we keep our single property-driven enforcement factory and activate ONLY the merging side using public + * SS7 API: {@link #mfaFilterMergingPostProcessor()} replicates {@code EnableMfaFiltersPostProcessor} by invoking the + * public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on the form-login and WebAuthn + * processing filters. This is gated on {@code user.mfa.enabled=true}, leaving the default (no-MFA) path untouched. *

* * @see MfaConfigProperties @@ -83,6 +115,42 @@ public DefaultAuthorizationManagerFactory mfaAuthorizationManagerFactory return factory; } + /** + * Activates Spring Security 7 factor merging by enabling MFA mode on every authentication processing filter. + *

+ * This replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal + * {@code EnableMfaFiltersPostProcessor} using only public API. When MFA mode is enabled, + * {@link AbstractAuthenticationProcessingFilter} merges the factor authorities of the existing authentication onto + * the authentication produced by a subsequent login step (for the same principal) instead of replacing it. Without + * this, completing a second factor would drop the first factor's authority and the user could never satisfy all + * required factors (H4 lockout). + *

+ *

+ * The bean is only created when MFA is enabled, so the default (no-MFA) login path is completely unaffected. + *

+ * + * @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters + */ + @Bean + @ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false) + public static BeanPostProcessor mfaFilterMergingPostProcessor() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + // Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every + // authentication mechanism this framework configures: formLogin, webAuthn, and oauth2Login + // all extend it. SS's internal EnableMfaFiltersPostProcessor additionally flips the flag on + // AuthenticationFilter, BasicAuthenticationFilter, and pre-authentication filters; this + // framework does not configure those mechanisms, so they are deliberately not targeted here. + if (bean instanceof AbstractAuthenticationProcessingFilter filter) { + filter.setMfaEnabled(true); + log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName()); + } + return bean; + } + }; + } + /** * Validates MFA configuration on application startup. Performs validation only when MFA is enabled; returns * immediately otherwise. diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java new file mode 100644 index 0000000..0e3e53e --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java @@ -0,0 +1,135 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; +import jakarta.servlet.Filter; + +/** + * Integration test proving that multi-factor login is functional: factor authorities from successive login steps must + * MERGE into a single {@link Authentication} so a user who completes every required factor can actually reach protected + * resources. + *

+ * Spring Security 7's factor merging is performed inside {@code AbstractAuthenticationProcessingFilter.doFilter} ONLY + * when {@code mfaEnabled} has been set to {@code true} on the authentication processing filters. That flag is normally + * flipped by {@code @EnableMultiFactorAuthentication}'s {@code EnableMfaFiltersPostProcessor}. Before the H4 fix, this + * framework configured the enforcement side ({@code AllRequiredFactorsAuthorizationManager}) but never activated the + * merging filters, so completing a second factor REPLACED the authentication (losing the first factor) and the user was + * permanently locked out. + *

+ *

+ * The two tests cover different halves of the fix: + *

+ *
    + *
  • {@link #authenticationProcessingFiltersHaveMfaMergingEnabled()} is what proves the merging side. It + * reflectively asserts {@code mfaEnabled == true} on the authentication processing filters; before the fix the + * form-login and WebAuthn filters have {@code mfaEnabled == false}, so cross-step factor merging never happens.
  • + *
  • {@link #bothFactorsGrantAccessWhileSingleFactorIsDenied()} injects a single, already PRE-MERGED authentication + * (both factor authorities in one token) rather than completing two login steps. It therefore exercises the + * enforcement side — that {@code .authenticated()} requires all configured factors and that an + * authentication carrying every factor is granted — and acts as a regression guard against introducing a second + * {@code AuthorizationManagerFactory} bean that would make Spring Security's by-type lookup ambiguous and silently + * disable factor enforcement. It does NOT exercise the cross-step merging itself; that is the reflective test's job.
  • + *
+ */ +@SecurityTest +@TestPropertySource(properties = {"user.mfa.enabled=true", "user.mfa.factors=PASSWORD,WEBAUTHN", "user.webauthn.enabled=true"}) +@DisplayName("MFA Login Integration Tests (H4)") +class MfaLoginIntegrationTest { + + /** A request path that requires authentication under the test profile's {@code defaultAction=deny}. */ + private static final String PROTECTED_URI = "/protected.html"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private FilterChainProxy filterChainProxy; + + @Test + @DisplayName("authentication processing filters have MFA factor-merging enabled when MFA is configured") + void authenticationProcessingFiltersHaveMfaMergingEnabled() { + List processingFilters = findAuthenticationProcessingFilters(); + + assertThat(processingFilters) + .as("the security filter chain must contain authentication processing filters (e.g. form login, WebAuthn)") + .isNotEmpty(); + + // The actual H4 gap: every authentication processing filter must have mfaEnabled=true, otherwise completing a + // second factor replaces the existing authentication instead of merging factor authorities onto it. + assertThat(processingFilters) + .as("every authentication processing filter must have mfaEnabled=true so factor authorities merge across login steps") + .allSatisfy(filter -> assertThat((Boolean) ReflectionTestUtils.getField(filter, "mfaEnabled")) + .as("mfaEnabled on %s", filter.getClass().getSimpleName()) + .isTrue()); + } + + @Test + @DisplayName("both factor authorities grant access while a single factor is denied") + void bothFactorsGrantAccessWhileSingleFactorIsDenied() throws Exception { + // Step 1: only the PASSWORD factor present -> the WEBAUTHN factor is still required, so access is denied. + Authentication passwordOnly = authenticationWithFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY); + mockMvc.perform(get(PROTECTED_URI).with(authentication(passwordOnly))) + .andExpect(result -> assertThat(result.getResponse().getStatus()) + .as("password-only authentication must NOT be granted access to a protected resource") + .isNotEqualTo(200)); + + // Step 2: both factor authorities present in ONE authentication -> all required factors satisfied -> access granted. + Authentication bothFactors = authenticationWithFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY, + FactorGrantedAuthority.WEBAUTHN_AUTHORITY); + mockMvc.perform(get(PROTECTED_URI).with(authentication(bothFactors))) + .andExpect(result -> assertThat(result.getResponse().getStatus()) + .as("authentication carrying BOTH factor authorities must be granted access (no 401/403/redirect)") + .isNotIn(401, 403, 302)); + } + + /** + * Builds an authenticated token for {@code user@test.com} carrying {@code ROLE_USER} plus the supplied factor + * authorities. + * + * @param factorAuthorities the {@link FactorGrantedAuthority} authority strings to attach + * @return an authenticated {@link Authentication} + */ + private Authentication authenticationWithFactors(String... factorAuthorities) { + List authorities = new java.util.ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + for (String factor : factorAuthorities) { + authorities.add(FactorGrantedAuthority.fromAuthority(factor)); + } + return UsernamePasswordAuthenticationToken.authenticated("user@test.com", null, authorities); + } + + /** + * Walks every {@link SecurityFilterChain} and collects the {@link AbstractAuthenticationProcessingFilter} instances + * (form login, WebAuthn, etc.) that participate in MFA factor merging. + * + * @return the authentication processing filters present in the configured filter chains + */ + private List findAuthenticationProcessingFilters() { + List result = new java.util.ArrayList<>(); + for (SecurityFilterChain chain : filterChainProxy.getFilterChains()) { + for (Filter filter : chain.getFilters()) { + if (filter instanceof AbstractAuthenticationProcessingFilter processingFilter) { + result.add(processingFilter); + } + } + } + return result; + } +} From 47cfda9e43af32e404fd6060847cf9179386c4eb Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 14:18:55 -0600 Subject: [PATCH 07/55] fix(arch): make JPA auditing conditional so it doesn't hijack consumer auditing (H5) --- CONFIG.md | 4 + .../spring/user/util/JpaAuditingConfig.java | 17 +++ ...itional-spring-configuration-metadata.json | 6 ++ .../config/dsspringuserconfig.properties | 5 + .../user/util/JpaAuditingConfigTest.java | 101 ++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java diff --git a/CONFIG.md b/CONFIG.md index 08bfe4a..94c58e4 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -40,6 +40,10 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl - **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing. Defaults to `false` for performance. - **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. Prevents memory issues with large logs. Defaults to `10000`. +## JPA Auditing + +- **Enable JPA Auditing (`user.jpa.auditing.enabled`)**: Controls whether the library enables Spring Data JPA auditing (`@EnableJpaAuditing`) and registers an `AuditorAware` that captures the current user from the Spring Security context for `@CreatedBy`/`@LastModifiedBy` fields. Defaults to `true`. Set to `false` if your application runs its own JPA auditing or supplies its own `AuditorAware` bean, so the library does not hijack it. This property is the primary opt-out, because the library's `@EnableJpaAuditing` resolves the auditor bean by name (`auditorProvider`). + ## GDPR Compliance GDPR features are disabled by default and must be explicitly enabled. diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java b/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java index af0663f..05fbaa6 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.util; import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; @@ -19,9 +20,25 @@ * {@code @CreatedBy} and {@code @LastModifiedBy} annotations to automatically track * which user created or modified them. *

+ *

+ * Consumer opt-out (H5): This configuration is gated by the {@code user.jpa.auditing.enabled} property + * (default {@code true}). A consuming application that runs its own JPA auditing, or that supplies its own + * {@link AuditorAware}, can disable the library's auditing entirely by setting {@code user.jpa.auditing.enabled=false}. + * Disabling the whole configuration is the single, reliable opt-out: because + * {@code @EnableJpaAuditing(auditorAwareRef = "auditorProvider")} resolves the auditor bean strictly by name, + * a consumer with their own auditing must disable this configuration via the property so the library's name-bound + * {@code @EnableJpaAuditing} (and its {@code "auditorProvider"} bean) are not registered at all. + *

+ *

+ * A {@code @ConditionalOnMissingBean} on {@code auditorProvider} was intentionally not used. In this + * component-scanned (non-auto-configuration) context it does not reliably defer to a consumer bean, and combined with + * the {@code auditorAwareRef = "auditorProvider"} name binding it can leave {@code @EnableJpaAuditing} searching for a + * suppressed bean. The class-level property gate is the supported mechanism. + *

*/ @Slf4j @Configuration +@ConditionalOnProperty(name = "user.jpa.auditing.enabled", havingValue = "true", matchIfMissing = true) @EnableJpaAuditing(auditorAwareRef = "auditorProvider") public class JpaAuditingConfig { diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 2fe5c1b..240a4c4 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -1,5 +1,11 @@ { "properties": [ + { + "name": "user.jpa.auditing.enabled", + "type": "java.lang.Boolean", + "description": "Enable the library's JPA auditing (@EnableJpaAuditing) and its AuditorAware. Set to false if the consuming application runs its own JPA auditing or supplies its own AuditorAware.", + "defaultValue": true + }, { "name": "user.registration.facebookEnabled", "type": "java.lang.Boolean", diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index d033767..8d5e0f1 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -32,6 +32,11 @@ user.audit.logEvents=true # Set to 0 or negative to disable the limit (not recommended for production). user.audit.maxQueryResults=10000 +# If true, the library enables JPA auditing (@EnableJpaAuditing) and registers an AuditorAware that captures the +# current user from the Spring Security context for @CreatedBy/@LastModifiedBy fields. Set this to false if your +# application runs its own JPA auditing or supplies its own AuditorAware, so the library does not hijack it. Default true. +user.jpa.auditing.enabled=true + # If true, users can delete their own accounts. If false, accounts are disabled instead of deleted. user.actuallyDeleteAccount=false diff --git a/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java b/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java new file mode 100644 index 0000000..b8b2921 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java @@ -0,0 +1,101 @@ +package com.digitalsanctuary.spring.user.util; + +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.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.data.domain.AuditorAware; + +/** + * Tests for the conditional gating of JPA auditing (H5). + *

+ * The library's {@link JpaAuditingConfig} is gated by {@code user.jpa.auditing.enabled} (default {@code true}). When the + * property is {@code false}, the whole configuration — including {@code @EnableJpaAuditing} and the + * {@code auditorProvider} {@link AuditorAware} bean — is skipped, so a consuming application can run its own JPA + * auditing without the library hijacking it. + *

+ *

+ * Why the "enabled" cases use a stand-in: {@link JpaAuditingConfig} carries {@code @EnableJpaAuditing}, + * which eagerly initializes a {@code JpaMetamodelMappingContext}. Bootstrapping that machinery inside a unit-slice + * context (and supplying a mock {@code EntityManagerFactory} to satisfy it) pollutes the JPA metamodel shared by this + * module's parallel integration-test contexts, causing unrelated "domain class can not be found in the given Metamodel" + * failures. So the "enabled" assertions use {@link GatedConfiguration}, a stand-in that mirrors + * {@link JpaAuditingConfig}'s exact class-level {@code @ConditionalOnProperty} but omits {@code @EnableJpaAuditing}. + * The "disabled" case is asserted against the real {@link JpaAuditingConfig}, which is safe because the gate skips the + * auditing machinery entirely. The full {@code @EnableJpaAuditing} wiring (and that auditing works by default) is + * exercised by the integration tests running against the real {@code TestApplication} context. + *

+ * + * @see JpaAuditingConfig + */ +@DisplayName("JpaAuditingConfig Conditional Gating Tests") +class JpaAuditingConfigTest { + + /** Runner over the REAL config — used only for the disabled case, where no auditing machinery initializes. */ + private final ApplicationContextRunner realConfigRunner = + new ApplicationContextRunner().withUserConfiguration(JpaAuditingConfig.class); + + /** Runner over the stand-in — used for the enabled cases to avoid bootstrapping the JPA metamodel. */ + private final ApplicationContextRunner gatedRunner = + new ApplicationContextRunner().withUserConfiguration(GatedConfiguration.class); + + /** + * Mirrors {@link JpaAuditingConfig}'s class-level gate exactly, exposing a marker bean that reflects whether the + * gated configuration is active. Deliberately omits {@code @EnableJpaAuditing} so no JPA metamodel is initialized. + */ + @Configuration + @ConditionalOnProperty(name = "user.jpa.auditing.enabled", havingValue = "true", matchIfMissing = true) + static class GatedConfiguration { + @Bean + String auditingGateMarker() { + return "active"; + } + } + + @Test + @DisplayName("Should NOT register auditorProvider / AuditorAware when user.jpa.auditing.enabled=false") + void shouldNotRegisterAuditorProviderWhenDisabled() { + realConfigRunner.withPropertyValues("user.jpa.auditing.enabled=false").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("auditorProvider")).isFalse(); + assertThat(context).doesNotHaveBean(AuditorAware.class); + }); + } + + @Test + @DisplayName("Should be ENABLED by default when the property is absent (backward compatible)") + void shouldEnableByDefaultWhenPropertyAbsent() { + gatedRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("auditingGateMarker")).isTrue(); + }); + } + + @Test + @DisplayName("Should be ENABLED when user.jpa.auditing.enabled=true") + void shouldEnableWhenPropertyTrue() { + gatedRunner.withPropertyValues("user.jpa.auditing.enabled=true").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("auditingGateMarker")).isTrue(); + }); + } + + @Test + @DisplayName("Should carry the exact @ConditionalOnProperty gate on the real JpaAuditingConfig class") + void shouldCarryExactConditionalOnPropertyGateOnRealConfig() { + // Pure reflection on the class annotation — no Spring context boot, so no @EnableJpaAuditing + // metamodel initialization and zero metamodel-pollution risk. Fails fast if the gate is removed or weakened. + var attributes = + AnnotatedElementUtils.findMergedAnnotationAttributes(JpaAuditingConfig.class, ConditionalOnProperty.class, false, false); + + assertThat(attributes).as("@ConditionalOnProperty must be present on JpaAuditingConfig").isNotNull(); + assertThat(attributes.getStringArray("name")).containsExactly("user.jpa.auditing.enabled"); + assertThat(attributes.getString("havingValue")).isEqualTo("true"); + assertThat(attributes.getBoolean("matchIfMissing")).isTrue(); + } +} From a02faadf74640f338f0ca9d2c29b441c940706f5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 14:39:14 -0600 Subject: [PATCH 08/55] fix(arch): make library SecurityFilterChain ordered + conditional, fix migration guidance (H6) --- MIGRATION.md | 25 +++- .../user/security/WebSecurityConfig.java | 13 +- ...bSecurityFilterChainAutoConfiguration.java | 71 ++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../WebSecurityConfigCompositionTest.java | 131 ++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java diff --git a/MIGRATION.md b/MIGRATION.md index e8cc4ed..a492cac 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -266,7 +266,19 @@ If you have a custom `WebSecurityConfig` or extend the framework's security conf 2. **Update to lambda DSL style** (required in Spring Security 7) 3. **Review method security annotations** - `@PreAuthorize`, `@PostAuthorize` unchanged -**Example custom security configuration:** +#### SecurityFilterChain override model (4.x) + +The library now contributes its `SecurityFilterChain` through a dedicated auto-configuration with two important properties: + +- **Ordered at low precedence.** The library's chain is registered with `@Order(Ordered.LOWEST_PRECEDENCE - 5)` — the same low precedence Spring Boot uses for its own default servlet security chain. This value is sourced from `SecurityFilterProperties.BASIC_AUTH_ORDER`; the constant was `SecurityProperties.BASIC_AUTH_ORDER` in Spring Boot 3.x and was relocated to `SecurityFilterProperties.BASIC_AUTH_ORDER` in Spring Boot 4.0 (still `Ordered.LOWEST_PRECEDENCE - 5`). This means any consumer-supplied chain with a lower (higher-precedence) `@Order` is consulted first by Spring Security's `FilterChainProxy`. +- **Backs off entirely if you define your own.** The library's chain is annotated `@ConditionalOnMissingBean(SecurityFilterChain.class)`. If your application defines **any** `SecurityFilterChain` bean, the library's chain is suppressed completely. + +This gives you two ways to customize security: + +**Option A — Replace the library's chain (you own all the rules).** + +Define your own `SecurityFilterChain`. Because of `@ConditionalOnMissingBean`, the library's chain backs off entirely and **does not** apply any of its rules. You are now responsible for protecting *all* URIs, including the framework's endpoints (login, registration, password reset, profile, etc.). Use this when you want full control. + ```java @Configuration @EnableWebSecurity @@ -279,6 +291,9 @@ public class CustomSecurityConfig { // All patterns must start with / .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") + // You must also permit/secure the framework's own URIs here, + // since the library chain no longer applies: + .requestMatchers("/user/registration", "/user/login", "/user/resetPassword").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form @@ -290,6 +305,14 @@ public class CustomSecurityConfig { } ``` +**Option B — Layer your own higher-precedence chain in front of the library's.** + +Define your own `SecurityFilterChain` with a `securityMatcher` scoping it to a subset of requests and a higher-precedence (lower) `@Order`. Spring Security evaluates chains in order and uses the **first** chain whose matcher matches. Requests that don't match your chain fall through to the library's chain. + +> Note: As soon as you define *any* `SecurityFilterChain` bean, the library's `@ConditionalOnMissingBean` causes its chain to back off. So Option B does **not** keep the library's chain active automatically — if you want both your chain and the library's behavior, you currently need to reproduce the rules you care about in your own chain(s). The `@Order` mechanism is what lets multiple consumer-defined chains coexist with predictable precedence. + +For most applications that only need to *add* a few rules, the simplest path is to rely on the library's chain and the `user.security.*` properties (`protectedURIs`, `unprotectedURIs`, `defaultAction`, etc.) rather than defining your own `SecurityFilterChain`. + ### Custom User Services If you extend `UserService` or implement custom user management: diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 36a30d6..91735f9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -135,15 +135,20 @@ public class WebSecurityConfig { private final Environment environment; /** - * - * The securityFilterChain method builds the security filter chain for Spring Security. + * Builds the library's security filter chain for Spring Security. + *

+ * This method is invoked by {@link WebSecurityFilterChainAutoConfiguration}, which exposes the result as a {@link SecurityFilterChain} bean at a + * low precedence and backs off entirely when the consuming application defines its own {@link SecurityFilterChain}. It is intentionally NOT a + * {@code @Bean} method here: {@code @ConditionalOnMissingBean} is only reliable on auto-configuration classes (which load after user-defined + * beans), so the conditional/ordering lives on the auto-configuration class rather than on this component-scanned {@code @Configuration}. + *

* * @param http the HttpSecurity object + * @param sessionRegistry the SessionRegistry used to track active sessions * @return the SecurityFilterChain object * @throws Exception if there is an issue creating the SecurityFilterChain */ - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { + public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { log.debug("WebSecurityConfig.configure: user.security.defaultAction: {}", getDefaultAction()); log.debug("WebSecurityConfig.configure: unprotectedURIs: {}", Arrays.toString(getUnprotectedURIsArray())); List unprotectedURIs = getUnprotectedURIsList(); diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java new file mode 100644 index 0000000..3238cb3 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java @@ -0,0 +1,71 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.web.SecurityFilterChain; +import com.digitalsanctuary.spring.user.UserConfiguration; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Auto-configuration that contributes the library's {@link SecurityFilterChain}. + * + *

+ * The chain is contributed at a low precedence ({@link #SECURITY_FILTER_CHAIN_ORDER}) and backs off entirely via {@link ConditionalOnMissingBean} + * when the consuming application defines its own {@link SecurityFilterChain}. This lets consumers either: + *

+ *
    + *
  • Rely on the library's chain (the default), or
  • + *
  • Fully replace it by defining their own {@link SecurityFilterChain} bean — in which case the library's chain is suppressed entirely + * and the consumer owns all security rules, including the library's protected URIs.
  • + *
+ *

+ * A consumer that wants to layer additional rules in front of the library's chain can define their own chain with a higher-precedence (lower) + * {@code @Order} value; that chain is then consulted first by Spring Security's {@code FilterChainProxy}. + *

+ * + *

+ * The actual chain-building logic lives in {@link WebSecurityConfig#buildSecurityFilterChain(HttpSecurity, SessionRegistry)}. It is exposed via this + * auto-configuration (rather than directly as a {@code @Bean} on {@link WebSecurityConfig}) because {@code @ConditionalOnMissingBean} is only reliable + * on auto-configuration classes, which are guaranteed to load after any user-defined bean definitions. Placing the conditional on a + * component-scanned {@code @Configuration} bean method would evaluate it too early and could suppress the library chain incorrectly. + *

+ */ +@Slf4j +@AutoConfiguration(after = UserConfiguration.class) +@RequiredArgsConstructor +public class WebSecurityFilterChainAutoConfiguration { + + /** + * Order of the library's {@link SecurityFilterChain}. This is a low precedence (high numeric value) so that any consumer-supplied chain with a + * lower {@code @Order} takes precedence. The value is sourced from {@link SecurityFilterProperties#BASIC_AUTH_ORDER} + * ({@code Ordered.LOWEST_PRECEDENCE - 5}), so the library's chain sits at the same low precedence as Spring Boot's own default servlet security + * chain and always loses to consumer chains. This constant was historically exposed as {@code SecurityProperties.BASIC_AUTH_ORDER} in Spring Boot + * 3.x; in Spring Boot 4.0 it was relocated to {@link SecurityFilterProperties#BASIC_AUTH_ORDER} (still {@code Ordered.LOWEST_PRECEDENCE - 5}). + */ + public static final int SECURITY_FILTER_CHAIN_ORDER = SecurityFilterProperties.BASIC_AUTH_ORDER; + + private final WebSecurityConfig webSecurityConfig; + + /** + * Exposes the library's {@link SecurityFilterChain} bean, delegating construction to + * {@link WebSecurityConfig#buildSecurityFilterChain(HttpSecurity, SessionRegistry)}. + * + * @param http the shared {@link HttpSecurity} builder + * @param sessionRegistry the {@link SessionRegistry} used to track active sessions + * @return the library's configured {@link SecurityFilterChain} + * @throws Exception if there is an issue creating the {@link SecurityFilterChain} + */ + @Bean + @Order(SECURITY_FILTER_CHAIN_ORDER) + @ConditionalOnMissingBean(SecurityFilterChain.class) + public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { + log.debug("WebSecurityFilterChainAutoConfiguration: contributing library SecurityFilterChain at order {}", SECURITY_FILTER_CHAIN_ORDER); + return webSecurityConfig.buildSecurityFilterChain(http, sessionRegistry); + } +} 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 7963c40..87ed36d 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 +1,2 @@ com.digitalsanctuary.spring.user.UserConfiguration +com.digitalsanctuary.spring.user.security.WebSecurityFilterChainAutoConfiguration diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java new file mode 100644 index 0000000..9aa64d2 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java @@ -0,0 +1,131 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Tests that the library's {@link SecurityFilterChain} bean is composable: it is contributed at a low precedence + * ({@link SecurityFilterProperties#BASIC_AUTH_ORDER}) and backs off entirely (via {@link ConditionalOnMissingBean}) when a consuming application defines its own + * {@link SecurityFilterChain}. + *

+ * The chain is contributed by {@link WebSecurityFilterChainAutoConfiguration} (an auto-configuration), which delegates construction to + * {@link WebSecurityConfig#buildSecurityFilterChain}. It lives on an auto-configuration class — rather than directly as a {@code @Bean} on the + * component-scanned {@link WebSecurityConfig} — precisely because {@code @ConditionalOnMissingBean} is only reliable on auto-configuration + * classes (which load after user-defined bean definitions). + *

+ *

+ * The real bean method requires the full security context and many collaborators to invoke. Rather than boot that heavyweight context (which would + * also risk polluting the shared JPA metamodel across parallel integration contexts), this test verifies the composition contract in two + * complementary, isolated ways: + *

+ *
    + *
  1. A reflection assertion that the auto-configuration's {@code securityFilterChain} method carries + * {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} and a low-precedence {@code @Order}.
  2. + *
  3. An {@link ApplicationContextRunner} test against a lightweight stand-in auto-configuration that mirrors the exact same conditional/order + * semantics, proving the back-off behaviour when a consumer supplies their own chain.
  4. + *
+ */ +@DisplayName("WebSecurityConfig SecurityFilterChain Composition Tests") +class WebSecurityConfigCompositionTest { + + @Nested + @DisplayName("Annotation Contract on the auto-configuration bean method") + class AnnotationContract { + + @Test + @DisplayName("securityFilterChain is annotated @ConditionalOnMissingBean(SecurityFilterChain.class)") + void securityFilterChainIsConditionalOnMissingBean() throws Exception { + Method method = WebSecurityFilterChainAutoConfiguration.class.getMethod("securityFilterChain", HttpSecurity.class, SessionRegistry.class); + ConditionalOnMissingBean conditional = method.getAnnotation(ConditionalOnMissingBean.class); + assertThat(conditional).as("@ConditionalOnMissingBean must be present").isNotNull(); + assertThat(conditional.value()).as("must back off when any SecurityFilterChain is present").contains(SecurityFilterChain.class); + } + + @Test + @DisplayName("securityFilterChain is annotated with a low-precedence @Order so consumer chains win") + void securityFilterChainIsOrderedAtLowPrecedence() throws Exception { + Method method = WebSecurityFilterChainAutoConfiguration.class.getMethod("securityFilterChain", HttpSecurity.class, SessionRegistry.class); + Order order = method.getAnnotation(Order.class); + assertThat(order).as("@Order must be present").isNotNull(); + // Matches Spring Boot's own default servlet security chain order (SecurityFilterProperties.BASIC_AUTH_ORDER, + // relocated from SecurityProperties.BASIC_AUTH_ORDER in Spring Boot 4.0). + assertThat(order.value()).as("library chain must be low precedence so consumer chains win") + .isEqualTo(SecurityFilterProperties.BASIC_AUTH_ORDER); + assertThat(WebSecurityFilterChainAutoConfiguration.SECURITY_FILTER_CHAIN_ORDER).isEqualTo(SecurityFilterProperties.BASIC_AUTH_ORDER); + } + } + + @Nested + @DisplayName("Back-off semantics via ApplicationContextRunner") + class BackOffSemantics { + + // Register the library stand-in as an auto-configuration so it is processed AFTER user-defined beans, + // which is required for @ConditionalOnMissingBean to evaluate correctly. + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LibraryChainConfiguration.class)); + + @Test + @DisplayName("Library SecurityFilterChain is present by default") + void libraryChainPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(SecurityFilterChain.class); + assertThat(context).hasBean("librarySecurityFilterChain"); + }); + } + + @Test + @DisplayName("Library SecurityFilterChain backs off when a consumer defines their own") + void libraryChainBacksOffWhenConsumerSuppliesChain() { + contextRunner + .withUserConfiguration(ConsumerChainConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(SecurityFilterChain.class); + assertThat(context).hasBean("consumerSecurityFilterChain"); + assertThat(context).doesNotHaveBean("librarySecurityFilterChain"); + }); + } + } + + /** + * Lightweight stand-in mirroring the auto-configuration's composition contract (same annotations as + * {@link WebSecurityFilterChainAutoConfiguration#securityFilterChain}). Returns a Mockito mock so no servlet/security context is required. + *

+ * Intentionally NOT annotated with {@code @Configuration}: that would make this nested class eligible for the integration tests' component scan + * ({@code com.digitalsanctuary.spring.user}) and pollute those contexts with stray {@link SecurityFilterChain} beans. The + * {@link ApplicationContextRunner} registers these classes explicitly via {@code withConfiguration}/{@code withUserConfiguration}, so they are + * still processed as (lite) configuration sources without being scannable. + *

+ */ + static class LibraryChainConfiguration { + @Bean + @Order(SecurityFilterProperties.BASIC_AUTH_ORDER) + @ConditionalOnMissingBean(SecurityFilterChain.class) + public SecurityFilterChain librarySecurityFilterChain() { + return org.mockito.Mockito.mock(SecurityFilterChain.class); + } + } + + /** + * A trivial consumer-supplied chain that should win and suppress the library's chain. Intentionally NOT annotated with {@code @Configuration} so it + * is not component-scanned by the integration tests (see {@link LibraryChainConfiguration}). + */ + static class ConsumerChainConfiguration { + @Bean + public SecurityFilterChain consumerSecurityFilterChain() { + return org.mockito.Mockito.mock(SecurityFilterChain.class); + } + } +} From 6a776175dd81f7aad132ca86713b2377a8ef1a5e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 20:00:11 -0600 Subject: [PATCH 09/55] feat(extensibility): allow consumers to override core beans via @ConditionalOnMissingBean (H8) --- .../UserSecurityBeansAutoConfiguration.java | 119 ++++++++ .../user/security/WebSecurityConfig.java | 62 ---- ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../user/security/CoreBeanOverrideTest.java | 264 ++++++++++++++++++ 4 files changed, 384 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java new file mode 100644 index 0000000..0a72906 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -0,0 +1,119 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import com.digitalsanctuary.spring.user.UserConfiguration; +import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Auto-configuration that contributes the library's core, consumer-overridable security beans: + * {@link PasswordEncoder}, {@link SessionRegistry}, {@link RoleHierarchy}, and {@link DaoAuthenticationProvider}. + * + *

+ * Each bean is guarded by {@link ConditionalOnMissingBean}, so a consuming application can fully replace any of them simply by defining their own bean + * of the same type — for example, to swap {@code BCryptPasswordEncoder} for an Argon2 or delegating encoder, supply a different + * {@link SessionRegistry} implementation, or provide a custom {@link RoleHierarchy}. When the consumer defines no such bean, the library's default + * applies and behavior is unchanged. + *

+ * + *

+ * These beans live on an {@code @AutoConfiguration} class — rather than directly as {@code @Bean} methods on the component-scanned + * {@link WebSecurityConfig} — precisely because {@code @ConditionalOnMissingBean} is only reliable on auto-configuration classes, which are + * guaranteed to load AFTER user-defined bean definitions. Placing the conditional on a component-scanned {@code @Configuration} would evaluate it too + * early and could suppress the consumer's override or cause a bean-definition conflict (the H8 finding). This mirrors the pattern established for the + * library's {@link org.springframework.security.web.SecurityFilterChain} in {@link WebSecurityFilterChainAutoConfiguration}. + *

+ * + *

+ * {@code authProvider()} intentionally receives the effective {@link PasswordEncoder} as a method parameter rather than calling + * {@code encoder()} directly. Because Spring proxies {@code @Configuration}/{@code @AutoConfiguration} classes, a self-call to {@code encoder()} would + * always return the library's bean even when a consumer overrode the {@link PasswordEncoder}. Injecting the parameter lets Spring supply the consumer's + * encoder when present, so the authentication provider honors the override. + *

+ */ +@Slf4j +@AutoConfiguration(after = UserConfiguration.class) +@RequiredArgsConstructor +public class UserSecurityBeansAutoConfiguration { + + private final UserDetailsService userDetailsService; + private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig; + + @Value("${user.security.bcryptStrength:10}") + private int bcryptStrength; + + /** + * Creates the library's default {@link PasswordEncoder}, a {@link BCryptPasswordEncoder} using the configured {@code user.security.bcryptStrength}. + * Backs off entirely if the consuming application defines its own {@link PasswordEncoder}. + * + * @return the default {@link BCryptPasswordEncoder} + */ + @Bean + @ConditionalOnMissingBean(PasswordEncoder.class) + public PasswordEncoder encoder() { + return new BCryptPasswordEncoder(bcryptStrength); + } + + /** + * Creates the library's default {@link SessionRegistry}, a {@link SessionRegistryImpl}. Backs off entirely if the consuming application defines its + * own {@link SessionRegistry}. + * + * @return the default {@link SessionRegistryImpl} + */ + @Bean + @ConditionalOnMissingBean(SessionRegistry.class) + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + /** + * Creates the library's default {@link RoleHierarchy} from the {@code roleHierarchyString} in {@link RolesAndPrivilegesConfig}. Returns + * {@code null} (no role hierarchy) when the configuration is absent or empty — preserving the historical behavior. Backs off entirely if the + * consuming application defines its own {@link RoleHierarchy}. + * + * @return the configured {@link RoleHierarchyImpl}, or {@code null} when no hierarchy is configured + */ + @Bean + @ConditionalOnMissingBean(RoleHierarchy.class) + public RoleHierarchy roleHierarchy() { + if (rolesAndPrivilegesConfig == null) { + log.error("UserSecurityBeansAutoConfiguration.roleHierarchy: rolesAndPrivilegesConfig is null!"); + return null; + } + if (rolesAndPrivilegesConfig.getRoleHierarchyString() == null) { + log.error("UserSecurityBeansAutoConfiguration.roleHierarchy: rolesAndPrivilegesConfig.getRoleHierarchyString() is null!"); + return null; + } + RoleHierarchyImpl roleHierarchy = RoleHierarchyImpl.fromHierarchy(rolesAndPrivilegesConfig.getRoleHierarchyString()); + log.debug("UserSecurityBeansAutoConfiguration.roleHierarchy: roleHierarchy: {}", roleHierarchy.toString()); + return roleHierarchy; + } + + /** + * Creates the library's default {@link DaoAuthenticationProvider}, wiring in the {@link UserDetailsService} and the effective + * {@link PasswordEncoder}. The encoder is received as a method parameter (not via a self-call to {@code encoder()}) so a consumer-supplied + * {@link PasswordEncoder} is honored. Backs off entirely if the consuming application defines its own {@link DaoAuthenticationProvider}. + * + * @param passwordEncoder the effective {@link PasswordEncoder} (the consumer's bean if present, otherwise the library default) + * @return the default {@link DaoAuthenticationProvider} + */ + @Bean + @ConditionalOnMissingBean(DaoAuthenticationProvider.class) + public DaoAuthenticationProvider authProvider(PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder); + return authProvider; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 91735f9..444ac46 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -15,25 +15,19 @@ import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; -import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.authentication.AuthenticationEventPublisher; import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; -import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; import com.digitalsanctuary.spring.user.service.DSOAuth2UserService; import com.digitalsanctuary.spring.user.service.DSOidcUserService; import com.digitalsanctuary.spring.user.service.LoginSuccessService; @@ -111,9 +105,6 @@ public class WebSecurityConfig { @Value("${spring.security.oauth2.enabled:false}") private boolean oauth2Enabled; - @Value("${user.security.bcryptStrength}") - private int bcryptStrength = 10; - @Value("${user.security.rememberMe.enabled:false}") private boolean rememberMeEnabled; @@ -127,7 +118,6 @@ public class WebSecurityConfig { private final UserDetailsService userDetailsService; private final LoginSuccessService loginSuccessService; private final LogoutSuccessService logoutSuccessService; - private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig; private final DSOAuth2UserService dsOAuth2UserService; private final DSOidcUserService dsOidcUserService; private final WebAuthnConfigProperties webAuthnConfigProperties; @@ -339,58 +329,6 @@ private List getUnprotectedURIsList() { return unprotectedURIs; } - /** - * The authProvider method creates a DaoAuthenticationProvider and sets the UserDetailsService and PasswordEncoder for the provider. - * - * @return the DaoAuthenticationProvider object - */ - @Bean - public DaoAuthenticationProvider authProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService); - authProvider.setPasswordEncoder(encoder()); - return authProvider; - } - - /** - * The encoder method creates a BCryptPasswordEncoder with the bcryptStrength value. - * - * @return the BCryptPasswordEncoder object - */ - @Bean - public PasswordEncoder encoder() { - return new BCryptPasswordEncoder(bcryptStrength); - } - - /** - * The sessionRegistry method creates a SessionRegistryImpl object. - * - * @return the SessionRegistryImpl object - */ - @Bean - public SessionRegistry sessionRegistry() { - return new SessionRegistryImpl(); - } - - /** - * The roleHierarchy method creates a RoleHierarchyImpl object from the roleHierarchyString in the rolesAndPrivilegesConfig object. - * - * @return the RoleHierarchyImpl object - */ - @Bean - public RoleHierarchy roleHierarchy() { - if (rolesAndPrivilegesConfig == null) { - log.error("WebSecurityConfig.roleHierarchy: rolesAndPrivilegesConfig is null!"); - return null; - } - if (rolesAndPrivilegesConfig.getRoleHierarchyString() == null) { - log.error("WebSecurityConfig.roleHierarchy: rolesAndPrivilegesConfig.getRoleHierarchyString() is null!"); - return null; - } - RoleHierarchyImpl roleHierarchy = RoleHierarchyImpl.fromHierarchy(rolesAndPrivilegesConfig.getRoleHierarchyString()); - log.debug("WebSecurityConfig.roleHierarchy: roleHierarchy: {}", roleHierarchy.toString()); - return roleHierarchy; - } - /** * The methodSecurityExpressionHandler method creates a MethodSecurityExpressionHandler object and sets the roleHierarchy for the handler. This * ensures that method security annotations like @PreAuthorize use the configured role hierarchy. 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 87ed36d..e714f39 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,2 +1,3 @@ com.digitalsanctuary.spring.user.UserConfiguration +com.digitalsanctuary.spring.user.security.UserSecurityBeansAutoConfiguration com.digitalsanctuary.spring.user.security.WebSecurityFilterChainAutoConfiguration diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java new file mode 100644 index 0000000..aab5add --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java @@ -0,0 +1,264 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; + +/** + * Proves that the four core, overridable security beans — {@link PasswordEncoder}, {@link SessionRegistry}, {@link RoleHierarchy}, and + * {@link DaoAuthenticationProvider} — are genuinely replaceable by a consuming application. + * + *

+ * The library historically defined these beans on the component-scanned {@link WebSecurityConfig} with no {@code @ConditionalOnMissingBean}, so a + * consumer that defined their own bean of the same type got a bean-definition conflict. Task 3.2 established (and this test re-proves) that + * {@code @ConditionalOnMissingBean} is only reliable on an {@code @AutoConfiguration} class — which loads AFTER user-defined beans — not on + * a component-scanned {@code @Configuration}. These beans therefore live on {@link UserSecurityBeansAutoConfiguration}. + *

+ * + *

+ * The test is deliberately isolated: it drives {@link UserSecurityBeansAutoConfiguration} directly through an {@link ApplicationContextRunner} with a + * tiny set of mock collaborators, so it never boots the full security/JPA context. This avoids polluting the shared JPA metamodel across parallel + * integration contexts while still exercising the real override semantics end-to-end. + *

+ */ +@DisplayName("Core Security Bean Override Tests") +class CoreBeanOverrideTest { + + /** + * Drives the real {@link UserSecurityBeansAutoConfiguration}. A {@link RolesAndPrivilegesConfig} and a {@link UserDetailsService} are supplied as + * collaborators because {@code roleHierarchy()} and {@code authProvider()} depend on them. Registered as an auto-configuration so + * {@code @ConditionalOnMissingBean} evaluates AFTER any user-supplied beans. + */ + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(UserDetailsService.class, () -> username -> User.withUsername("test").password("x").authorities("ROLE_USER").build()) + .withBean(RolesAndPrivilegesConfig.class, CoreBeanOverrideTest::roleConfig) + .withConfiguration(AutoConfigurations.of(UserSecurityBeansAutoConfiguration.class)); + + private static RolesAndPrivilegesConfig roleConfig() { + RolesAndPrivilegesConfig config = new RolesAndPrivilegesConfig(); + // getRoleHierarchyString() is built from the roleHierarchy list; set it so roleHierarchy() returns a real hierarchy by default. + config.setRoleHierarchy(List.of("ROLE_ADMIN > ROLE_USER")); + return config; + } + + @Nested + @DisplayName("Default behavior: library beans present when consumer supplies none") + class Defaults { + + @Test + @DisplayName("Library provides a BCryptPasswordEncoder by default") + void libraryEncoderPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(PasswordEncoder.class); + assertThat(context.getBean(PasswordEncoder.class)).isInstanceOf(BCryptPasswordEncoder.class); + }); + } + + @Test + @DisplayName("Library provides a SessionRegistryImpl by default") + void librarySessionRegistryPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(SessionRegistry.class); + assertThat(context.getBean(SessionRegistry.class)).isInstanceOf(SessionRegistryImpl.class); + }); + } + + @Test + @DisplayName("Library provides a DaoAuthenticationProvider by default") + void libraryAuthProviderPresentByDefault() { + contextRunner.run(context -> assertThat(context).hasSingleBean(DaoAuthenticationProvider.class)); + } + + @Test + @DisplayName("Library provides a RoleHierarchy by default when config supplies a hierarchy") + void libraryRoleHierarchyPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RoleHierarchy.class); + assertThat(context.getBean(RoleHierarchy.class)).isInstanceOf(RoleHierarchyImpl.class); + }); + } + } + + @Nested + @DisplayName("Override behavior: consumer beans win") + class Overrides { + + @Test + @DisplayName("Consumer PasswordEncoder replaces the library's BCryptPasswordEncoder") + void consumerEncoderWins() { + PasswordEncoder consumerEncoder = NoOpPasswordEncoder.getInstance(); + contextRunner.withUserConfiguration(ConsumerEncoderConfig.class).run(context -> { + assertThat(context).hasSingleBean(PasswordEncoder.class); + PasswordEncoder active = context.getBean(PasswordEncoder.class); + assertThat(active).as("consumer's encoder must win").isSameAs(consumerEncoder); + assertThat(active).as("library BCryptPasswordEncoder must NOT be the active encoder").isNotInstanceOf(BCryptPasswordEncoder.class); + }); + } + + @Test + @DisplayName("Consumer SessionRegistry replaces the library's SessionRegistryImpl") + void consumerSessionRegistryWins() { + contextRunner.withUserConfiguration(ConsumerSessionRegistryConfig.class).run(context -> { + assertThat(context).hasSingleBean(SessionRegistry.class); + SessionRegistry active = context.getBean(SessionRegistry.class); + assertThat(active).as("consumer's session registry must win").isSameAs(ConsumerSessionRegistryConfig.CONSUMER_REGISTRY); + assertThat(active).as("library SessionRegistryImpl must NOT be the active registry").isNotInstanceOf(SessionRegistryImpl.class); + }); + } + + @Test + @DisplayName("Consumer RoleHierarchy replaces the library's RoleHierarchyImpl") + void consumerRoleHierarchyWins() { + contextRunner.withUserConfiguration(ConsumerRoleHierarchyConfig.class).run(context -> { + assertThat(context).hasSingleBean(RoleHierarchy.class); + assertThat(context.getBean(RoleHierarchy.class)).isSameAs(ConsumerRoleHierarchyConfig.CONSUMER_HIERARCHY); + }); + } + + @Test + @DisplayName("Consumer DaoAuthenticationProvider replaces the library's, and is wired with the consumer's encoder") + void consumerAuthProviderWins() { + contextRunner.withUserConfiguration(ConsumerAuthProviderConfig.class).run(context -> { + assertThat(context).hasSingleBean(DaoAuthenticationProvider.class); + assertThat(context.getBean(DaoAuthenticationProvider.class)).isSameAs(ConsumerAuthProviderConfig.CONSUMER_PROVIDER); + }); + } + + @Test + @DisplayName("authProvider() honors a consumer-supplied PasswordEncoder (no intra-class self-call to encoder())") + void authProviderUsesConsumerEncoder() { + PasswordEncoder consumerEncoder = NoOpPasswordEncoder.getInstance(); + contextRunner.withUserConfiguration(ConsumerEncoderConfig.class).run(context -> { + DaoAuthenticationProvider provider = context.getBean(DaoAuthenticationProvider.class); + // The provider must encode using the consumer's encoder, not the library BCrypt one. NoOpPasswordEncoder + // returns the raw password, so a match against the raw value proves the consumer encoder was injected. + assertThat(provider.authenticate(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("test", "x")) + .isAuthenticated()).as("authProvider must authenticate using the consumer's NoOp encoder").isTrue(); + // Sanity: the consumer's encoder is indeed the active one. + assertThat(context.getBean(PasswordEncoder.class)).isSameAs(consumerEncoder); + }); + } + } + + @Nested + @DisplayName("Annotation contract on the auto-configuration bean methods") + class AnnotationContract { + + @Test + @DisplayName("encoder() is @ConditionalOnMissingBean") + void encoderIsConditional() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("encoder"); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + + @Test + @DisplayName("sessionRegistry() is @ConditionalOnMissingBean") + void sessionRegistryIsConditional() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("sessionRegistry"); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + + @Test + @DisplayName("roleHierarchy() is @ConditionalOnMissingBean") + void roleHierarchyIsConditional() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("roleHierarchy"); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + + @Test + @DisplayName("authProvider() is @ConditionalOnMissingBean and receives PasswordEncoder as a parameter") + void authProviderIsConditionalAndParameterized() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("authProvider", PasswordEncoder.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).as("@ConditionalOnMissingBean must be present").isNotNull(); + // authProvider must RECEIVE the PasswordEncoder (so a consumer override is honored) rather than self-call encoder(). + assertThat(method.getParameterTypes()).as("authProvider must receive PasswordEncoder via injection").contains(PasswordEncoder.class); + } + } + + // ---- Consumer-supplied stand-in configurations. Not @Configuration so the integration tests' component scan does not pick them up. ---- + + static class ConsumerEncoderConfig { + static final PasswordEncoder CONSUMER_ENCODER = NoOpPasswordEncoder.getInstance(); + + @Bean + PasswordEncoder consumerEncoder() { + return CONSUMER_ENCODER; + } + } + + static class ConsumerSessionRegistryConfig { + static final SessionRegistry CONSUMER_REGISTRY = new CustomSessionRegistry(); + + @Bean + SessionRegistry consumerSessionRegistry() { + return CONSUMER_REGISTRY; + } + } + + static class ConsumerRoleHierarchyConfig { + static final RoleHierarchy CONSUMER_HIERARCHY = RoleHierarchyImpl.fromHierarchy("ROLE_X > ROLE_Y"); + + @Bean + RoleHierarchy consumerRoleHierarchy() { + return CONSUMER_HIERARCHY; + } + } + + static class ConsumerAuthProviderConfig { + static final DaoAuthenticationProvider CONSUMER_PROVIDER = + new DaoAuthenticationProvider(username -> User.withUsername("c").password("x").authorities("ROLE_USER").build()); + + @Bean + DaoAuthenticationProvider consumerAuthProvider() { + return CONSUMER_PROVIDER; + } + } + + /** + * A trivial custom {@link SessionRegistry} that is NOT a {@link SessionRegistryImpl}, so the test can assert the consumer's instance wins. + */ + static class CustomSessionRegistry implements SessionRegistry { + @Override + public List getAllPrincipals() { + return List.of(); + } + + @Override + public List getAllSessions(Object principal, boolean includeExpiredSessions) { + return List.of(); + } + + @Override + public org.springframework.security.core.session.SessionInformation getSessionInformation(String sessionId) { + return null; + } + + @Override + public void refreshLastRequest(String sessionId) {} + + @Override + public void registerNewSession(String sessionId, Object principal) {} + + @Override + public void removeSessionInformation(String sessionId) {} + } +} From bf7527c8bcd829c0e3d0df2cb1b704714a17e824 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 20:11:34 -0600 Subject: [PATCH 10/55] feat(extensibility): allow overriding AuditLogWriter and MailService beans --- .../spring/user/audit/AuditEventListener.java | 23 ++- .../audit/AuditMailAutoConfiguration.java | 95 ++++++++++ .../audit/FileAuditLogFlushScheduler.java | 12 +- .../spring/user/audit/FileAuditLogWriter.java | 18 +- .../spring/user/mail/MailService.java | 6 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../user/audit/AuditEventListenerTest.java | 11 +- .../audit/AuditLogWriterOverrideTest.java | 164 ++++++++++++++++++ .../user/mail/MailServiceOverrideTest.java | 116 +++++++++++++ 9 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java index b620855..f7ef052 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.audit; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -17,6 +18,17 @@ * {@code AuditConfig.isLogEvents()}. All exceptions are caught and logged to ensure * audit failures never impact application flow. * + *

The {@link AuditLogWriter} dependency is resolved lazily through an + * {@link ObjectProvider}. This is deliberate: the library's default writer bean + * ({@link FileAuditLogWriter}) is gated by {@code user.audit.logEvents} in + * {@link AuditMailAutoConfiguration}, so when audit logging is disabled (and no consumer + * supplies their own writer) no {@link AuditLogWriter} bean exists. Injecting the writer + * directly would then fail the application context startup with an + * {@code UnsatisfiedDependencyException}. By holding an {@link ObjectProvider} and + * resolving the writer only when an event is actually logged, this listener always starts + * cleanly and simply short-circuits on the {@code logEvents} flag (with a null guard as a + * belt-and-suspenders safety) when no writer is available. + * * @see AuditEvent * @see AuditLogWriter * @see AuditConfig @@ -29,7 +41,7 @@ public class AuditEventListener { private final AuditConfig auditConfig; - private final AuditLogWriter auditLogWriter; + private final ObjectProvider auditLogWriterProvider; /** * Handle the AuditEvents. @@ -43,8 +55,13 @@ public void onApplicationEvent(AuditEvent event) { try { log.debug("AuditEventListener.onApplicationEvent: called with event: {}", event); if (auditConfig.isLogEvents() && event != null) { - log.debug("AuditEventListener.onApplicationEvent: logging event..."); - auditLogWriter.writeLog(event); + AuditLogWriter auditLogWriter = auditLogWriterProvider.getIfAvailable(); + if (auditLogWriter != null) { + log.debug("AuditEventListener.onApplicationEvent: logging event..."); + auditLogWriter.writeLog(event); + } else { + log.debug("AuditEventListener.onApplicationEvent: no AuditLogWriter available; skipping event."); + } } } catch (Exception e) { // Never let audit failures impact application flow diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java new file mode 100644 index 0000000..eca6efe --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java @@ -0,0 +1,95 @@ +package com.digitalsanctuary.spring.user.audit; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import com.digitalsanctuary.spring.user.UserConfiguration; +import com.digitalsanctuary.spring.user.mail.MailContentBuilder; +import com.digitalsanctuary.spring.user.mail.MailService; + +/** + * Auto-configuration that contributes the library's two consumer-overridable extension-point beans for audit logging and email delivery: the default + * {@link AuditLogWriter} (a {@link FileAuditLogWriter}) and the default {@link MailService}. + * + *

+ * Both beans are guarded by {@link ConditionalOnMissingBean}, so a consuming application can fully replace either of them simply by defining their own + * bean of the same type. A consumer can route audit events to a database, REST endpoint, or SIEM by supplying their own {@link AuditLogWriter}, and can + * replace mail delivery by supplying their own {@link MailService} (typically a subclass). When the consumer defines no such bean, the library's default + * applies and behavior is unchanged. + *

+ * + *

+ * These beans live on an {@code @AutoConfiguration} class — rather than as a component-scanned {@code @Component}/{@code @Service} — precisely + * because {@code @ConditionalOnMissingBean} is only reliable on auto-configuration classes, which are guaranteed to load AFTER user-defined bean + * definitions. Placing the conditional on a component-scanned stereotype would evaluate it too early and could suppress the consumer's override or cause a + * bean-definition conflict (the H8 finding). This mirrors {@link com.digitalsanctuary.spring.user.security.UserSecurityBeansAutoConfiguration}. + *

+ * + *

+ * Bean-method-produced instances retain full Spring lifecycle support: {@link FileAuditLogWriter}'s {@code @PostConstruct setup()} / + * {@code @PreDestroy cleanup()} and {@link MailService}'s {@code @PostConstruct init()}, {@code @Value} {@code fromAddress} field injection, and + * {@code @Async}/{@code @Retryable} AOP proxying all still apply to {@code @Bean}-produced objects. + *

+ */ +@AutoConfiguration(after = UserConfiguration.class) +public class AuditMailAutoConfiguration { + + /** + * Creates the library's default {@link AuditLogWriter}, a {@link FileAuditLogWriter} that writes pipe-delimited audit events to a log file. Backs + * off entirely if the consuming application defines its own {@link AuditLogWriter}. + * + *

+ * Gated by {@code user.audit.logEvents} (default {@code true}): when audit logging is disabled the writer bean is not created at all, which in turn + * lets {@link FileAuditLogFlushScheduler} back off too. Runtime write paths ({@link AuditEventListener}) already short-circuit on the same flag, so + * this gate simply avoids creating an unused file-handle-owning bean. + *

+ * + * @param auditConfig the audit configuration properties + * @return the default {@link FileAuditLogWriter} + */ + @Bean + @ConditionalOnMissingBean(AuditLogWriter.class) + @ConditionalOnProperty(name = "user.audit.logEvents", havingValue = "true", matchIfMissing = true) + public FileAuditLogWriter fileAuditLogWriter(AuditConfig auditConfig) { + return new FileAuditLogWriter(auditConfig); + } + + /** + * Creates the {@link FileAuditLogFlushScheduler} that periodically flushes the {@link FileAuditLogWriter} buffer to disk. + * + *

+ * Only created when the library's {@link FileAuditLogWriter} is present ({@link ConditionalOnBean}) — so it backs off cleanly when a consumer + * replaces the writer with their own {@link AuditLogWriter} — and only when audit logging is enabled and flush-on-write is disabled, because + * immediate flushing makes the scheduler unnecessary. Declared after {@link #fileAuditLogWriter(AuditConfig)} so {@code @ConditionalOnBean} reliably + * observes the writer. + *

+ * + * @param fileAuditLogWriter the library's file audit log writer + * @return the flush scheduler + */ + @Bean + @ConditionalOnBean(FileAuditLogWriter.class) + @ConditionalOnExpression("${user.audit.logEvents:true} && !${user.audit.flushOnWrite:true}") + public FileAuditLogFlushScheduler fileAuditLogFlushScheduler(FileAuditLogWriter fileAuditLogWriter) { + return new FileAuditLogFlushScheduler(fileAuditLogWriter); + } + + /** + * Creates the library's default {@link MailService}. Backs off entirely if the consuming application defines its own {@link MailService} (or a + * subclass), keeping the concrete type so existing injectors are unaffected. + * + * @param mailSenderProvider provider for the mail sender; may resolve to null when mail is not configured + * @param mailContentBuilder the mail content builder + * @return the default {@link MailService} + */ + @Bean + @ConditionalOnMissingBean(MailService.class) + public MailService mailService(ObjectProvider mailSenderProvider, MailContentBuilder mailContentBuilder) { + return new MailService(mailSenderProvider, mailContentBuilder); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java index 0e10957..f2d079d 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java @@ -1,8 +1,6 @@ package com.digitalsanctuary.spring.user.audit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,22 +10,24 @@ *

This component ensures buffered audit data is written to the file at regular intervals, * balancing write performance with data integrity. * - *

Conditional Activation: This scheduler is only active when both conditions are met: + *

Conditional Activation: This scheduler is contributed as a {@code @Bean} by + * {@link AuditMailAutoConfiguration} only when all of these hold: *

    *
  • {@code user.audit.logEvents=true} - audit logging is enabled
  • *
  • {@code user.audit.flushOnWrite=false} - immediate flush is disabled
  • + *
  • a {@link FileAuditLogWriter} bean is present (i.e. a consumer has not replaced the writer)
  • *
* *

When flush-on-write is enabled, logs are flushed immediately after each write, * making this scheduler unnecessary. The flush frequency is controlled by - * {@code user.audit.flushRate} (in milliseconds). + * {@code user.audit.flushRate} (in milliseconds). It is not component-scanned because it depends on the + * auto-configured {@link FileAuditLogWriter} and must back off when that writer is absent. * * @see FileAuditLogWriter * @see AuditConfig + * @see AuditMailAutoConfiguration */ @Slf4j -@Component -@ConditionalOnExpression("${user.audit.logEvents:true} && !${user.audit.flushOnWrite:true}") @RequiredArgsConstructor public class FileAuditLogFlushScheduler { diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java index 43c59a8..a22ebc9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java @@ -8,11 +8,9 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.text.MessageFormat; -import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -29,18 +27,30 @@ *

If the configured log path is not writable, the writer falls back to a temporary * directory location. * + *

This class is not component-scanned; it is contributed as a consumer-overridable {@code @Bean} by + * {@link AuditMailAutoConfiguration} so a consumer-supplied {@link AuditLogWriter} can replace it via + * {@code @ConditionalOnMissingBean}. + * * @see AuditLogWriter * @see AuditConfig * @see FileAuditLogFlushScheduler + * @see AuditMailAutoConfiguration */ @Slf4j -@Component -@RequiredArgsConstructor public class FileAuditLogWriter implements AuditLogWriter { private final AuditConfig auditConfig; private BufferedWriter bufferedWriter; + /** + * Constructs the writer with the audit configuration it depends on. + * + * @param auditConfig the audit configuration properties + */ + public FileAuditLogWriter(AuditConfig auditConfig) { + this.auditConfig = auditConfig; + } + /** * Initializes the log file writer. This method is called after the bean is constructed. It validates the configuration and opens the log file for * writing. diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java index 37a6f50..6fcb43c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java @@ -12,7 +12,6 @@ import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; import org.thymeleaf.context.Context; import lombok.extern.slf4j.Slf4j; @@ -23,9 +22,12 @@ *

Email is treated as optional: if no {@link JavaMailSender} bean is available (typically because {@code spring.mail.host} is not configured), * a single warning is logged at startup and all send operations silently no-op, so the application starts and runs normally with email-dependent * features degraded.

+ * + *

This class is not component-scanned; it is contributed as a consumer-overridable {@code @Bean} by + * {@code AuditMailAutoConfiguration} so a consumer can replace mail delivery by supplying their own {@link MailService} + * (or subclass) bean, which the library's default then backs off from via {@code @ConditionalOnMissingBean}.

*/ @Slf4j -@Service public class MailService { private final ObjectProvider mailSenderProvider; 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 e714f39..f5da61f 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,3 +1,4 @@ com.digitalsanctuary.spring.user.UserConfiguration +com.digitalsanctuary.spring.user.audit.AuditMailAutoConfiguration com.digitalsanctuary.spring.user.security.UserSecurityBeansAutoConfiguration com.digitalsanctuary.spring.user.security.WebSecurityFilterChainAutoConfiguration diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java index fc09ac8..64fb854 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java @@ -11,9 +11,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; @ExtendWith(MockitoExtension.class) @DisplayName("AuditEventListener Tests") @@ -25,7 +25,9 @@ class AuditEventListenerTest { @Mock private AuditLogWriter auditLogWriter; - @InjectMocks + @Mock + private ObjectProvider auditLogWriterProvider; + private AuditEventListener auditEventListener; private User testUser; @@ -33,6 +35,11 @@ class AuditEventListenerTest { @BeforeEach void setUp() { + // The provider resolves to the mock writer by default; tests that exercise the + // disabled path never reach getIfAvailable() because isLogEvents() short-circuits. + lenient().when(auditLogWriterProvider.getIfAvailable()).thenReturn(auditLogWriter); + auditEventListener = new AuditEventListener(auditConfig, auditLogWriterProvider); + testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java new file mode 100644 index 0000000..2840284 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java @@ -0,0 +1,164 @@ +package com.digitalsanctuary.spring.user.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import com.digitalsanctuary.spring.user.mail.MailContentBuilder; + +/** + * Proves that the library's {@link AuditLogWriter} is genuinely replaceable by a consuming application. + * + *

+ * The library historically defined {@link FileAuditLogWriter} as an unconditional component-scanned {@code @Component}. A consumer that supplied their + * own {@link AuditLogWriter} bean got a collision at startup. Following the H8/Task 3.3 lesson, the library's default writer now lives on an + * {@code @AutoConfiguration} class guarded by {@code @ConditionalOnMissingBean(AuditLogWriter.class)}, which loads AFTER user-defined beans so the + * consumer's override reliably wins. + *

+ * + *

+ * The test is deliberately isolated: it drives {@link AuditMailAutoConfiguration} directly through an {@link ApplicationContextRunner} with mock + * collaborators, so it never boots the full JPA/security context (avoiding JPA-metamodel pollution across parallel integration contexts). + *

+ */ +@DisplayName("AuditLogWriter Override Tests") +class AuditLogWriterOverrideTest { + + /** + * Drives the real {@link AuditMailAutoConfiguration}. An {@link AuditConfig} is supplied as a collaborator because {@link FileAuditLogWriter} + * depends on it. Registered as an auto-configuration so {@code @ConditionalOnMissingBean} evaluates AFTER any consumer-supplied beans. + */ + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("user.mail.fromAddress=test@example.com") + .withBean(AuditConfig.class, AuditLogWriterOverrideTest::auditConfig) + .withBean(MailContentBuilder.class, () -> mock(MailContentBuilder.class)) + .withBean("javaMailSender", JavaMailSender.class, () -> mock(JavaMailSender.class)) + .withConfiguration(AutoConfigurations.of(AuditMailAutoConfiguration.class)); + + private static AuditConfig auditConfig() { + AuditConfig config = new AuditConfig(); + // logEvents=true so the default writer bean is created under the @ConditionalOnProperty gate. + config.setLogEvents(true); + config.setLogFilePath(System.getProperty("java.io.tmpdir") + "/audit-override-test.log"); + config.setFlushOnWrite(true); + config.setFlushRate(1000); + return config; + } + + @Nested + @DisplayName("Default behavior: library FileAuditLogWriter present when consumer supplies none") + class Defaults { + + @Test + @DisplayName("Library provides a FileAuditLogWriter by default") + void libraryWriterPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(AuditLogWriter.class); + assertThat(context.getBean(AuditLogWriter.class)).isInstanceOf(FileAuditLogWriter.class); + }); + } + } + + @Nested + @DisplayName("Override behavior: consumer AuditLogWriter wins") + class Overrides { + + @Test + @DisplayName("Consumer AuditLogWriter replaces the library's FileAuditLogWriter, which backs off") + void consumerWriterWins() { + contextRunner.withUserConfiguration(ConsumerAuditLogWriterConfig.class).run(context -> { + assertThat(context).hasSingleBean(AuditLogWriter.class); + AuditLogWriter active = context.getBean(AuditLogWriter.class); + assertThat(active).as("consumer's writer must win").isSameAs(ConsumerAuditLogWriterConfig.CONSUMER_WRITER); + assertThat(active).as("library FileAuditLogWriter must NOT be the active writer").isNotInstanceOf(FileAuditLogWriter.class); + assertThat(context).as("library FileAuditLogWriter must back off entirely").doesNotHaveBean(FileAuditLogWriter.class); + }); + } + } + + @Nested + @DisplayName("Disabled audit: context starts even though no writer bean exists (regression: Task 3.5)") + class DisabledAudit { + + /** + * Regression guard for the Task 3.5 bug. When {@code user.audit.logEvents=false} the library's + * {@link FileAuditLogWriter} bean is suppressed by {@code @ConditionalOnProperty}. The unconditional + * {@link AuditEventListener} must still start, because its {@link AuditLogWriter} dependency is now resolved + * through an {@link org.springframework.beans.factory.ObjectProvider} rather than a hard constructor injection. + * Before the fix this configuration threw {@code UnsatisfiedDependencyException} ("No qualifying bean of type + * 'AuditLogWriter' available") at context startup. + */ + @Test + @DisplayName("Context starts with AuditEventListener present and logEvents=false (no UnsatisfiedDependencyException)") + void contextStartsWhenAuditDisabledAndListenerPresent() { + contextRunner.withPropertyValues("user.audit.logEvents=false") + .withUserConfiguration(AuditEventListenerConfig.class) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(AuditEventListener.class); + assertThat(context).as("library FileAuditLogWriter must not be created when logEvents=false") + .doesNotHaveBean(FileAuditLogWriter.class); + assertThat(context).as("no AuditLogWriter bean of any kind should exist") + .doesNotHaveBean(AuditLogWriter.class); + }); + } + } + + @Nested + @DisplayName("Annotation contract on the auto-configuration bean method") + class AnnotationContract { + + @Test + @DisplayName("fileAuditLogWriter() is @ConditionalOnMissingBean") + void writerIsConditional() throws Exception { + Method method = AuditMailAutoConfiguration.class.getMethod("fileAuditLogWriter", AuditConfig.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + } + + // ---- Consumer-supplied stand-in configuration. Not @Configuration so the integration tests' component scan does not pick it up. ---- + + static class ConsumerAuditLogWriterConfig { + static final AuditLogWriter CONSUMER_WRITER = new CustomAuditLogWriter(); + + @Bean + AuditLogWriter consumerAuditLogWriter() { + return CONSUMER_WRITER; + } + } + + /** + * Registers the real {@link AuditEventListener} so the disabled-audit regression test can prove the context starts + * even when no {@link AuditLogWriter} bean exists. Spring supplies the {@code ObjectProvider} + * automatically; {@link AuditConfig} is provided by the shared context runner. + */ + static class AuditEventListenerConfig { + @Bean + AuditEventListener auditEventListener(AuditConfig auditConfig, + org.springframework.beans.factory.ObjectProvider auditLogWriterProvider) { + return new AuditEventListener(auditConfig, auditLogWriterProvider); + } + } + + /** + * A trivial custom {@link AuditLogWriter} that is NOT a {@link FileAuditLogWriter}, so the test can assert the consumer's instance wins. + */ + static class CustomAuditLogWriter implements AuditLogWriter { + @Override + public void writeLog(AuditEvent event) {} + + @Override + public void setup() {} + + @Override + public void cleanup() {} + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java new file mode 100644 index 0000000..624c29b --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java @@ -0,0 +1,116 @@ +package com.digitalsanctuary.spring.user.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import com.digitalsanctuary.spring.user.audit.AuditMailAutoConfiguration; + +/** + * Proves that the library's {@link MailService} is genuinely replaceable by its concrete type. + * + *

+ * Per the Task 3.5 scope, {@link MailService} keeps its concrete type (no interface is extracted) so existing injectors that depend on the concrete + * type are unaffected. A consumer overrides mail delivery by supplying their own {@link MailService} (typically a subclass) bean; the library's default + * then backs off via {@code @ConditionalOnMissingBean(MailService.class)}. Following the H8/Task 3.3 lesson, the default lives on an + * {@code @AutoConfiguration} class (loading AFTER user beans) so the override reliably wins. + *

+ * + *

+ * The test is isolated: it drives {@link AuditMailAutoConfiguration} through an {@link ApplicationContextRunner} with mocked collaborators + * ({@link MailContentBuilder} and an {@link ObjectProvider} of {@link JavaMailSender}), so it never boots the full JPA/security context. + *

+ */ +@DisplayName("MailService Override Tests") +class MailServiceOverrideTest { + + /** + * Drives the real {@link AuditMailAutoConfiguration}. A {@link MailContentBuilder} and an empty {@link ObjectProvider} of {@link JavaMailSender} are + * supplied because the {@link MailService} {@code @Bean} method depends on them. {@code user.mail.fromAddress} is set so the {@code @Value} field on + * a default-created {@link MailService} resolves. + */ + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("user.mail.fromAddress=test@example.com", "user.audit.logEvents=false") + .withBean(MailContentBuilder.class, () -> mock(MailContentBuilder.class)) + .withBean("javaMailSender", JavaMailSender.class, () -> mock(JavaMailSender.class)) + .withConfiguration(AutoConfigurations.of(AuditMailAutoConfiguration.class)); + + @Nested + @DisplayName("Default behavior: library MailService present when consumer supplies none") + class Defaults { + + @Test + @DisplayName("Library provides a MailService by default") + void libraryMailServicePresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(MailService.class); + // The library's default is exactly MailService (not a subclass). + assertThat(context.getBean(MailService.class).getClass()).isEqualTo(MailService.class); + }); + } + } + + @Nested + @DisplayName("Override behavior: consumer MailService wins") + class Overrides { + + @Test + @DisplayName("Consumer MailService subclass replaces the library's default") + void consumerMailServiceWins() { + contextRunner.withUserConfiguration(ConsumerMailServiceConfig.class).run(context -> { + assertThat(context).hasSingleBean(MailService.class); + MailService active = context.getBean(MailService.class); + assertThat(active).as("consumer's mail service must win").isSameAs(ConsumerMailServiceConfig.CONSUMER_MAIL_SERVICE); + assertThat(active).as("consumer's mail service is a subclass, proving it is not the library default").isInstanceOf(CustomMailService.class); + }); + } + } + + @Nested + @DisplayName("Annotation contract on the auto-configuration bean method") + class AnnotationContract { + + @Test + @DisplayName("mailService() is @ConditionalOnMissingBean") + void mailServiceIsConditional() throws Exception { + Method method = AuditMailAutoConfiguration.class.getMethod("mailService", ObjectProvider.class, MailContentBuilder.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + } + + // ---- Consumer-supplied stand-in configuration. Not @Configuration so the integration tests' component scan does not pick it up. ---- + + static class ConsumerMailServiceConfig { + static final MailService CONSUMER_MAIL_SERVICE = new CustomMailService(); + + @Bean + MailService consumerMailService() { + return CONSUMER_MAIL_SERVICE; + } + } + + /** + * A consumer subclass of {@link MailService}, proving a consumer override of the concrete type wins. Constructed with nulls because this stand-in is + * never invoked to send mail in the test; {@code init()} is overridden to a no-op so the parent's {@code @PostConstruct} does not dereference the + * null provider. + */ + static class CustomMailService extends MailService { + CustomMailService() { + super(null, null); + } + + @Override + void init() { + // no-op: avoids the parent @PostConstruct touching the null provider in this stand-in + } + } +} From 334cecc0ca6315275d28bd1e3b220b5ad8758ed5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 20:35:34 -0600 Subject: [PATCH 11/55] fix(security): hash tokens at rest with dual-read, single active token, atomic consume, configurable lifetime --- CONFIG.md | 10 + .../spring/user/api/UserAPI.java | 17 +- .../persistence/model/PasswordResetToken.java | 16 +- .../persistence/model/VerificationToken.java | 39 +- .../PasswordResetTokenRepository.java | 7 + .../VerificationTokenRepository.java | 7 + .../spring/user/service/TokenHasher.java | 110 ++++++ .../spring/user/service/UserEmailService.java | 22 +- .../spring/user/service/UserService.java | 88 ++++- .../user/service/UserVerificationService.java | 88 ++++- ...itional-spring-configuration-metadata.json | 17 + .../config/dsspringuserconfig.properties | 8 + .../spring/user/service/TokenHasherTest.java | 70 ++++ .../service/TokenHashingSecurityTest.java | 363 ++++++++++++++++++ .../user/service/UserEmailServiceTest.java | 23 +- .../spring/user/service/UserServiceTest.java | 11 + .../service/UserVerificationServiceTest.java | 2 +- 17 files changed, 854 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java diff --git a/CONFIG.md b/CONFIG.md index 94c58e4..f898087 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -69,6 +69,16 @@ user: - **Account Lockout Duration (`spring.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout. - **BCrypt Strength (`spring.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`. +### Token Security + +Verification and password-reset tokens are **hashed at rest**. The raw token is only ever sent to the user in the emailed link; the database stores its hash. Lookups hash the incoming token and match by hash, with a transparent fallback to plaintext lookup so that any links issued before upgrading keep working until they expire. This requires no schema migration and no action from consuming applications. + +- **Token Hash Secret (`user.security.tokenHashSecret`)**: Optional secret used to key the at-rest hashing (HMAC-SHA-256) of verification and password-reset tokens. If left unset, plain SHA-256 is used, which is adequate because tokens are high-entropy random values. Setting a secret (kept outside the database) adds defense-in-depth against a database-only compromise. Default: unset. +- **Password Reset Token Lifetime (`user.security.passwordResetTokenValidityMinutes`)**: Lifetime in minutes of a password reset token before it expires. Default is `1440` (24 hours). +- **Verification Token Lifetime (`user.registration.verificationTokenValidityMinutes`)**: Lifetime in minutes of a registration verification token before it expires. Default is `1440` (24 hours). + +Only one active token per user is kept for each token type: requesting a new password reset or verification email invalidates the previous one. + ## WebAuthn / Passkey Settings Provides passwordless login using biometrics, security keys, or device authentication. **HTTPS is required** for WebAuthn to function. diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java index ff631d7..fc478c7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -265,13 +265,20 @@ public ResponseEntity savePassword(@Valid @RequestBody SavePasswor return buildErrorResponse(String.join(" ", errors), 4, HttpStatus.BAD_REQUEST); } - // Save the new password (this also saves to history) - userService.changeUserPassword(user, savePasswordDto.getNewPassword()); + // Atomically consume the reset token: this validates the token is still present and + // deletes it in a single transaction so it cannot be double-consumed by a concurrent + // request. If it returns null, the token was already used or expired between validation + // above and now. + User consumedUser = userService.validateAndConsumePasswordResetToken(savePasswordDto.getToken()); + if (consumedUser == null) { + return buildErrorResponse(messages.getMessage("auth.message.invalid", null, "Invalid token", locale), 3, + HttpStatus.BAD_REQUEST); + } - // Delete the reset token (it's been used) - userService.deletePasswordResetToken(savePasswordDto.getToken()); + // Save the new password (this also saves to history) + userService.changeUserPassword(consumedUser, savePasswordDto.getNewPassword()); - logAuditEvent("PasswordReset", "Success", "Password reset completed", user, request); + logAuditEvent("PasswordReset", "Success", "Password reset completed", consumedUser, request); return buildSuccessResponse(messages.getMessage("message.reset-password.success", null, "Password has been reset successfully", locale), "/user/login.html"); diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java index 061f1f8..e74b158 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java @@ -18,7 +18,7 @@ @Entity public class PasswordResetToken { - /** The Constant EXPIRATION. */ + /** The Constant EXPIRATION. Default token lifetime in minutes (24h) used by the legacy constructors. */ private static final int EXPIRATION = 60 * 24; /** The id. */ @@ -68,6 +68,20 @@ public PasswordResetToken(final String token, final User user) { this.expiryDate = calculateExpiryDate(EXPIRATION); } + /** + * Instantiates a new password reset token with a configurable lifetime. + * + * @param token the token (already hashed for storage by the calling service) + * @param user the user + * @param expiryTimeInMinutes the token lifetime in minutes + */ + public PasswordResetToken(final String token, final User user, final int expiryTimeInMinutes) { + super(); + this.token = token; + this.user = user; + this.expiryDate = calculateExpiryDate(expiryTimeInMinutes); + } + /** * Calculate expiry date. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java index 615969e..0aa5f58 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; +import jakarta.persistence.Transient; import lombok.Data; /** @@ -19,7 +20,7 @@ @Entity public class VerificationToken { - /** The Constant EXPIRATION. */ + /** The Constant EXPIRATION. Default token lifetime in minutes (24h) used by the legacy constructors. */ private static final int EXPIRATION = 60 * 24; /** The id. */ @@ -38,6 +39,15 @@ public class VerificationToken { /** The expiry date. */ private Date expiryDate; + /** + * The raw (unhashed) token value. This is transient and never persisted: the {@link #token} + * column holds only the hash. It carries the raw token back to a caller (e.g. so a verification + * email link can be built) when a service regenerates a token. It is {@code null} on entities + * loaded from the database. + */ + @Transient + private transient String plainToken; + /** * Instantiates a new verification token. */ @@ -69,6 +79,20 @@ public VerificationToken(final String token, final User user) { this.expiryDate = calculateExpiryDate(EXPIRATION); } + /** + * Instantiates a new verification token with a configurable lifetime. + * + * @param token the token (already hashed for storage by the calling service) + * @param user the user + * @param expiryTimeInMinutes the token lifetime in minutes + */ + public VerificationToken(final String token, final User user, final int expiryTimeInMinutes) { + super(); + this.token = token; + this.user = user; + this.expiryDate = calculateExpiryDate(expiryTimeInMinutes); + } + /** * Calculate expiry date. * @@ -83,7 +107,7 @@ private Date calculateExpiryDate(final int expiryTimeInMinutes) { } /** - * Update token. + * Update token, resetting the expiry to the default (24h) lifetime. * * @param token the token */ @@ -92,4 +116,15 @@ public void updateToken(final String token) { this.expiryDate = calculateExpiryDate(EXPIRATION); } + /** + * Update token with a configurable lifetime. + * + * @param token the token (already hashed for storage by the calling service) + * @param expiryTimeInMinutes the token lifetime in minutes + */ + public void updateToken(final String token, final int expiryTimeInMinutes) { + this.token = token; + this.expiryDate = calculateExpiryDate(expiryTimeInMinutes); + } + } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java index 7287caa..394565b 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java @@ -29,6 +29,13 @@ public interface PasswordResetTokenRepository extends JpaRepository + * The raw token (a high-entropy, URL-safe value) is what gets emailed to the user. Only the + * hash of that token is persisted in the database. On lookup, the service hashes the + * incoming raw token and queries by the hash. Because the hash is deterministic, the same raw token + * always maps to the same stored value, which is what makes lookup-by-hash possible. + *

+ * + *

+ * Keyed vs. plain. When {@code user.security.tokenHashSecret} is configured, this + * class uses HMAC-SHA-256 keyed by that secret. Otherwise it falls back to a plain SHA-256 digest. + *

+ *
    + *
  • Plain SHA-256 is adequate here because the tokens are themselves high-entropy + * random values (256 bits of entropy). An attacker who steals the database cannot feasibly reverse + * the hash or guess the pre-image, so even unkeyed hashing prevents the stored value from being used + * directly as a token.
  • + *
  • HMAC with a secret adds defense-in-depth against a database-only compromise: + * without the application secret (stored outside the DB), an attacker cannot pre-compute or verify + * candidate hashes offline at all. Configure a secret if you want the stored hashes to be useless to + * anyone who only has the database.
  • + *
+ * + *

+ * The output is a lowercase hexadecimal string (64 characters), which fits comfortably in the + * existing {@code String token} column — so enabling hashing requires no schema migration. + *

+ */ +@Slf4j +@Component +public class TokenHasher { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String DIGEST_ALGORITHM = "SHA-256"; + + /** Optional secret. When present, HMAC-SHA-256 is used; otherwise plain SHA-256. */ + private final String tokenHashSecret; + + /** + * Instantiates a new token hasher. + * + * @param tokenHashSecret the optional secret used to key the HMAC; may be {@code null} or blank, + * in which case plain SHA-256 is used + */ + public TokenHasher(@Value("${user.security.tokenHashSecret:#{null}}") final String tokenHashSecret) { + this.tokenHashSecret = tokenHashSecret; + if (StringUtils.hasText(tokenHashSecret)) { + log.debug("TokenHasher initialized with a configured secret (HMAC-SHA-256)."); + } else { + log.debug("TokenHasher initialized without a secret (plain SHA-256). " + + "Set user.security.tokenHashSecret for keyed hashing."); + } + } + + /** + * Hashes the given raw token deterministically. + * + * @param rawToken the raw token value (the value emailed to the user) + * @return the lowercase hex-encoded hash, or {@code null} if {@code rawToken} is {@code null} + */ + public String hash(final String rawToken) { + if (rawToken == null) { + return null; + } + try { + final byte[] digest; + if (StringUtils.hasText(tokenHashSecret)) { + final Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(tokenHashSecret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + digest = mac.doFinal(rawToken.getBytes(StandardCharsets.UTF_8)); + } else { + final MessageDigest md = MessageDigest.getInstance(DIGEST_ALGORITHM); + digest = md.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + } + return toHex(digest); + } catch (final NoSuchAlgorithmException | java.security.InvalidKeyException e) { + // SHA-256 / HmacSHA256 are guaranteed by the JCA spec; this should never happen. + throw new IllegalStateException("Failed to hash token", e); + } + } + + /** + * Converts a byte array to a lowercase hex string. + * + * @param bytes the bytes + * @return the hex string + */ + private static String toHex(final byte[] bytes) { + final StringBuilder sb = new StringBuilder(bytes.length * 2); + for (final byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java index 9303074..263aa6e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -14,6 +14,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.mail.MailService; import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; @@ -57,10 +58,17 @@ public class UserEmailService { /** The session invalidation service. */ private final SessionInvalidationService sessionInvalidationService; + /** Hashes tokens before they are stored at rest. */ + private final TokenHasher tokenHasher; + /** The configured app URL for admin-initiated password resets. */ @Value("${user.admin.appUrl:#{null}}") private String configuredAppUrl; + /** Password reset token lifetime in minutes. Defaults to 24h. */ + @Value("${user.security.passwordResetTokenValidityMinutes:1440}") + private int passwordResetTokenValidityMinutes; + /** ObjectMapper for JSON serialization in audit events. */ private final ObjectMapper objectMapper = new ObjectMapper(); @@ -200,11 +208,21 @@ private String generateToken() { /** * Creates the password reset token for user. * + *

+ * The token is hashed before storage (the raw value goes into the emailed link). Any existing + * token for the user is deleted first so that only one active reset token exists per user. + *

+ * * @param user the user - * @param token the token + * @param token the raw token (emailed to the user) */ + @Transactional public void createPasswordResetTokenForUser(final User user, final String token) { - final PasswordResetToken myToken = new PasswordResetToken(token, user); + // Single active token per user: remove any previously issued token before creating a new one. + passwordTokenRepository.deleteByUser(user); + // Store only the hash of the token; the raw token is what was emailed to the user. + final PasswordResetToken myToken = + new PasswordResetToken(tokenHasher.hash(token), user, passwordResetTokenValidityMinutes); passwordTokenRepository.save(myToken); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 4679509..362308f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -226,6 +226,9 @@ public String getValue() { private final SessionInvalidationService sessionInvalidationService; + /** Hashes tokens before they are stored / looked up at rest. */ + private final TokenHasher tokenHasher; + /** The send registration verification email flag. */ @Value("${user.registration.sendVerificationEmail:false}") private boolean sendRegistrationVerificationEmail; @@ -400,27 +403,50 @@ public User findUserByEmail(final String email) { return userRepository.findByEmail(email.toLowerCase()); } + /** + * Resolves a password reset token by its raw value using a dual-read strategy. + * + *

+ * Tokens are stored hashed, so we first look up by {@code hash(rawToken)}. For backward + * compatibility we fall back to looking up by the raw value, which resolves any pre-upgrade + * tokens that were stored in plaintext before token hashing was introduced. This fallback is + * permanently safe and needs no operator action to retire: every token carries an + * {@code expiryDate} bounded by the configured lifetime, and the validate path rejects expired + * tokens, so any lingering plaintext token becomes unusable within its lifetime window. + *

+ * + * @param rawToken the raw token value + * @return the resolved token entity, or {@code null} if not found + */ + private PasswordResetToken resolvePasswordResetToken(final String rawToken) { + if (rawToken == null) { + return null; + } + PasswordResetToken token = passwordTokenRepository.findByToken(tokenHasher.hash(rawToken)); + if (token == null) { + token = passwordTokenRepository.findByToken(rawToken); + } + return token; + } + /** * Gets the password reset token. * - * @param token the token + * @param token the raw token * @return the password reset token */ public PasswordResetToken getPasswordResetToken(final String token) { - return passwordTokenRepository.findByToken(token); + return resolvePasswordResetToken(token); } /** * Gets the user by password reset token. * - * @param token the token + * @param token the raw token * @return the user by password reset token */ public Optional getUserByPasswordResetToken(final String token) { - if (token == null) { - return Optional.empty(); - } - PasswordResetToken passwordResetToken = passwordTokenRepository.findByToken(token); + PasswordResetToken passwordResetToken = resolvePasswordResetToken(token); if (passwordResetToken == null) { return Optional.empty(); } @@ -429,18 +455,56 @@ public Optional getUserByPasswordResetToken(final String token) { /** * Deletes a password reset token after it has been used. - * Uses a direct DELETE query for efficiency (no SELECT required). * - * @param token the token string to delete + *

+ * Uses dual-delete to match the dual-read lookup: deletes the hashed value first, then falls back + * to the raw value to clean up any pre-upgrade plaintext token. + *

+ * + * @param token the raw token string to delete */ public void deletePasswordResetToken(final String token) { if (token == null) { return; } - int deletedCount = passwordTokenRepository.deleteByToken(token); + int deletedCount = passwordTokenRepository.deleteByToken(tokenHasher.hash(token)); + if (deletedCount == 0) { + deletedCount = passwordTokenRepository.deleteByToken(token); + } if (deletedCount > 0) { - log.debug("Deleted password reset token: {}", token); + log.debug("Deleted used password reset token."); + } + } + + /** + * Atomically validates and consumes a password reset token in a single transaction. + * + *

+ * This prevents a token from being double-consumed: validation and deletion happen together, so + * two concurrent reset attempts cannot both succeed with the same token. Returns the associated + * user when the token is valid (and deletes it), or {@code null} when the token is missing or + * expired (expired tokens are also deleted as a cleanup). Uses dual-read so both hashed + * (post-upgrade) and plaintext (pre-upgrade) tokens resolve. + *

+ * + * @param token the raw token to validate and consume + * @return the user associated with the token if it was valid, otherwise {@code null} + */ + @Transactional + public User validateAndConsumePasswordResetToken(final String token) { + final PasswordResetToken passToken = resolvePasswordResetToken(token); + if (passToken == null) { + return null; + } + final Calendar cal = Calendar.getInstance(); + if (passToken.getExpiryDate().before(cal.getTime())) { + passwordTokenRepository.delete(passToken); + return null; } + final User user = passToken.getUser(); + // Consume the token immediately so it cannot be reused. + passwordTokenRepository.delete(passToken); + return user; } /** @@ -580,7 +644,7 @@ private boolean emailExists(final String email) { * @return the password reset token validation result enum */ public TokenValidationResult validatePasswordResetToken(String token) { - final PasswordResetToken passToken = passwordTokenRepository.findByToken(token); + final PasswordResetToken passToken = resolvePasswordResetToken(token); if (passToken == null) { return TokenValidationResult.INVALID_TOKEN; } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index c413b3c..7449bdf 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -2,7 +2,9 @@ import java.util.Calendar; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; @@ -32,6 +34,39 @@ public class UserVerificationService { /** The token repository. */ private final VerificationTokenRepository tokenRepository; + /** Hashes tokens before they are stored at rest. */ + private final TokenHasher tokenHasher; + + /** Verification token lifetime in minutes. Defaults to 24h. */ + @Value("${user.registration.verificationTokenValidityMinutes:1440}") + private int verificationTokenValidityMinutes; + + /** + * Resolves a verification token by its raw value using a dual-read strategy. + * + *

+ * Tokens are stored hashed, so we first look up by {@code hash(rawToken)}. For backward + * compatibility we fall back to looking up by the raw value, which resolves any pre-upgrade + * tokens that were stored in plaintext before token hashing was introduced. This fallback is + * permanently safe and needs no operator action to retire: every token carries an + * {@code expiryDate} bounded by the configured lifetime, and the validate path rejects expired + * tokens, so any lingering plaintext token becomes unusable within its lifetime window. + *

+ * + * @param rawToken the raw token value + * @return the resolved token entity, or {@code null} if not found + */ + private VerificationToken resolveByRawToken(final String rawToken) { + if (rawToken == null) { + return null; + } + VerificationToken token = tokenRepository.findByToken(tokenHasher.hash(rawToken)); + if (token == null) { + token = tokenRepository.findByToken(rawToken); + } + return token; + } + /** * Gets the user by verification token. * @@ -40,7 +75,7 @@ public class UserVerificationService { */ public User getUserByVerificationToken(final String verificationToken) { log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", verificationToken); - final VerificationToken token = tokenRepository.findByToken(verificationToken); + final VerificationToken token = resolveByRawToken(verificationToken); if (token != null) { log.debug("UserVerificationService.getUserByVerificationToken: user found: {}", token.getUser()); return token.getUser(); @@ -56,19 +91,33 @@ public User getUserByVerificationToken(final String verificationToken) { * @return the verification token entity */ public VerificationToken getVerificationToken(final String verificationToken) { - return tokenRepository.findByToken(verificationToken); + return resolveByRawToken(verificationToken); } /** - * Generates a new verification token to replace an existing one. - * Useful for extending verification periods or re-sending verification emails. + * Generates a new verification token to replace an existing one. Useful for extending + * verification periods or re-sending verification emails. + * + *

+ * A fresh high-entropy raw token is generated. Only its hash is persisted in the + * {@code token} column (consistent with {@link #createVerificationTokenForUser}); the raw value + * is returned to the caller via {@link VerificationToken#getPlainToken()} so a verification email + * link can be built. The expiry is set from the configurable + * {@code user.registration.verificationTokenValidityMinutes} (not a hardcoded 24h). The existing + * row is updated in place, preserving the single-active-token invariant. + *

* * @param existingVerificationToken the existing verification token string to replace - * @return the updated verification token entity with a new token value + * @return the updated verification token entity. Its persisted {@code token} is the hash; the raw + * value is available via {@link VerificationToken#getPlainToken()}. */ + @Transactional public VerificationToken generateNewVerificationToken(final String existingVerificationToken) { - VerificationToken vToken = tokenRepository.findByToken(existingVerificationToken); - vToken.updateToken(UUID.randomUUID().toString()); + VerificationToken vToken = resolveByRawToken(existingVerificationToken); + final String rawToken = UUID.randomUUID().toString(); + // Store the hash of the new raw token; the raw value is what gets emailed to the user. + vToken.updateToken(tokenHasher.hash(rawToken), verificationTokenValidityMinutes); + vToken.setPlainToken(rawToken); vToken = tokenRepository.save(vToken); return vToken; } @@ -76,22 +125,33 @@ public VerificationToken generateNewVerificationToken(final String existingVerif /** * Creates the verification token for user. * + *

+ * The token is hashed before storage (the raw value goes into the emailed link). Any existing + * token for the user is deleted first so that only one active verification token exists per user. + *

+ * * @param user the user - * @param token the token + * @param token the raw token (emailed to the user) */ + @Transactional public void createVerificationTokenForUser(final User user, final String token) { - final VerificationToken myToken = new VerificationToken(token, user); + // Single active token per user: remove any previously issued token before creating a new one. + tokenRepository.deleteByUser(user); + // Store only the hash of the token; the raw token is what was emailed to the user. + final VerificationToken myToken = + new VerificationToken(tokenHasher.hash(token), user, verificationTokenValidityMinutes); tokenRepository.save(myToken); } /** - * Validates a user verification token. + * Validates a user verification token and, when valid, atomically consumes it by enabling the + * user. Uses dual-read so both hashed (post-upgrade) and plaintext (pre-upgrade) tokens resolve. * - * @param token the token to validate + * @param token the raw token to validate * @return the token validation result (VALID, INVALID_TOKEN, or EXPIRED) */ public UserService.TokenValidationResult validateVerificationToken(String token) { - final VerificationToken verificationToken = tokenRepository.findByToken(token); + final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken == null) { return UserService.TokenValidationResult.INVALID_TOKEN; } @@ -111,11 +171,11 @@ public UserService.TokenValidationResult validateVerificationToken(String token) /** * Delete verification token. * - * @param token the token + * @param token the raw token */ public void deleteVerificationToken(final String token) { log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", token); - final VerificationToken verificationToken = tokenRepository.findByToken(token); + final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken != null) { tokenRepository.delete(verificationToken); log.debug("UserVerificationService.deleteVerificationToken: token deleted."); diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 240a4c4..f978735 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -216,6 +216,23 @@ "type": "java.lang.Integer", "description": "BCrypt hash strength (4-31, default 10)" }, + { + "name": "user.security.tokenHashSecret", + "type": "java.lang.String", + "description": "Optional secret used to key the at-rest hashing (HMAC-SHA-256) of verification and password-reset tokens. If unset, plain SHA-256 is used. Setting a secret adds defense-in-depth against a database-only compromise." + }, + { + "name": "user.security.passwordResetTokenValidityMinutes", + "type": "java.lang.Integer", + "description": "Lifetime in minutes of a password reset token before it expires. Default 1440 (24 hours).", + "defaultValue": 1440 + }, + { + "name": "user.registration.verificationTokenValidityMinutes", + "type": "java.lang.Integer", + "description": "Lifetime in minutes of a registration verification token before it expires. Default 1440 (24 hours).", + "defaultValue": 1440 + }, { "name": "user.security.testHashTime", "type": "java.lang.Boolean", diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 8d5e0f1..57dd021 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -43,6 +43,8 @@ user.actuallyDeleteAccount=false # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. user.registration.sendVerificationEmail=true +# The lifetime, in minutes, of a registration verification token before it expires. Default is 1440 (24 hours). +user.registration.verificationTokenValidityMinutes=1440 # If true, Google OAuth2 will be enabled for registration. user.registration.googleEnabled=false @@ -59,6 +61,12 @@ user.security.failedLoginAttempts=10 user.security.accountLockoutDuration=30 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31. user.security.bcryptStrength=12 +# Optional secret used to key the at-rest hashing of verification and password-reset tokens (HMAC-SHA-256). +# If left unset, tokens are hashed with plain SHA-256, which is adequate because tokens are high-entropy. +# Setting a secret adds defense-in-depth against a database-only compromise. Default: unset. +# user.security.tokenHashSecret= +# The lifetime, in minutes, of a password reset token before it expires. Default is 1440 (24 hours). +user.security.passwordResetTokenValidityMinutes=1440 # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. user.security.testHashTime=true # The default action for all requests. This can be either deny or allow. diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java new file mode 100644 index 0000000..9213b3b --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java @@ -0,0 +1,70 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TokenHasher}. + */ +@DisplayName("TokenHasher Tests") +class TokenHasherTest { + + @Test + @DisplayName("hash is deterministic - same input yields same output (plain SHA-256)") + void shouldProduceDeterministicHashWhenNoSecretConfigured() { + TokenHasher hasher = new TokenHasher(null); + String raw = "my-high-entropy-token"; + + String first = hasher.hash(raw); + String second = hasher.hash(raw); + + assertThat(first).isEqualTo(second); + } + + @Test + @DisplayName("hashed value is not equal to the raw token") + void shouldNotReturnRawTokenWhenHashing() { + TokenHasher hasher = new TokenHasher(null); + String raw = "my-high-entropy-token"; + + assertThat(hasher.hash(raw)).isNotEqualTo(raw); + } + + @Test + @DisplayName("hash output is a 64-char lowercase hex string (SHA-256)") + void shouldReturnHexEncodedSha256() { + TokenHasher hasher = new TokenHasher(null); + + assertThat(hasher.hash("token")).matches("[0-9a-f]{64}"); + } + + @Test + @DisplayName("keyed HMAC differs from plain SHA-256 for the same input") + void shouldProduceDifferentHashWhenSecretConfigured() { + TokenHasher plain = new TokenHasher(null); + TokenHasher keyed = new TokenHasher("super-secret-key"); + String raw = "my-high-entropy-token"; + + assertThat(keyed.hash(raw)).isNotEqualTo(plain.hash(raw)); + } + + @Test + @DisplayName("keyed HMAC is deterministic with the same secret") + void shouldProduceDeterministicHashWhenSecretConfigured() { + TokenHasher keyed = new TokenHasher("super-secret-key"); + String raw = "my-high-entropy-token"; + + assertThat(keyed.hash(raw)).isEqualTo(keyed.hash(raw)); + } + + @Test + @DisplayName("blank secret falls back to plain SHA-256 behavior") + void shouldTreatBlankSecretAsUnset() { + TokenHasher blank = new TokenHasher(" "); + TokenHasher plain = new TokenHasher(null); + + assertThat(blank.hash("token")).isEqualTo(plain.hash("token")); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java new file mode 100644 index 0000000..9494599 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -0,0 +1,363 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Calendar; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import com.digitalsanctuary.spring.user.mail.MailService; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +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.test.builders.UserTestDataBuilder; + +/** + * Security tests for token-at-rest hashing, single-active-token enforcement, dual-read backward + * compatibility, configurable lifetime, and atomic consume. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Token Hashing Security Tests") +class TokenHashingSecurityTest { + + private final TokenHasher tokenHasher = new TokenHasher(null); + + private User testUser; + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aUser().withId(1L).withEmail("test@example.com").enabled().build(); + } + + private Date future(int minutes) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.MINUTE, minutes); + return cal.getTime(); + } + + private Date past(int minutes) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.MINUTE, -minutes); + return cal.getTime(); + } + + // --------------------------------------------------------------------------------------------- + // Password Reset Token tests + // --------------------------------------------------------------------------------------------- + @Nested + @DisplayName("Password Reset Token") + class PasswordResetTokenTests { + + @Mock + private MailService mailService; + @Mock + private UserVerificationService userVerificationService; + @Mock + private PasswordResetTokenRepository passwordTokenRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private SessionInvalidationService sessionInvalidationService; + + private UserEmailService userEmailService; + + @BeforeEach + void initService() { + userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, + eventPublisher, sessionInvalidationService, tokenHasher); + } + + @Test + @DisplayName("(a) stored token value is the HASH, not the raw token") + void shouldStoreHashedTokenNotRawToken() { + String rawToken = "raw-reset-token-value"; + + userEmailService.createPasswordResetTokenForUser(testUser, rawToken); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PasswordResetToken.class); + verify(passwordTokenRepository).save(captor.capture()); + String stored = captor.getValue().getToken(); + + assertThat(stored).isNotEqualTo(rawToken); + assertThat(stored).isEqualTo(tokenHasher.hash(rawToken)); + } + + @Test + @DisplayName("(c) creating a second reset token deletes the first (single active token)") + void shouldDeleteExistingTokenWhenCreatingNewOne() { + userEmailService.createPasswordResetTokenForUser(testUser, "raw"); + + verify(passwordTokenRepository).deleteByUser(testUser); + } + + @Test + @DisplayName("(e) expiry honors the configured minutes") + void shouldHonorConfiguredLifetime() { + ReflectionTestUtils.setField(userEmailService, "passwordResetTokenValidityMinutes", 30); + long before = System.currentTimeMillis(); + + userEmailService.createPasswordResetTokenForUser(testUser, "raw"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PasswordResetToken.class); + verify(passwordTokenRepository).save(captor.capture()); + long expiry = captor.getValue().getExpiryDate().getTime(); + // ~30 minutes out, well below the 1440 default + assertThat(expiry).isBetween(before + 25L * 60 * 1000, before + 35L * 60 * 1000); + } + } + + @Nested + @DisplayName("Password Reset Lookup / Consume") + class PasswordResetLookupTests { + + @Mock + private PasswordResetTokenRepository passwordTokenRepository; + + private UserService userService; + + @BeforeEach + void initService() { + userService = new UserService(null, null, passwordTokenRepository, null, null, null, null, null, null, null, + null, null, null, tokenHasher); + } + + @Test + @DisplayName("(b) lookup by RAW token resolves the entity stored under the HASH") + void shouldResolveByRawTokenWhenStoredAsHash() { + String rawToken = "raw-reset-token"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken entity = new PasswordResetToken(); + entity.setToken(hashed); + entity.setUser(testUser); + entity.setExpiryDate(future(60)); + when(passwordTokenRepository.findByToken(hashed)).thenReturn(entity); + + assertThat(userService.getUserByPasswordResetToken(rawToken)).contains(testUser); + } + + @Test + @DisplayName("(f) DUAL-READ: non-expired PLAINTEXT token (pre-upgrade) still resolves") + void shouldResolvePreUpgradePlaintextTokenWhenNotExpired() { + String rawToken = "legacy-plaintext-token"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken legacy = new PasswordResetToken(); + legacy.setToken(rawToken); // stored as plaintext before upgrade + legacy.setUser(testUser); + legacy.setExpiryDate(future(60)); + // hash lookup misses, raw lookup hits + when(passwordTokenRepository.findByToken(hashed)).thenReturn(null); + when(passwordTokenRepository.findByToken(rawToken)).thenReturn(legacy); + + assertThat(userService.validatePasswordResetToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.VALID); + assertThat(userService.getUserByPasswordResetToken(rawToken)).contains(testUser); + } + + @Test + @DisplayName("(g) DUAL-READ: EXPIRED plaintext token is REJECTED by validate") + void shouldRejectExpiredPreUpgradePlaintextToken() { + String rawToken = "legacy-expired-token"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken legacy = new PasswordResetToken(); + legacy.setToken(rawToken); + legacy.setUser(testUser); + legacy.setExpiryDate(past(60)); + when(passwordTokenRepository.findByToken(hashed)).thenReturn(null); + when(passwordTokenRepository.findByToken(rawToken)).thenReturn(legacy); + + assertThat(userService.validatePasswordResetToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.EXPIRED); + } + + @Test + @DisplayName("(d) reusing a consumed token fails (atomic consume deletes it)") + void shouldFailWhenReusingConsumedToken() { + String rawToken = "consume-me"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken entity = new PasswordResetToken(); + entity.setToken(hashed); + entity.setUser(testUser); + entity.setExpiryDate(future(60)); + + // First consume: token found, then deleted + when(passwordTokenRepository.findByToken(hashed)).thenReturn(entity, (PasswordResetToken) null); + lenient().when(passwordTokenRepository.findByToken(rawToken)).thenReturn(null); + + User consumed = userService.validateAndConsumePasswordResetToken(rawToken); + assertThat(consumed).isEqualTo(testUser); + verify(passwordTokenRepository).delete(entity); + + // Second consume: token no longer present -> null user + User second = userService.validateAndConsumePasswordResetToken(rawToken); + assertThat(second).isNull(); + } + } + + // --------------------------------------------------------------------------------------------- + // Verification Token tests + // --------------------------------------------------------------------------------------------- + @Nested + @DisplayName("Verification Token") + class VerificationTokenTests { + + @Mock + private UserRepository userRepository; + @Mock + private VerificationTokenRepository tokenRepository; + + private UserVerificationService verificationService; + + @BeforeEach + void initService() { + verificationService = new UserVerificationService(userRepository, tokenRepository, tokenHasher); + } + + @Test + @DisplayName("(a) stored verification token value is the HASH, not the raw token") + void shouldStoreHashedVerificationToken() { + String rawToken = "raw-verification-token"; + + verificationService.createVerificationTokenForUser(testUser, rawToken); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + assertThat(captor.getValue().getToken()).isEqualTo(tokenHasher.hash(rawToken)); + assertThat(captor.getValue().getToken()).isNotEqualTo(rawToken); + } + + @Test + @DisplayName("(c) creating a second verification token deletes the first") + void shouldDeleteExistingVerificationTokenWhenCreatingNewOne() { + verificationService.createVerificationTokenForUser(testUser, "raw"); + + verify(tokenRepository).deleteByUser(testUser); + } + + @Test + @DisplayName("(b) lookup by RAW token resolves entity stored under HASH") + void shouldResolveVerificationByRawTokenWhenStoredAsHash() { + String rawToken = "raw-verification"; + String hashed = tokenHasher.hash(rawToken); + VerificationToken entity = new VerificationToken(); + entity.setToken(hashed); + entity.setUser(testUser); + entity.setExpiryDate(future(60)); + when(tokenRepository.findByToken(hashed)).thenReturn(entity); + + assertThat(verificationService.getUserByVerificationToken(rawToken)).isEqualTo(testUser); + } + + @Test + @DisplayName("(f) DUAL-READ: non-expired PLAINTEXT verification token resolves and validates") + void shouldValidatePreUpgradePlaintextVerificationToken() { + String rawToken = "legacy-verify-token"; + String hashed = tokenHasher.hash(rawToken); + VerificationToken legacy = new VerificationToken(); + legacy.setToken(rawToken); + legacy.setUser(testUser); + legacy.setExpiryDate(future(60)); + when(tokenRepository.findByToken(hashed)).thenReturn(null); + when(tokenRepository.findByToken(rawToken)).thenReturn(legacy); + + assertThat(verificationService.validateVerificationToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.VALID); + } + + @Test + @DisplayName("(g) DUAL-READ: EXPIRED plaintext verification token is REJECTED") + void shouldRejectExpiredPreUpgradePlaintextVerificationToken() { + String rawToken = "legacy-verify-expired"; + String hashed = tokenHasher.hash(rawToken); + VerificationToken legacy = new VerificationToken(); + legacy.setToken(rawToken); + legacy.setUser(testUser); + legacy.setExpiryDate(past(60)); + when(tokenRepository.findByToken(hashed)).thenReturn(null); + when(tokenRepository.findByToken(rawToken)).thenReturn(legacy); + + assertThat(verificationService.validateVerificationToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.EXPIRED); + } + + @Test + @DisplayName("(e) verification expiry honors the configured minutes") + void shouldHonorConfiguredVerificationLifetime() { + ReflectionTestUtils.setField(verificationService, "verificationTokenValidityMinutes", 45); + long before = System.currentTimeMillis(); + + verificationService.createVerificationTokenForUser(testUser, "raw"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + long expiry = captor.getValue().getExpiryDate().getTime(); + assertThat(expiry).isBetween(before + 40L * 60 * 1000, before + 50L * 60 * 1000); + } + + @Test + @DisplayName("(h) generateNewVerificationToken stores the HASH and exposes the RAW token via plainToken") + void shouldStoreHashAndExposeRawTokenOnRegenerate() { + // An existing token resolved by raw value for regeneration. + VerificationToken existing = new VerificationToken(); + existing.setToken(tokenHasher.hash("old-raw")); + existing.setUser(testUser); + existing.setExpiryDate(future(10)); + when(tokenRepository.findByToken(tokenHasher.hash("old-raw"))).thenReturn(existing); + when(tokenRepository.save(any(VerificationToken.class))).thenAnswer(inv -> inv.getArgument(0)); + + VerificationToken regenerated = verificationService.generateNewVerificationToken("old-raw"); + + // The raw token is exposed for email-link building... + String raw = regenerated.getPlainToken(); + assertThat(raw).isNotBlank(); + // ...but the persisted column holds the HASH of that raw token, not the raw value. + assertThat(regenerated.getToken()).isEqualTo(tokenHasher.hash(raw)); + assertThat(regenerated.getToken()).isNotEqualTo(raw); + + // And the dual-read lookup path resolves the entity from the raw token. + when(tokenRepository.findByToken(tokenHasher.hash(raw))).thenReturn(regenerated); + assertThat(verificationService.getUserByVerificationToken(raw)).isEqualTo(testUser); + } + + @Test + @DisplayName("(i) regenerated token expiry honors the configured minutes, not a hardcoded 24h") + void shouldHonorConfiguredLifetimeOnRegenerate() { + ReflectionTestUtils.setField(verificationService, "verificationTokenValidityMinutes", 45); + VerificationToken existing = new VerificationToken(); + existing.setToken(tokenHasher.hash("old-raw")); + existing.setUser(testUser); + existing.setExpiryDate(future(10)); + when(tokenRepository.findByToken(tokenHasher.hash("old-raw"))).thenReturn(existing); + when(tokenRepository.save(any(VerificationToken.class))).thenAnswer(inv -> inv.getArgument(0)); + long before = System.currentTimeMillis(); + + VerificationToken regenerated = verificationService.generateNewVerificationToken("old-raw"); + + long expiry = regenerated.getExpiryDate().getTime(); + // ~45 minutes out, clearly distinct from the hardcoded 24h (1440m) default. + assertThat(expiry).isBetween(before + 40L * 60 * 1000, before + 50L * 60 * 1000); + } + + @Test + @DisplayName("(j) plainToken is JPA @Transient and never persisted") + void plainTokenIsNotPersisted() throws NoSuchFieldException { + assertThat(VerificationToken.class.getDeclaredField("plainToken") + .isAnnotationPresent(jakarta.persistence.Transient.class)).isTrue(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java index ef10400..e21fbac 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java @@ -19,7 +19,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; @@ -50,7 +49,9 @@ class UserEmailServiceTest { @Mock private SessionInvalidationService sessionInvalidationService; - @InjectMocks + // Real hasher (not a mock) so stored-vs-raw token assertions reflect production behavior. + private final TokenHasher tokenHasher = new TokenHasher(null); + private UserEmailService userEmailService; private User testUser; @@ -59,6 +60,8 @@ class UserEmailServiceTest { @BeforeEach void setUp() { + userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, + eventPublisher, sessionInvalidationService, tokenHasher); testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") @@ -116,8 +119,8 @@ void sendForgotPasswordVerificationEmail_sendsEmailWithCorrectParameters() { PasswordResetToken savedToken = tokenCaptor.getValue(); assertThat(savedToken.getUser()).isEqualTo(testUser); assertThat(savedToken.getToken()).isNotNull(); - // Base64 URL-safe encoded 32-byte token = 43 characters - assertThat(savedToken.getToken()).matches("[A-Za-z0-9_-]{43}"); + // Stored token is the HASH (64-char hex SHA-256), NOT the raw 43-char token. + assertThat(savedToken.getToken()).matches("[0-9a-f]{64}"); // Verify audit event was published ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditEvent.class); @@ -144,8 +147,13 @@ void sendForgotPasswordVerificationEmail_sendsEmailWithCorrectParameters() { assertThat(variables).containsKey("user"); assertThat(variables.get("appUrl")).isEqualTo(appUrl); assertThat(variables.get("user")).isEqualTo(testUser); - assertThat(variables.get("confirmationUrl")).asString() - .startsWith(appUrl + "/user/changePassword?token="); + // The emailed link must carry the RAW token (43-char URL-safe), while the DB stores its hash. + String confirmationUrl = (String) variables.get("confirmationUrl"); + assertThat(confirmationUrl).startsWith(appUrl + "/user/changePassword?token="); + String rawTokenInUrl = confirmationUrl.substring(confirmationUrl.indexOf("token=") + "token=".length()); + assertThat(rawTokenInUrl).matches("[A-Za-z0-9_-]{43}"); + // The stored (hashed) value is the hash of the raw token emailed to the user. + assertThat(savedToken.getToken()).isEqualTo(tokenHasher.hash(rawTokenInUrl)); } @Test @@ -191,7 +199,8 @@ void createPasswordResetTokenForUser_createsAndSavesToken() { ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(PasswordResetToken.class); verify(passwordTokenRepository).save(tokenCaptor.capture()); PasswordResetToken savedToken = tokenCaptor.getValue(); - assertThat(savedToken.getToken()).isEqualTo(token); + // The stored token is the HASH of the raw token, not the raw token itself. + assertThat(savedToken.getToken()).isEqualTo(tokenHasher.hash(token)); assertThat(savedToken.getUser()).isEqualTo(testUser); assertThat(savedToken.getExpiryDate()).isNotNull(); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index 43b30e0..f15b6b5 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -90,6 +90,8 @@ public class UserServiceTest { private PasswordHistoryRepository passwordHistoryRepository; @Mock private SessionInvalidationService sessionInvalidationService; + @Mock + private TokenHasher tokenHasher; @InjectMocks private UserService userService; private User testUser; @@ -301,6 +303,15 @@ void registerNewUserAccount_enablesUserWhenVerificationDisabled() { @DisplayName("Password Reset Token Tests") class PasswordResetTokenTests { + @BeforeEach + void stubHasher() { + // These tests stub findByToken with the raw token string. The service hashes the token + // before lookup (dual-read), so make the hasher identity here to keep the existing + // stubs valid. The hashing behavior itself is covered by TokenHashingSecurityTest. + org.mockito.Mockito.lenient().when(tokenHasher.hash(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + @Test @DisplayName("getPasswordResetToken - returns token when exists") void getPasswordResetToken_returnsTokenWhenExists() { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java index adefb74..c36a68f 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java @@ -40,7 +40,7 @@ void setUp() { testToken = new VerificationToken(); testToken.setUser(testUser); - userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository); + userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository, new TokenHasher(null)); } @Test From 65be92ba687a4c5ad8c441e105b60f170a476caf Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 20:48:23 -0600 Subject: [PATCH 12/55] fix(security): exclude password from User.toString; redact tokens/principals in logs --- .../user/controller/UserActionController.java | 22 +++++++++++++-- .../spring/user/persistence/model/User.java | 1 + .../user/service/DSOAuth2UserService.java | 8 +++--- .../user/service/DSOidcUserService.java | 4 +-- .../user/service/LoginSuccessService.java | 7 +++-- .../user/service/LogoutSuccessService.java | 3 +- .../spring/user/service/UserEmailService.java | 2 +- .../spring/user/service/UserService.java | 10 +++---- .../user/service/UserVerificationService.java | 25 +++++++++++++++-- .../spring/user/util/JpaAuditingConfig.java | 7 +++-- .../persistence/model/UserToStringTest.java | 28 +++++++++++++++++++ 11 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java index c0bb63f..9ac6e92 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java +++ b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java @@ -78,7 +78,7 @@ public class UserActionController { @GetMapping("${user.security.changePasswordURI:/user/changePassword}") public ModelAndView showChangePasswordPage(final HttpServletRequest request, final ModelMap model, @RequestParam("token") final String token) { - log.debug("UserAPI.showChangePasswordPage: called with token: {}", token); + log.debug("UserAPI.showChangePasswordPage: called with token: {}", tokenFingerprint(token)); final TokenValidationResult result = userService.validatePasswordResetToken(token); log.debug("UserAPI.showChangePasswordPage: result: {}", result); AuditEvent changePasswordAuditEvent = AuditEvent.builder().source(this).sessionId(request.getSession().getId()) @@ -111,7 +111,7 @@ public ModelAndView showChangePasswordPage(final HttpServletRequest request, fin @GetMapping("${user.security.registrationConfirmURI:/user/registrationConfirm}") public ModelAndView confirmRegistration(final HttpServletRequest request, final ModelMap model, @RequestParam("token") final String token) throws UnsupportedEncodingException { - log.debug("UserAPI.confirmRegistration: called with token: {}", token); + log.debug("UserAPI.confirmRegistration: called with token: {}", tokenFingerprint(token)); Locale locale = request.getLocale(); model.addAttribute("lang", locale.getLanguage()); final TokenValidationResult result = userVerificationService.validateVerificationToken(token); @@ -144,4 +144,22 @@ public ModelAndView confirmRegistration(final HttpServletRequest request, final String redirectString = "redirect:" + registrationNewVerificationURI; return new ModelAndView(redirectString, model); } + + /** + * Produces a non-reversible fingerprint of a token for safe logging. Never logs the full token + * value (which is sensitive authentication material). + * + * @param token the raw token value + * @return "null" if the token is null, "****" for short tokens, otherwise the first 6 characters + * followed by an ellipsis + */ + private String tokenFingerprint(final String token) { + if (token == null) { + return "null"; + } + if (token.length() <= 8) { + return "****"; + } + return token.substring(0, 6) + "…"; + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java index 4b71010..063e06d 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java @@ -73,6 +73,7 @@ public enum Provider { private Provider provider = Provider.LOCAL; /** The password. */ + @ToString.Exclude @Column(length = 60) private String password; diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index 6831f36..df88b88 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -161,11 +161,11 @@ private User updateExistingUser(User existingUser, User user) { * @return A User object representing the authenticated user. */ public User getUserFromGoogleOAuth2User(OAuth2User principal) { - log.debug("Getting user info from Google OAuth2 provider with principal: {}", principal); + log.debug("Getting user info from Google OAuth2 provider with principal: {}", principal != null ? principal.getName() : null); if (principal == null) { return null; } - log.debug("Principal attributes: {}", principal.getAttributes()); + log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); User user = new User(); String email = principal.getAttribute("email"); user.setEmail(email != null ? email.toLowerCase() : null); @@ -182,11 +182,11 @@ public User getUserFromGoogleOAuth2User(OAuth2User principal) { * @return A User object representing the authenticated user. */ public User getUserFromFacebookOAuth2User(OAuth2User principal) { - log.debug("Getting user info from Facebook OAuth2 provider with principal: {}", principal); + log.debug("Getting user info from Facebook OAuth2 provider with principal: {}", principal != null ? principal.getName() : null); if (principal == null) { return null; } - log.debug("Principal attributes: {}", principal.getAttributes()); + log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); User user = new User(); String email = principal.getAttribute("email"); user.setEmail(email != null ? email.toLowerCase() : null); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java index 2c7b0d6..65a606c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java @@ -168,11 +168,11 @@ private User updateExistingUser(User existingUser, User user) { * @return A User object representing the authenticated user. */ public User getUserFromKeycloakOidc2User(OidcUser principal) { - log.debug("Getting user info from Keycloak Oidc provider with principal: {}", principal); + log.debug("Getting user info from Keycloak Oidc provider with principal: {}", principal != null ? principal.getName() : null); if (principal == null) { return null; } - log.debug("Principal attributes: {}", principal.getAttributes()); + log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); User user = new User(); String email = principal.getEmail(); user.setEmail(email != null ? email.trim().toLowerCase(Locale.ROOT) : null); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java index 905ba76..b5ee404 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java @@ -57,7 +57,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo ServletException { log.debug("LoginSuccessService.onAuthenticationSuccess()"); log.debug("LoginSuccessService.onAuthenticationSuccess: called with request: {}", request); - log.debug("LoginSuccessService.onAuthenticationSuccess: called with authentication: {}", authentication); + log.debug("LoginSuccessService.onAuthenticationSuccess: called for user: {}", + authentication != null ? authentication.getName() : null); // Enhanced logging to check request attributes log.debug("Request URI: {}", request.getRequestURI()); @@ -72,12 +73,12 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo User user = null; if (authentication != null && authentication.getPrincipal() != null) { - log.debug("LoginSuccessService.onAuthenticationSuccess() authentication.getPrincipal(): " + authentication.getPrincipal()); + log.debug("LoginSuccessService.onAuthenticationSuccess() user: {}", authentication.getName()); log.debug("LoginSuccessService.onAuthenticatonSuccess() authentication.getClass(): " + authentication.getClass()); log.debug("LoginSuccessService.onAuthenticationSuccess() authentication.getPrincipal().getClass(): " + authentication.getPrincipal().getClass()); if (authentication.getPrincipal() instanceof DSUserDetails) { - log.debug("LoginSuccessService.onAuthenticationSuccess: DSUserDetails: {}", authentication.getPrincipal()); + log.debug("LoginSuccessService.onAuthenticationSuccess: DSUserDetails for user: {}", authentication.getName()); user = ((DSUserDetails) authentication.getPrincipal()).getUser(); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java index 4e81131..9644d80 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java @@ -51,7 +51,8 @@ public class LogoutSuccessService extends SimpleUrlLogoutSuccessHandler { public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { log.debug("LogoutSuccessService.onLogoutSuccess: called."); - log.debug("LogoutSuccessService.onAuthenticationSuccess: called with authentiation: {}", authentication); + log.debug("LogoutSuccessService.onLogoutSuccess: called for user: {}", + authentication != null ? authentication.getName() : null); log.debug("LogoutSuccessService.onAuthenticationSuccess: targetUrl: {}", super.determineTargetUrl(request, response)); User user = null; diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java index 263aa6e..3c8f5e9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -83,7 +83,7 @@ public class UserEmailService { * @throws IllegalArgumentException if appUrl is null, blank, or uses a dangerous scheme */ public void sendForgotPasswordVerificationEmail(final User user, final String appUrl) { - log.debug("UserEmailService.sendForgotPasswordVerificationEmail: called with user: {}", user); + log.debug("UserEmailService.sendForgotPasswordVerificationEmail: called for user: {}", user != null ? user.getEmail() : null); final String token = generateToken(); createPasswordResetTokenForUser(user, token); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 362308f..2ccfab2 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -354,9 +354,9 @@ private void cleanUpPasswordHistory(User user) { */ @Transactional public void deleteOrDisableUser(final User user) { - log.debug("UserService.deleteOrDisableUser: called with user: {}", user); + log.debug("UserService.deleteOrDisableUser: called for user: {}", user != null ? user.getEmail() : null); if (actuallyDeleteAccount) { - log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user); + log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user.getEmail()); // Capture user details before deletion for the post-delete event Long userId = user.getId(); String userEmail = user.getEmail(); @@ -383,7 +383,7 @@ public void deleteOrDisableUser(final User user) { log.debug("Publishing UserDeletedEvent"); eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail)); } else { - log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user); + log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user.getEmail()); user.setEnabled(false); userRepository.save(user); log.debug("UserService.deleteOrDisableUser: user {} has been disabled", user.getEmail()); @@ -603,7 +603,7 @@ public void setInitialPassword(User user, String rawPassword) { @Transactional(isolation = Isolation.SERIALIZABLE) public User registerPasswordlessAccount(final PasswordlessRegistrationDto dto) { TimeLogger timeLogger = new TimeLogger(log, "UserService.registerPasswordlessAccount"); - log.debug("UserService.registerPasswordlessAccount: called with dto: {}", dto); + log.debug("UserService.registerPasswordlessAccount: called for email: {}", dto != null ? dto.getEmail() : null); if (emailExists(dto.getEmail())) { log.debug("UserService.registerPasswordlessAccount: email already exists: {}", dto.getEmail()); @@ -687,7 +687,7 @@ public List getUsersFromSessionRegistry() { * @param user The user to authenticate without password verification */ public void authWithoutPassword(User user) { - log.debug("UserService.authWithoutPassword: authenticating user: {}", user); + log.debug("UserService.authWithoutPassword: authenticating user: {}", user != null ? user.getEmail() : null); if (user == null || user.getEmail() == null) { log.error("Invalid user or user email"); return; diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index 7449bdf..297d03c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -74,10 +74,11 @@ private VerificationToken resolveByRawToken(final String rawToken) { * @return the user by verification token */ public User getUserByVerificationToken(final String verificationToken) { - log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", verificationToken); + log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", tokenFingerprint(verificationToken)); final VerificationToken token = resolveByRawToken(verificationToken); if (token != null) { - log.debug("UserVerificationService.getUserByVerificationToken: user found: {}", token.getUser()); + log.debug("UserVerificationService.getUserByVerificationToken: user found: {}", + token.getUser() != null ? token.getUser().getEmail() : null); return token.getUser(); } log.debug("UserVerificationService.getUserByVerificationToken: no user found!"); @@ -174,7 +175,7 @@ public UserService.TokenValidationResult validateVerificationToken(String token) * @param token the raw token */ public void deleteVerificationToken(final String token) { - log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", token); + log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", tokenFingerprint(token)); final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken != null) { tokenRepository.delete(verificationToken); @@ -184,4 +185,22 @@ public void deleteVerificationToken(final String token) { } } + /** + * Produces a non-reversible fingerprint of a token for safe logging. Never logs the full token + * value (which is sensitive authentication material). + * + * @param token the raw token value + * @return "null" if the token is null, "****" for short tokens, otherwise the first 6 characters + * followed by an ellipsis + */ + private String tokenFingerprint(final String token) { + if (token == null) { + return "null"; + } + if (token.length() <= 8) { + return "****"; + } + return token.substring(0, 6) + "…"; + } + } diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java b/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java index 05fbaa6..0d32b92 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java @@ -65,16 +65,17 @@ private class AuditorAwareImpl implements AuditorAware { @Override public Optional getCurrentAuditor() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - log.debug("AuditorAwareImpl.getCurrentAuditor: Authentication: {}", authentication); + log.debug("AuditorAwareImpl.getCurrentAuditor: Authentication for user: {}", + authentication != null ? authentication.getName() : null); if (authentication == null || !authentication.isAuthenticated()) { return Optional.empty(); } - log.debug("AuditorAwareImpl.getCurrentAuditor: Principal: {}", authentication.getPrincipal()); + log.debug("AuditorAwareImpl.getCurrentAuditor: Principal for user: {}", authentication.getName()); if (authentication.getPrincipal() instanceof String) { - log.info("AuditorAwareImpl.getCurrentAuditor: principal is String: {}. Returning empty.", authentication.getPrincipal()); + log.info("AuditorAwareImpl.getCurrentAuditor: principal is String for user: {}. Returning empty.", authentication.getName()); return Optional.empty(); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java new file mode 100644 index 0000000..0c8596e --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java @@ -0,0 +1,28 @@ +package com.digitalsanctuary.spring.user.persistence.model; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; + +/** + * Unit tests verifying that {@link User#toString()} does not leak sensitive material. + * + *

The {@code password} field holds the bcrypt hash and must never appear in log output, which is + * commonly produced by toString(). This test guards the {@code @ToString.Exclude} annotation on that + * field.

+ */ +class UserToStringTest { + + @Test + void shouldNotIncludePasswordHashWhenToStringCalled() { + // Given a user with a (fake) password hash set + User user = new User(); + user.setEmail("user@test.com"); + user.setPassword("SUPERSECRET_HASH"); + + // When the user is rendered to a string (e.g. via a log statement) + String rendered = user.toString(); + + // Then the password hash must not be present + assertThat(rendered).doesNotContain("SUPERSECRET_HASH"); + } +} From 4e02429f77c003f9f08dacc985d2de66f0b7609d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 20:56:34 -0600 Subject: [PATCH 13/55] fix(security): validate email_verified where available; sanitize OAuth failure messages --- ...ingOAuth2AuthenticationFailureHandler.java | 83 ++++++++++++++++ .../user/security/WebSecurityConfig.java | 13 ++- .../user/service/DSOAuth2UserService.java | 34 +++++++ .../user/service/DSOidcUserService.java | 8 ++ ...Auth2AuthenticationFailureHandlerTest.java | 91 +++++++++++++++++ .../user/service/DSOAuth2UserServiceTest.java | 99 +++++++++++++++++++ .../user/service/DSOidcUserServiceTest.java | 62 ++++++++++++ 7 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java new file mode 100644 index 0000000..3d6449f --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java @@ -0,0 +1,83 @@ +package com.digitalsanctuary.spring.user.security; + +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * OAuth2/OIDC login failure handler that stores a GENERIC, user-safe message in the session for the UI to + * display, while logging the real exception detail server-side. + * + *

+ * Raw {@link AuthenticationException} messages can leak sensitive detail to the browser. In particular, the + * {@code LockedException}/{@code DisabledException} messages thrown during OAuth2/OIDC login (see Task 1.4) + * embed the account email, and provider-conflict messages reveal which provider an account is registered with. + * This handler ensures none of that raw detail reaches the user-facing {@code error.message} session attribute. + *

+ * + *

+ * The full exception (including its message and stack trace) is logged at {@code error}/{@code debug} level so + * operators retain the diagnostic detail server-side, where it is an acceptable place for it. + *

+ */ +@Slf4j +public class SanitizingOAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { + + /** Session attribute key the login page reads to display an error message. */ + static final String ERROR_MESSAGE_SESSION_ATTRIBUTE = "error.message"; + + /** OAuth2 error code raised when a provider explicitly reports the email is not verified. */ + static final String EMAIL_NOT_VERIFIED_ERROR_CODE = "email_not_verified"; + + /** Generic message shown to the user for any unspecified authentication failure. */ + public static final String GENERIC_FAILURE_MESSAGE = "Authentication failed. Please try again."; + + /** Slightly more specific (but still non-sensitive) message for unverified-email failures. */ + public static final String EMAIL_NOT_VERIFIED_MESSAGE = + "Your email address is not verified with your login provider. Please verify it and try again."; + + /** The login page URI to redirect to after a failure. */ + private final String loginPageURI; + + /** + * Creates a new handler. + * + * @param loginPageURI the URI to redirect the user back to after a failed login + */ + public SanitizingOAuth2AuthenticationFailureHandler(String loginPageURI) { + this.loginPageURI = loginPageURI; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException, ServletException { + // Log the real detail server-side only. Server logs are an acceptable place for sensitive detail. + log.error("OAuth2 login failure: {}", exception.getMessage()); + log.debug("OAuth2 login failure detail", exception); + + // Store ONLY a generic, non-sensitive message for the UI. Never the raw exception message. + request.getSession().setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception)); + response.sendRedirect(loginPageURI); + } + + /** + * Maps an authentication failure to a safe, user-facing message. A small number of failure categories map to + * a slightly more helpful (but still non-sensitive) message; everything else falls back to a fixed generic + * message. + * + * @param exception the authentication failure + * @return a generic, non-sensitive message safe to display to the user + */ + private String resolveUserFacingMessage(AuthenticationException exception) { + if (exception instanceof OAuth2AuthenticationException oauth2Exception && oauth2Exception.getError() != null + && EMAIL_NOT_VERIFIED_ERROR_CODE.equals(oauth2Exception.getError().getErrorCode())) { + return EMAIL_NOT_VERIFIED_MESSAGE; + } + return GENERIC_FAILURE_MESSAGE; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 444ac46..43dde36 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -217,13 +217,12 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe * @throws Exception the exception */ private void setupOAuth2(HttpSecurity http) throws Exception { - // Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean - http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> { - log.error("WebSecurityConfig.configure: OAuth2 login failure: {}", exception.getMessage()); - request.getSession().setAttribute("error.message", exception.getMessage()); - response.sendRedirect(loginPageURI); - // handler.onAuthenticationFailure(request, response, exception); - }).userInfoEndpoint(userInfo -> { + // Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean. + // The failure handler stores only a GENERIC message in the session for the UI (raw exception messages can + // leak account emails from Locked/Disabled exceptions and the registered provider from conflict errors); + // the real detail is logged server-side by the handler itself. + http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService) + .failureHandler(new SanitizingOAuth2AuthenticationFailureHandler(loginPageURI)).userInfoEndpoint(userInfo -> { userInfo.userService(dsOAuth2UserService); userInfo.oidcUserService(dsOidcUserService); })); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index df88b88..7e7d9f6 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -166,6 +166,13 @@ public User getUserFromGoogleOAuth2User(OAuth2User principal) { return null; } log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); + // Reject the login if Google explicitly reports the email as NOT verified. + // Providers that do not expose email_verified are trusted; only an explicit false is rejected. + if (isExplicitlyUnverified(principal.getAttribute("email_verified"))) { + log.warn("getUserFromGoogleOAuth2User: rejecting login because Google reports email_verified=false"); + throw new OAuth2AuthenticationException(new OAuth2Error("email_not_verified"), + "Your email address is not verified with your login provider."); + } User user = new User(); String email = principal.getAttribute("email"); user.setEmail(email != null ? email.toLowerCase() : null); @@ -175,6 +182,33 @@ public User getUserFromGoogleOAuth2User(OAuth2User principal) { return user; } + /** + * Determines whether an {@code email_verified} claim value represents an explicit "not verified" signal. + * + *

+ * Google's userinfo serializes {@code email_verified} as either a {@link Boolean} or a {@link String} + * ({@code "true"}/{@code "false"}) depending on the response format, so both are handled here. The value is + * treated as explicitly unverified only when it is {@link Boolean#FALSE} or the case-insensitive String + * {@code "false"}. An absent ({@code null}) claim is trusted and is NOT treated as unverified. + *

+ * + * @param emailVerified the raw {@code email_verified} attribute value (may be {@code Boolean}, {@code String}, or {@code null}) + * @return {@code true} only when the provider explicitly reports the email as not verified + */ + private boolean isExplicitlyUnverified(Object emailVerified) { + if (emailVerified == null) { + return false; + } + if (emailVerified instanceof Boolean booleanValue) { + return Boolean.FALSE.equals(booleanValue); + } + if (emailVerified instanceof String stringValue) { + return "false".equalsIgnoreCase(stringValue.trim()); + } + // Unknown type: trust (do not reject) rather than risk locking out legitimate users. + return false; + } + /** * Retrieves user information from a Facebook OAuth2User object. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java index 65a606c..eec24af 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java @@ -173,6 +173,14 @@ public User getUserFromKeycloakOidc2User(OidcUser principal) { return null; } log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); + // Reject the login if the OIDC provider explicitly reports the email as NOT verified. + // The standard OIDC email_verified claim is a Boolean. Providers that do not expose it are trusted; + // only an explicit false is rejected (an absent/null claim is trusted). + if (Boolean.FALSE.equals(principal.getEmailVerified())) { + log.warn("getUserFromKeycloakOidc2User: rejecting login because OIDC provider reports email_verified=false"); + throw new OAuth2AuthenticationException(new OAuth2Error("email_not_verified"), + "Your email address is not verified with your login provider."); + } User user = new User(); String email = principal.getEmail(); user.setEmail(email != null ? email.trim().toLowerCase(Locale.ROOT) : null); diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java new file mode 100644 index 0000000..1ff15f2 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java @@ -0,0 +1,91 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +/** + * Tests for {@link SanitizingOAuth2AuthenticationFailureHandler}, which must never leak raw exception + * messages (which may contain account emails from Locked/Disabled exceptions) to the user-facing session. + */ +@DisplayName("SanitizingOAuth2AuthenticationFailureHandler Tests") +class SanitizingOAuth2AuthenticationFailureHandlerTest { + + private static final String LOGIN_PAGE_URI = "/user/login.html"; + private static final String SESSION_ATTRIBUTE = "error.message"; + + private SanitizingOAuth2AuthenticationFailureHandler handler; + + @BeforeEach + void setUp() { + handler = new SanitizingOAuth2AuthenticationFailureHandler(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should store generic message (not raw exception) for LockedException with email") + void shouldNotLeakRawMessageForLockedException() throws Exception { + // Given - a LockedException whose message contains the account email (per Task 1.4) + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + LockedException raw = new LockedException("Account is locked for user secret.email@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + Object stored = session.getAttribute(SESSION_ATTRIBUTE); + assertThat(stored).isInstanceOf(String.class); + assertThat((String) stored).doesNotContain("secret.email@example.com"); + assertThat((String) stored).doesNotContain("Account is locked"); + assertThat((String) stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.GENERIC_FAILURE_MESSAGE); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should store generic message for arbitrary OAuth2AuthenticationException") + void shouldNotLeakRawMessageForOAuth2Exception() throws Exception { + // Given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2AuthenticationException raw = new OAuth2AuthenticationException( + new OAuth2Error("User Registered With Alternate Provider"), + "Looks like you're signed up with your GOOGLE account leaking@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then + String stored = (String) request.getSession(false).getAttribute(SESSION_ATTRIBUTE); + assertThat(stored).doesNotContain("leaking@example.com"); + assertThat(stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.GENERIC_FAILURE_MESSAGE); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should map email_not_verified error to a specific generic message") + void shouldMapEmailNotVerifiedToSpecificGenericMessage() throws Exception { + // Given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2AuthenticationException raw = new OAuth2AuthenticationException( + new OAuth2Error("email_not_verified"), "Email verified=false for victim@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then + String stored = (String) request.getSession(false).getAttribute(SESSION_ATTRIBUTE); + assertThat(stored).doesNotContain("victim@example.com"); + assertThat(stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.EMAIL_NOT_VERIFIED_MESSAGE); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java index 85dcf51..0a2b205 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java @@ -195,6 +195,105 @@ void shouldConvertEmailToLowercase() { } } + @Nested + @DisplayName("Google email_verified Tests") + class GoogleEmailVerifiedTests { + + @Test + @DisplayName("Should accept Google login when email_verified is Boolean true") + void shouldAcceptWhenEmailVerifiedBooleanTrue() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("verified@gmail.com") + .withAttribute("email_verified", Boolean.TRUE) + .build(); + + when(userRepository.findByEmail("verified@gmail.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOAuthLoginSuccess("google", googleUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("verified@gmail.com"); + } + + @Test + @DisplayName("Should accept Google login when email_verified is String \"true\"") + void shouldAcceptWhenEmailVerifiedStringTrue() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("verified-str@gmail.com") + .withAttribute("email_verified", "true") + .build(); + + when(userRepository.findByEmail("verified-str@gmail.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOAuthLoginSuccess("google", googleUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("verified-str@gmail.com"); + } + + @Test + @DisplayName("Should accept Google login when email_verified is absent (trusted)") + void shouldAcceptWhenEmailVerifiedAbsent() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("noclaim@gmail.com") + .withoutAttribute("email_verified") + .build(); + + when(userRepository.findByEmail("noclaim@gmail.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOAuthLoginSuccess("google", googleUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("noclaim@gmail.com"); + } + + @Test + @DisplayName("Should reject Google login when email_verified is Boolean false") + void shouldRejectWhenEmailVerifiedBooleanFalse() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("unverified@gmail.com") + .withAttribute("email_verified", Boolean.FALSE) + .build(); + + // When/Then + assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser)) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("email_not_verified")); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("Should reject Google login when email_verified is String \"false\"") + void shouldRejectWhenEmailVerifiedStringFalse() { + // Given - String "false" with mixed case to verify case-insensitive handling + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("unverified-str@gmail.com") + .withAttribute("email_verified", "False") + .build(); + + // When/Then + assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser)) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("email_not_verified")); + verify(userRepository, never()).save(any(User.class)); + } + } + @Nested @DisplayName("Facebook OAuth2 Tests") class FacebookOAuth2Tests { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java index 65f90f5..95dc7a7 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java @@ -212,6 +212,68 @@ void shouldHandleNullOidcUserInfo() { } } + @Nested + @DisplayName("OIDC email_verified Tests") + class OidcEmailVerifiedTests { + + @Test + @DisplayName("Should accept OIDC login when email_verified claim is true") + void shouldAcceptWhenEmailVerifiedTrue() { + // Given + OidcUser keycloakUser = OidcUserTestDataBuilder.keycloak() + .withEmail("verified@keycloak.com") + .withUserInfoClaim("email_verified", true) + .build(); + + when(userRepository.findByEmail("verified@keycloak.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("verified@keycloak.com"); + } + + @Test + @DisplayName("Should accept OIDC login when email_verified claim is absent (trusted)") + void shouldAcceptWhenEmailVerifiedAbsent() { + // Given + OidcUser keycloakUser = OidcUserTestDataBuilder.keycloak() + .withEmail("noclaim@keycloak.com") + .withoutUserInfoClaim("email_verified") + .build(); + + when(userRepository.findByEmail("noclaim@keycloak.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("noclaim@keycloak.com"); + } + + @Test + @DisplayName("Should reject OIDC login when email_verified claim is false") + void shouldRejectWhenEmailVerifiedFalse() { + // Given + OidcUser keycloakUser = OidcUserTestDataBuilder.keycloak() + .withEmail("unverified@keycloak.com") + .withUserInfoClaim("email_verified", false) + .build(); + + // When/Then + assertThatThrownBy(() -> service.handleOidcLoginSuccess("keycloak", keycloakUser)) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("email_not_verified")); + verify(userRepository, never()).save(any(User.class)); + } + } + @Nested @DisplayName("Provider Conflict Tests") class ProviderConflictTests { From e64abd738fc3e413d03915f4c86b85bfa42abd28 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 21:05:48 -0600 Subject: [PATCH 14/55] fix(security): rotate session id on programmatic login; wire LogoutSuccessService for logout audit --- .../user/security/WebSecurityConfig.java | 4 +- .../spring/user/service/UserService.java | 15 ++- .../security/LogoutAuditIntegrationTest.java | 99 +++++++++++++++++++ .../spring/user/service/UserServiceTest.java | 40 ++++++++ 4 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 43dde36..8f30a25 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -155,7 +155,9 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService)); } - http.logout(logout -> logout.logoutUrl(logoutActionURI).logoutSuccessUrl(logoutSuccessURI).invalidateHttpSession(true) + // Use the LogoutSuccessService handler (instead of logoutSuccessUrl) so logout publishes an audit event. + // The handler still redirects to logoutSuccessURI (see LogoutSuccessService.onLogoutSuccess). + http.logout(logout -> logout.logoutUrl(logoutActionURI).logoutSuccessHandler(logoutSuccessService).invalidateHttpSession(true) .deleteCookies("JSESSIONID")); // Register sessions in the SessionRegistry so SessionInvalidationService and concurrent-session diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 2ccfab2..cb68be1 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -744,9 +744,20 @@ private void storeSecurityContextInSession() { } HttpServletRequest request = servletRequestAttributes.getRequest(); - HttpSession session = request.getSession(true); + // Ensure a session exists before attempting to rotate its id. + request.getSession(true); - // Store the security context in the session + // Defend against session fixation on this programmatic-login path: issue a new session id + // (existing attributes are preserved) so a pre-auth fixed id cannot be reused post-authentication (OWASP). + try { + request.changeSessionId(); + } catch (IllegalStateException e) { + // No active session to rotate (shouldn't happen after getSession(true)); fall back to a fresh session below. + log.warn("UserService.storeSecurityContextInSession: could not rotate session id: {}", e.getMessage()); + } + + // Store the security context on the (now rotated) session. + HttpSession session = request.getSession(true); session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java new file mode 100644 index 0000000..f2ac762 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java @@ -0,0 +1,99 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +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.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.service.LogoutSuccessService; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Integration test verifying that the {@link LogoutSuccessService} audit-publishing handler is wired into the + * security filter chain and that logout still redirects to the configured {@code logoutSuccessURI}. + */ +@SecurityTest +@Import(LogoutAuditIntegrationTest.RealAuthenticationProviderConfig.class) +class LogoutAuditIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ApplicationEventPublisher eventPublisher; + + @Autowired + LogoutSuccessHandler logoutSuccessHandler; + + @Value("${user.security.logoutSuccessURI}") + String logoutSuccessURI; + + @Test + void logoutHandlerIsTheLogoutSuccessService() { + // The configured logout success handler must be the audit-publishing LogoutSuccessService. + assertThat(logoutSuccessHandler).isInstanceOf(LogoutSuccessService.class); + } + + @Test + void logoutPublishesAuditEventAndRedirectsToLogoutSuccessUri() throws Exception { + // Given an authenticated session (SecurityTestConfiguration provides user@test.com / "password"). + MvcResult loginResult = mockMvc.perform(formLogin("/user/login").user("username", "user@test.com").password("password")) + .andReturn(); + HttpSession session = loginResult.getRequest().getSession(false); + assertThat(session).isNotNull(); + + Mockito.clearInvocations(eventPublisher); + + // When the user logs out (POST to the configured logout URL with the authenticated session and a CSRF token). + mockMvc.perform(post("/user/logout").session((org.springframework.mock.web.MockHttpSession) session).with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(logoutSuccessURI)); + + // Then a Logout audit event must have been published by the wired LogoutSuccessService. + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(eventPublisher, Mockito.atLeastOnce()).publishEvent(eventCaptor.capture()); + + boolean publishedLogoutAudit = eventCaptor.getAllValues().stream().filter(e -> e instanceof AuditEvent) + .map(e -> (AuditEvent) e).anyMatch(e -> "Logout".equals(e.getAction())); + assertThat(publishedLogoutAudit).as("a Logout AuditEvent should be published on logout").isTrue(); + } + + /** + * Provides a real {@link DaoAuthenticationProvider} so that form login with a + * {@code UsernamePasswordAuthenticationToken} can succeed in the security test slice. + */ + @TestConfiguration + static class RealAuthenticationProviderConfig { + + @Bean + @Primary + AuthenticationManager testFormLoginAuthenticationManager(UserDetailsService userDetailsService) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + return new ProviderManager(provider); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index f15b6b5..c77cc38 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -35,6 +35,7 @@ import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -643,6 +644,45 @@ void authWithoutPassword_handlesNoRequestContext() { // Should not throw exception even when request context is null } } + + @Test + @DisplayName("authWithoutPassword - rotates the session id to defend against session fixation") + void shouldRotateSessionIdWhenAuthSucceeds() { + // Given a real request/session bound to the RequestContextHolder so that the servlet + // changeSessionId() contract is exercised faithfully (MockHttpServletRequest rotates the + // underlying MockHttpSession id while preserving attributes). + DSUserDetails userDetails = new DSUserDetails(testUser); + Collection authorities = Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); + + when(dsUserDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(userDetails); + when(authorityService.getAuthoritiesFromUser(testUser)).thenReturn((Collection) authorities); + + MockHttpServletRequest mockRequest = new MockHttpServletRequest(); + // Ensure a pre-auth session exists with a fixed id and a pre-existing attribute + HttpSession preAuthSession = mockRequest.getSession(true); + preAuthSession.setAttribute("preAuthAttr", "value"); + String preAuthSessionId = preAuthSession.getId(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest)); + + try { + // When + userService.authWithoutPassword(testUser); + + // Then - the session id must have rotated (fixation defense)... + HttpSession postAuthSession = mockRequest.getSession(false); + assertThat(postAuthSession).isNotNull(); + assertThat(postAuthSession.getId()) + .as("session id should change after programmatic login") + .isNotEqualTo(preAuthSessionId); + // ...while preserving existing session attributes... + assertThat(postAuthSession.getAttribute("preAuthAttr")).isEqualTo("value"); + // ...and the security context must be stored on the (rotated) session. + assertThat(postAuthSession.getAttribute("SPRING_SECURITY_CONTEXT")).isNotNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.clearContext(); + } + } } @Nested @DisplayName("Password Status Tests") From c1a13dcc30d312c61c0495c1ce9c3a280cc43a46 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 21:13:50 -0600 Subject: [PATCH 15/55] fix(concurrency): atomic failed-login increment to prevent lockout-evasion race --- .../repository/UserRepository.java | 21 +++++++ .../user/service/LoginAttemptService.java | 32 +++++------ .../repository/UserRepositoryTest.java | 47 +++++++++++++++ .../user/service/LoginAttemptServiceTest.java | 57 ++++++++++++++++--- 4 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java index 38e9599..03638a0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java @@ -2,6 +2,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.digitalsanctuary.spring.user.persistence.model.User; /** @@ -17,6 +20,24 @@ public interface UserRepository extends JpaRepository { */ User findByEmail(String email); + /** + * Atomically increments the failed login attempt counter for the user with the given email. + * + *

This is a single bulk UPDATE statement executed directly against the database, which avoids the lost-update race that a read-modify-write + * (read counter, increment in memory, save) would suffer under concurrent failed logins. Each concurrent increment is serialized by the database + * so no increment is lost, ensuring an attacker cannot evade lockout by hammering an account in parallel.

+ * + *

{@code flushAutomatically = true} flushes any pending persistence-context changes before the bulk update so they are not lost. + * {@code clearAutomatically = true} clears the persistence context afterward; the bulk UPDATE bypasses the first-level cache, so clearing ensures a + * subsequent {@code findByEmail} reads the fresh, incremented value from the database rather than a stale cached entity.

+ * + * @param email the email of the user whose counter should be incremented + * @return the number of rows affected (1 if the user exists, 0 otherwise) + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update User u set u.failedLoginAttempts = u.failedLoginAttempts + 1 where u.email = :email") + int incrementFailedAttempts(@Param("email") String email); + /** * Find all enabled users. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java index 6a8f7fb..abb0021 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java @@ -69,30 +69,24 @@ public void loginSucceeded(final String email) { public void loginFailed(final String email) { log.debug("Login attempt failed for user: {}", email); if (maxFailedLoginAttempts > 0) { - User user = userRepository.findByEmail(email); - if (user != null) { - incrementFailedAttempts(user); - } else { + // Atomically increment the counter via a single DB UPDATE to avoid the lost-update race that a read-modify-write would suffer under + // concurrent failed logins (which could let an attacker evade lockout). + int updated = userRepository.incrementFailedAttempts(email); + if (updated == 0) { log.warn("User not found for email: {}", email); + return; + } + // Re-read the fresh user; thanks to clearAutomatically on the bulk update, this reflects the true incremented count from the database. + User user = userRepository.findByEmail(email); + if (user != null && user.getFailedLoginAttempts() >= maxFailedLoginAttempts && !user.isLocked()) { + // Setting locked is idempotent if two threads both observe the threshold; the COUNTER is what must not lose updates. + user.setLocked(true); + user.setLockedDate(new Date()); + userRepository.save(user); } } } - /** - * Increment failed attempts. - * - * @param user the user - */ - private void incrementFailedAttempts(User user) { - int currentAttempts = user.getFailedLoginAttempts(); - user.setFailedLoginAttempts(++currentAttempts); - if (currentAttempts >= maxFailedLoginAttempts) { - user.setLocked(true); - user.setLockedDate(new Date()); - } - userRepository.save(user); - } - /** * Checks if the user account is locked. * diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java new file mode 100644 index 0000000..a8bd933 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java @@ -0,0 +1,47 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Repository slice tests for {@link UserRepository}, focusing on the atomic failed-login-attempt increment used to prevent the lockout-evasion + * lost-update race. + */ +@DatabaseTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + void incrementFailedAttemptsAtomicallyIncreasesCounterAndReturnsRowsAffected() { + User user = UserTestDataBuilder.aUser().withId(null).withEmail("increment@test.com").withFailedLoginAttempts(0).build(); + entityManager.persistAndFlush(user); + entityManager.clear(); + + int firstUpdated = userRepository.incrementFailedAttempts("increment@test.com"); + int secondUpdated = userRepository.incrementFailedAttempts("increment@test.com"); + + assertThat(firstUpdated).isEqualTo(1); + assertThat(secondUpdated).isEqualTo(1); + + User reloaded = userRepository.findByEmail("increment@test.com"); + assertThat(reloaded).isNotNull(); + assertThat(reloaded.getFailedLoginAttempts()).isEqualTo(2); + } + + @Test + void incrementFailedAttemptsReturnsZeroForNonExistentEmail() { + int updated = userRepository.incrementFailedAttempts("does-not-exist@test.com"); + + assertThat(updated).isZero(); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java index c90c82a..a6618c5 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -58,17 +59,59 @@ void loginSucceeded_resetsFailedAttempts() { } @Test - void loginFailed_incrementsFailedAttempts() { - when(userRepository.findByEmail(anyString())).thenReturn(testUser); + void loginFailed_callsAtomicIncrementAndLocksAtThreshold() { + // The atomic UPDATE reports one row affected (the user exists). + when(userRepository.incrementFailedAttempts(testUser.getEmail())).thenReturn(1); + // Re-read returns the user whose counter has reached the threshold (simulating the fresh DB value after the bulk update + clear). + testUser.setFailedLoginAttempts(failedLoginAttempts); + when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser); - for (int i = 1; i <= failedLoginAttempts; i++) { - loginAttemptService.loginFailed(testUser.getEmail()); - } + loginAttemptService.loginFailed(testUser.getEmail()); - assertEquals(failedLoginAttempts, testUser.getFailedLoginAttempts()); + verify(userRepository).incrementFailedAttempts(testUser.getEmail()); + verify(userRepository).findByEmail(testUser.getEmail()); assertTrue(testUser.isLocked()); assertNotNull(testUser.getLockedDate()); - verify(userRepository, times(failedLoginAttempts)).save(testUser); + verify(userRepository).save(testUser); + } + + @Test + void loginFailed_doesNotLockBelowThreshold() { + when(userRepository.incrementFailedAttempts(testUser.getEmail())).thenReturn(1); + // Re-read returns the user with a count below the lockout threshold. + testUser.setFailedLoginAttempts(failedLoginAttempts - 1); + when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser); + + loginAttemptService.loginFailed(testUser.getEmail()); + + verify(userRepository).incrementFailedAttempts(testUser.getEmail()); + assertFalse(testUser.isLocked()); + assertNull(testUser.getLockedDate()); + verify(userRepository, never()).save(testUser); + } + + @Test + void loginFailed_warnsAndStopsWhenUserNotFound() { + // The atomic UPDATE affected no rows, meaning the user does not exist. + when(userRepository.incrementFailedAttempts(anyString())).thenReturn(0); + + loginAttemptService.loginFailed("missing@example.com"); + + verify(userRepository).incrementFailedAttempts("missing@example.com"); + verify(userRepository, never()).findByEmail(anyString()); + verify(userRepository, never()).save(testUser); + } + + @Test + void loginFailed_doesNothingWhenLockoutDisabled() { + loginAttemptService.setMaxFailedLoginAttempts(0); + + loginAttemptService.loginFailed(testUser.getEmail()); + + // When the feature is disabled, the atomic increment must not be invoked at all. + verify(userRepository, never()).incrementFailedAttempts(anyString()); + verify(userRepository, never()).findByEmail(anyString()); + verify(userRepository, never()).save(testUser); } @Test From 93fea9f75bf745c93b2682100b82d4197cbbb55e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 21:24:58 -0600 Subject: [PATCH 16/55] fix(concurrency): SERIALIZABLE on primary registration, remove no-op private @Transactional, AFTER_COMMIT delete event --- .../spring/user/event/UserDeletedEvent.java | 18 ++- .../spring/user/gdpr/GdprDeletionService.java | 31 ++++- .../repository/PasswordHistoryRepository.java | 32 +++++ .../spring/user/service/UserService.java | 117 +++++++++++++++--- .../PasswordHistoryRepositoryTest.java | 115 +++++++++++++++++ .../spring/user/service/UserServiceTest.java | 100 +++++++++++++++ 6 files changed, 393 insertions(+), 20 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java index 3b0a4f9..87ac793 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java @@ -6,9 +6,21 @@ * Event published after a user entity has been successfully deleted. * *

Unlike {@link UserPreDeleteEvent} which is published before deletion and allows - * cleanup operations within the transaction, this event is published after the - * deletion has been committed. Use this event for post-deletion notifications, - * external system updates, or logging that should only occur after successful deletion. + * cleanup operations within the transaction, this event is delivered after the + * deletion transaction has committed. Listeners (including {@code @Async} ones) + * are therefore guaranteed to observe a committed deletion and will never act on a + * not-yet-committed change. Use this event for post-deletion notifications, external + * system updates, or logging that should only occur after successful deletion. + * + *

Delivery-after-commit is achieved by the publisher itself, not by the listener: + * the event is published from a registered {@code TransactionSynchronization.afterCommit} + * callback, so it is only fired once the surrounding transaction has committed. Because + * publication is already deferred, consumers do not need + * {@code @TransactionalEventListener} — a plain {@code @EventListener} (or an + * {@code @Async @EventListener}) will already receive the event post-commit. When no + * transaction synchronization is active (e.g. a non-transactional caller), the event is + * published immediately as a fallback. {@code GdprDeletionService.executeUserDeletion} + * uses this same deferred-publication mechanism. * *

Note: Since the user entity has been deleted by the time this event is published, * only the user's ID and email are retained in this event. diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java index f6b9499..7f246ca 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java @@ -3,6 +3,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.digitalsanctuary.spring.user.dto.GdprExportDTO; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; @@ -168,14 +170,39 @@ protected DeletionResult executeUserDeletion(User user, GdprExportDTO exportedDa log.info("GdprDeletionService.deleteUser: Successfully deleted user {}", userId); - // Step 6: Publish UserDeletedEvent after successful deletion - eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, wasExported)); + // Step 6: Publish UserDeletedEvent AFTER the deletion transaction commits, so listeners + // (especially @Async ones) never observe a not-yet-committed deletion. If no transaction + // is active, publish immediately. + publishUserDeletedEventAfterCommit(userId, userEmail, wasExported); return wasExported ? DeletionResult.successWithExport(exportedData) : DeletionResult.success(null); } + /** + * Publishes a {@link UserDeletedEvent} after the current transaction commits. + * + *

If a transaction is active, the event is published from + * {@link TransactionSynchronization#afterCommit()}; otherwise it is published immediately. + * + * @param userId the id of the deleted user + * @param userEmail the email of the deleted user + * @param wasExported whether data was exported before deletion + */ + private void publishUserDeletedEventAfterCommit(Long userId, String userEmail, boolean wasExported) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + eventPublisher.publishEvent(new UserDeletedEvent(GdprDeletionService.this, userId, userEmail, wasExported)); + } + }); + } else { + eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, wasExported)); + } + } + /** * Notifies all GdprDataContributors to prepare for deletion. *

diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java index 3d3e2f5..a8c59b8 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java @@ -4,7 +4,9 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -34,6 +36,36 @@ public interface PasswordHistoryRepository extends JpaRepository findByUserOrderByEntryDateDesc(User user); + /** + * Fetch the ids of a user's password history entries, newest first. + * + *

Used to locate the cutoff id when pruning old entries. Ordering is by primary + * key descending: the id column is generated with {@code GenerationType.IDENTITY}, + * so it is monotonically increasing and therefore a reliable recency ordering even + * when multiple entries share the same {@code entryDate} timestamp. + * + * @param user the user + * @param pageable the pageable defining the offset/limit (used to fetch only the cutoff row) + * @return list of entry ids, newest first + */ + @Query("SELECT p.id FROM PasswordHistoryEntry p WHERE p.user = :user ORDER BY p.id DESC") + List findIdsByUserOrderByIdDesc(@Param("user") User user, Pageable pageable); + + /** + * Delete a user's password history entries whose id is below the given cutoff. + * + *

This is a single set-based delete that prunes everything older than the cutoff + * id in one statement, avoiding the load-then-deleteAll read/delete window. It is + * portable across H2, MariaDB, and PostgreSQL (no subquery {@code LIMIT}). + * + * @param user the user whose old entries should be removed + * @param cutoffId entries with an id strictly less than this are deleted + * @return the number of rows deleted + */ + @Modifying + @Query("DELETE FROM PasswordHistoryEntry p WHERE p.user = :user AND p.id < :cutoffId") + int deleteByUserAndIdLessThan(@Param("user") User user, @Param("cutoffId") Long cutoffId); + /** * Delete all password history entries for a user. * Used when removing a user's password for passwordless accounts. diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index cb68be1..79cfd8e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -9,6 +9,10 @@ import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.dao.ConcurrencyFailureException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; import org.springframework.security.core.Authentication; @@ -20,6 +24,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -246,10 +252,21 @@ public String getValue() { * * @param newUserDto the data transfer object containing the user registration * information + *

+ * Runs with {@link Isolation#SERIALIZABLE} isolation to close the duplicate-registration + * race when two requests register the same email concurrently. The {@link #emailExists} + * pre-check handles the common case, but a concurrent insert can still fail at commit; in + * that case the resulting {@link DataIntegrityViolationException} (unique-constraint + * violation) or serialization failure ({@link CannotAcquireLockException} / + * {@link ConcurrencyFailureException}) is translated into a {@link UserAlreadyExistException} + * (HTTP 409) rather than surfacing as a 500. Unrelated failures are never swallowed. + *

+ * * @return the newly created user entity * @throws UserAlreadyExistException if an account with the same email already * exists */ + @Transactional(isolation = Isolation.SERIALIZABLE) public User registerNewUserAccount(final UserDto newUserDto) { TimeLogger timeLogger = new TimeLogger(log, "UserService.registerNewUserAccount"); log.debug("UserService.registerNewUserAccount: called with userDto: {}", newUserDto); @@ -285,8 +302,20 @@ public User registerNewUserAccount(final UserDto newUserDto) { user.setEnabled(true); } - user = userRepository.save(user); - savePasswordHistory(user, user.getPassword()); + try { + user = userRepository.save(user); + savePasswordHistory(user, user.getPassword()); + } catch (DataIntegrityViolationException | ConcurrencyFailureException e) { + // A concurrent registration won the race: the unique-email constraint was violated + // (DataIntegrityViolationException) or the SERIALIZABLE transaction could not be + // serialized (ConcurrencyFailureException, e.g. CannotAcquireLockException). Translate + // to a 409 instead of letting it surface as a 500. Only these duplicate/serialization + // cases are translated; unrelated exceptions propagate unchanged. + log.debug("UserService.registerNewUserAccount: concurrent registration detected for email {}: {}", + newUserDto.getEmail(), e.getClass().getSimpleName()); + throw new UserAlreadyExistException( + "There is an account with that email address: " + newUserDto.getEmail()); + } // authWithoutPassword(user); timeLogger.end(); return user; @@ -318,25 +347,50 @@ private void savePasswordHistory(User user, String encodedPassword) { /** * Cleans up old password history entries for a user, keeping only the most recent entries. - * Uses SERIALIZABLE isolation to prevent race conditions when the same user changes - * their password concurrently from multiple sessions. + * + *

+ * This method runs within the caller's class-level transaction (it is invoked via + * self-invocation, so any method-level {@code @Transactional} would be bypassed by the proxy + * and never apply). Rather than load every history row and {@code deleteAll} the overflow + * (a read-then-delete window that races with concurrent inserts), it issues a single + * set-based, bounded delete: + *

+ *
    + *
  1. Locate the id of the oldest entry to keep (the {@code maxEntries}-th most recent entry, + * ordered by primary key descending).
  2. + *
  3. Delete all of the user's entries with an id strictly less than that cutoff.
  4. + *
+ * + *

+ * Ordering by id is reliable because the id is generated with {@code GenerationType.IDENTITY} + * and is therefore monotonically increasing. The approach is portable across H2, MariaDB, and + * PostgreSQL (no subquery {@code LIMIT}) and is tolerant of being called repeatedly. + *

* * @param user the user whose password history should be cleaned up */ - @Transactional(isolation = Isolation.SERIALIZABLE) private void cleanUpPasswordHistory(User user) { if (user == null || historyCount <= 0) { return; } - List entries = passwordHistoryRepository.findByUserOrderByEntryDateDesc(user); - // Keep historyCount + 1 entries: the current password plus historyCount previous passwords - // This ensures we actually prevent reuse of the last historyCount passwords + // Keep historyCount + 1 entries: the current password plus historyCount previous passwords. + // This ensures we actually prevent reuse of the last historyCount passwords. int maxEntries = historyCount + 1; - if (entries.size() > maxEntries) { - List toDelete = entries.subList(maxEntries, entries.size()); - passwordHistoryRepository.deleteAll(toDelete); - log.debug("Cleaned up {} old password history entries for user: {}", toDelete.size(), user.getEmail()); + + // Fetch only the cutoff row: the oldest entry we want to keep (0-based index maxEntries - 1, + // newest first). Everything older than this is pruned. + List cutoffIds = + passwordHistoryRepository.findIdsByUserOrderByIdDesc(user, PageRequest.of(maxEntries - 1, 1)); + if (cutoffIds.isEmpty()) { + // Fewer than maxEntries rows exist; nothing to prune. + return; + } + + Long cutoffId = cutoffIds.get(0); + int deleted = passwordHistoryRepository.deleteByUserAndIdLessThan(user, cutoffId); + if (deleted > 0) { + log.debug("Cleaned up {} old password history entries for user: {}", deleted, user.getEmail()); } } @@ -379,9 +433,13 @@ public void deleteOrDisableUser(final User user) { // Delete the user userRepository.delete(user); - // Publish UserDeletedEvent after successful deletion - log.debug("Publishing UserDeletedEvent"); - eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail)); + // Publish UserDeletedEvent AFTER the surrounding transaction commits. The event is + // primarily consumed by external applications (often via @Async listeners) that must + // not observe a not-yet-committed deletion. There is no framework-internal listener + // for this event, so rather than annotate a listener we defer publication itself via a + // transaction synchronization. If no transaction is active (e.g. called outside a + // transactional context), fall back to publishing immediately. + publishUserDeletedEventAfterCommit(userId, userEmail); } else { log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user.getEmail()); user.setEnabled(false); @@ -390,6 +448,35 @@ public void deleteOrDisableUser(final User user) { } } + /** + * Publishes a {@link UserDeletedEvent} after the current transaction commits. + * + *

+ * If a transaction is active, the event is published from + * {@link TransactionSynchronization#afterCommit()} so listeners (especially {@code @Async} + * ones) never act on a deletion that has not yet been committed. If no transaction is active, + * the event is published immediately so the behavior is still correct in non-transactional + * callers. + *

+ * + * @param userId the id of the deleted user + * @param userEmail the email of the deleted user + */ + private void publishUserDeletedEventAfterCommit(final Long userId, final String userEmail) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + log.debug("Publishing UserDeletedEvent after commit"); + eventPublisher.publishEvent(new UserDeletedEvent(UserService.this, userId, userEmail)); + } + }); + } else { + log.debug("Publishing UserDeletedEvent (no active transaction)"); + eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail)); + } + } + /** * Find user by email. * diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java new file mode 100644 index 0000000..a0d58f6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java @@ -0,0 +1,115 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Repository slice tests for {@link PasswordHistoryRepository}, focusing on the bounded, set-based + * password-history pruning used by {@code UserService.cleanUpPasswordHistory}. These verify that the + * cutoff-id lookup plus {@code deleteByUserAndIdLessThan} keeps exactly the most recent N entries and + * is tolerant of being run repeatedly. + */ +@DatabaseTest +class PasswordHistoryRepositoryTest { + + @Autowired + private PasswordHistoryRepository passwordHistoryRepository; + + @Autowired + private TestEntityManager entityManager; + + private User persistUser(String email) { + User user = UserTestDataBuilder.aUser().withId(null).withEmail(email).build(); + return entityManager.persistAndFlush(user); + } + + private void persistHistoryEntries(User user, int count) { + LocalDateTime base = LocalDateTime.now(); + for (int i = 0; i < count; i++) { + // Same timestamp on purpose for some rows, so the test exercises id-based ordering + PasswordHistoryEntry entry = new PasswordHistoryEntry(user, "hash-" + i, base.plusSeconds(i % 2)); + entityManager.persist(entry); + } + entityManager.flush(); + } + + /** + * Mirrors the keep-N logic in {@code UserService.cleanUpPasswordHistory}: locate the oldest entry + * to keep (0-based index {@code maxEntries - 1}, newest first) and delete everything older. + */ + private int prune(User user, int maxEntries) { + List cutoffIds = passwordHistoryRepository.findIdsByUserOrderByIdDesc(user, PageRequest.of(maxEntries - 1, 1)); + if (cutoffIds.isEmpty()) { + return 0; + } + return passwordHistoryRepository.deleteByUserAndIdLessThan(user, cutoffIds.get(0)); + } + + @Test + void prune_keepsOnlyMostRecentNEntries() { + User user = persistUser("prune-keep-n@test.com"); + persistHistoryEntries(user, 10); + int maxEntries = 5; + + int deleted = prune(user, maxEntries); + + assertThat(deleted).isEqualTo(5); + List remaining = passwordHistoryRepository.findByUserOrderByEntryDateDesc(user); + assertThat(remaining).hasSize(maxEntries); + // The kept entries are the most recent ones (highest ids): hash-5..hash-9 + assertThat(remaining).extracting(PasswordHistoryEntry::getPasswordHash) + .containsExactlyInAnyOrder("hash-5", "hash-6", "hash-7", "hash-8", "hash-9"); + } + + @Test + void prune_isNoOpWhenAtOrBelowLimit() { + User user = persistUser("prune-under-limit@test.com"); + persistHistoryEntries(user, 3); + int maxEntries = 5; + + int deleted = prune(user, maxEntries); + + assertThat(deleted).isZero(); + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(user)).hasSize(3); + } + + @Test + void prune_isIdempotentWhenCalledRepeatedly() { + User user = persistUser("prune-idempotent@test.com"); + persistHistoryEntries(user, 8); + int maxEntries = 4; + + int firstDeleted = prune(user, maxEntries); + int secondDeleted = prune(user, maxEntries); + int thirdDeleted = prune(user, maxEntries); + + assertThat(firstDeleted).isEqualTo(4); + assertThat(secondDeleted).isZero(); + assertThat(thirdDeleted).isZero(); + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(user)).hasSize(maxEntries); + } + + @Test + void prune_onlyAffectsTargetUser() { + User target = persistUser("prune-target@test.com"); + User other = persistUser("prune-other@test.com"); + persistHistoryEntries(target, 6); + persistHistoryEntries(other, 6); + + int deleted = prune(target, 2); + + assertThat(deleted).isEqualTo(4); + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(target)).hasSize(2); + // The other user's history is untouched + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(other)).hasSize(6); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index c77cc38..fad0b39 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -25,6 +25,8 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; @@ -37,10 +39,13 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; @@ -139,6 +144,57 @@ void registerNewUserAccount_throwsExceptionWhenUserExist() { .hasMessageContaining("There is an account with that email address"); } + @Test + @DisplayName("registerNewUserAccount - translates DataIntegrityViolationException from save into UserAlreadyExistException") + void registerNewUserAccount_translatesDataIntegrityViolationToUserAlreadyExist() { + // Given: pre-check passes (email not found) but the concurrent insert loses the race at commit + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))) + .thenThrow(new DataIntegrityViolationException("unique constraint violation")); + + // When & Then + assertThatThrownBy(() -> userService.registerNewUserAccount(testUserDto)) + .isInstanceOf(UserAlreadyExistException.class) + .hasMessageContaining("There is an account with that email address"); + } + + @Test + @DisplayName("registerNewUserAccount - translates serialization failure (ConcurrencyFailureException) into UserAlreadyExistException") + void registerNewUserAccount_translatesConcurrencyFailureToUserAlreadyExist() { + // Given: pre-check passes but the SERIALIZABLE transaction cannot acquire the lock at commit + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))) + .thenThrow(new CannotAcquireLockException("could not serialize access")); + + // When & Then + assertThatThrownBy(() -> userService.registerNewUserAccount(testUserDto)) + .isInstanceOf(UserAlreadyExistException.class) + .hasMessageContaining("There is an account with that email address"); + } + + @Test + @DisplayName("registerNewUserAccount - does not swallow unrelated runtime exceptions from save") + void registerNewUserAccount_doesNotSwallowUnrelatedExceptions() { + // Given + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))) + .thenThrow(new IllegalStateException("unrelated failure")); + + // When & Then: the unrelated exception must propagate, not be translated to 409 + assertThatThrownBy(() -> userService.registerNewUserAccount(testUserDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("unrelated failure"); + } + @Test void findByEmail_returnsUserWhenEmailExist() { // Given @@ -264,6 +320,50 @@ void deleteOrDisableUser_whenActuallyDeleteFalse_disablesUser() { verify(eventPublisher, never()).publishEvent(any()); } + @Test + @DisplayName("deleteOrDisableUser - UserDeletedEvent is deferred until after transaction commit") + void deleteOrDisableUser_publishesUserDeletedEventAfterCommit() { + // Given: an active transaction synchronization (simulating the surrounding @Transactional) + ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", true); + TransactionSynchronizationManager.initSynchronization(); + try { + // When + userService.deleteOrDisableUser(testUser); + + // Then: the pre-delete event fires immediately, but the deleted event must NOT yet + verify(eventPublisher).publishEvent(any(UserPreDeleteEvent.class)); + verify(eventPublisher, never()).publishEvent(any(UserDeletedEvent.class)); + + // A synchronization was registered for after-commit delivery + List syncs = TransactionSynchronizationManager.getSynchronizations(); + assertThat(syncs).hasSize(1); + + // When the transaction commits, the deleted event is delivered + syncs.forEach(TransactionSynchronization::afterCommit); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDeletedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(testUser.getId()); + assertThat(captor.getValue().getUserEmail()).isEqualTo(testUser.getEmail()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + @DisplayName("deleteOrDisableUser - UserDeletedEvent still fires when no transaction is active") + void deleteOrDisableUser_publishesUserDeletedEventWhenNoTransaction() { + // Given: no active transaction synchronization + ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", true); + + // When + userService.deleteOrDisableUser(testUser); + + // Then: the deleted event is published immediately (fallback path) + verify(eventPublisher).publishEvent(any(UserDeletedEvent.class)); + } + @Test @DisplayName("deleteOrDisableUser - publishes UserPreDeleteEvent when actually deleting") void deleteOrDisableUser_publishesUserPreDeleteEvent() { From b194c7a8bb353add5271c12d9a79cf8ef4754fb4 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 21:35:15 -0600 Subject: [PATCH 17/55] perf: hash passwords outside the DB transaction to avoid holding connections --- MIGRATION.md | 16 ++ .../spring/user/service/UserService.java | 171 ++++++++++++++++-- .../spring/user/service/UserServiceTest.java | 102 +++++++++++ 3 files changed, 270 insertions(+), 19 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index a492cac..23d43a0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -321,6 +321,22 @@ If you extend `UserService` or implement custom user management: 2. **Password encoding** - Still uses BCrypt, no changes required 3. **User entity** - No schema changes required +#### Password hashing moved outside the transaction (perf) + +To avoid holding a pooled DB connection during the deliberately slow bcrypt hash, password +hashing now runs *outside* the database transaction. As a result `registerNewUserAccount`, +`changeUserPassword`, and `setInitialPassword` are annotated `Propagation.NOT_SUPPORTED` and +delegate the actual DB write to short, separate transactions of their own. + +**Consumer-facing behavior change:** these three methods no longer participate in a caller's +transaction. If you previously called one of them from inside your own `@Transactional`, that +outer transaction is now suspended for the call and the registration / password change commits +independently — **an outer rollback will not roll back the registration or password change.** + +Most consumers call these methods from controllers (which are not transactional) and are +unaffected. If you depend on enlisting these operations in a surrounding transaction, you will +need to restructure that flow. + ### Custom Controllers If you have controllers that extend or work alongside framework controllers: diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 79cfd8e..7c9451c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -7,8 +7,10 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; import org.springframework.dao.CannotAcquireLockException; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.dao.DataIntegrityViolationException; @@ -23,6 +25,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -235,6 +238,25 @@ public String getValue() { /** Hashes tokens before they are stored / looked up at rest. */ private final TokenHasher tokenHasher; + /** + * Self-reference, resolved through the Spring proxy, used to invoke the transactional persistence + * methods from the non-transactional public entry points. + * + *

+ * bcrypt hashing is deliberately slow (~100ms+). Running it inside an open transaction holds a + * pooled DB connection for the full hash and starves the pool under load. The public entry methods + * are therefore annotated {@link Propagation#NOT_SUPPORTED} so they run with no transaction (no + * connection held) while the encode happens, then delegate the actual DB write to a short + * {@code @Transactional} persist method invoked through this proxy reference. Calling the + * persist method directly ({@code this.persistX(...)}) would be a self-invocation that bypasses the + * proxy, so the transaction would never start — hence the proxied self-reference. It is injected + * {@link Lazy} to break the construction-time circular dependency on itself. + *

+ */ + @Lazy + @Autowired + private UserService self; + /** The send registration verification email flag. */ @Value("${user.registration.sendVerificationEmail:false}") private boolean sendRegistrationVerificationEmail; @@ -262,11 +284,18 @@ public String getValue() { * (HTTP 409) rather than surfacing as a 500. Unrelated failures are never swallowed. *

* + * @implNote This method is {@link Propagation#NOT_SUPPORTED}: the slow bcrypt hash runs with no + * transaction (and no pooled connection) held, and the DB write is delegated to a + * short, separate transaction. As a result this method does not enlist in a + * caller's transaction — if a consumer calls it from inside their own + * {@code @Transactional}, that outer transaction is suspended and the registration + * commits independently, so an outer rollback will not undo the persisted user. + * * @return the newly created user entity * @throws UserAlreadyExistException if an account with the same email already * exists */ - @Transactional(isolation = Isolation.SERIALIZABLE) + @Transactional(propagation = Propagation.NOT_SUPPORTED) public User registerNewUserAccount(final UserDto newUserDto) { TimeLogger timeLogger = new TimeLogger(log, "UserService.registerNewUserAccount"); log.debug("UserService.registerNewUserAccount: called with userDto: {}", newUserDto); @@ -282,19 +311,14 @@ public User registerNewUserAccount(final UserDto newUserDto) { throw new IllegalArgumentException("Passwords do not match"); } - if (emailExists(newUserDto.getEmail())) { - log.debug("UserService.registerNewUserAccount: email already exists: {}", newUserDto.getEmail()); - throw new UserAlreadyExistException( - "There is an account with that email address: " + newUserDto.getEmail()); - } - - // Create a new User entity + // Create a new User entity. The (deliberately slow) bcrypt encode runs HERE, with NO + // transaction active (this method is Propagation.NOT_SUPPORTED), so it never holds a pooled + // DB connection. The DB write happens afterward in the short, proxied persistNewUserAccount. User user = new User(); user.setFirstName(newUserDto.getFirstName()); user.setLastName(newUserDto.getLastName()); user.setPassword(passwordEncoder.encode(newUserDto.getPassword())); user.setEmail(newUserDto.getEmail().toLowerCase()); - user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); // If we are not sending a verification email if (!sendRegistrationVerificationEmail) { @@ -302,23 +326,64 @@ public User registerNewUserAccount(final UserDto newUserDto) { user.setEnabled(true); } + // Persist through the proxy so the SERIALIZABLE transaction actually applies (a direct + // this.persistNewUserAccount(...) self-invocation would bypass the proxy and run no transaction). + User saved = self.persistNewUserAccount(user); + // authWithoutPassword(saved); + timeLogger.end(); + return saved; + } + + /** + * Persists a new user account inside a short, serializable transaction. + * + *

+ * This is the DB-only half of {@link #registerNewUserAccount(UserDto)}: the password has already + * been encoded by the (non-transactional) caller, so no slow bcrypt work happens while this + * connection-holding transaction is open. It runs with {@link Isolation#SERIALIZABLE} to close the + * duplicate-registration race when two requests register the same email concurrently. The + * {@link #emailExists} pre-check handles the common case, but a concurrent insert can still fail at + * commit; in that case the resulting {@link DataIntegrityViolationException} (unique-constraint + * violation) or serialization failure ({@link CannotAcquireLockException} / + * {@link ConcurrencyFailureException}) is translated into a {@link UserAlreadyExistException} + * (HTTP 409) rather than surfacing as a 500. Unrelated failures are never swallowed. + *

+ * + *

+ * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies, and is not + * intended to be called directly by consumers. + *

+ * + * @param user the fully built user entity (password already encoded) + * @return the saved user entity + * @throws UserAlreadyExistException if an account with the same email already exists + */ + @Transactional(isolation = Isolation.SERIALIZABLE) + public User persistNewUserAccount(final User user) { + if (emailExists(user.getEmail())) { + log.debug("UserService.persistNewUserAccount: email already exists: {}", user.getEmail()); + throw new UserAlreadyExistException( + "There is an account with that email address: " + user.getEmail()); + } + + user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); + try { - user = userRepository.save(user); - savePasswordHistory(user, user.getPassword()); + User saved = userRepository.save(user); + savePasswordHistory(saved, saved.getPassword()); + return saved; } catch (DataIntegrityViolationException | ConcurrencyFailureException e) { // A concurrent registration won the race: the unique-email constraint was violated // (DataIntegrityViolationException) or the SERIALIZABLE transaction could not be // serialized (ConcurrencyFailureException, e.g. CannotAcquireLockException). Translate // to a 409 instead of letting it surface as a 500. Only these duplicate/serialization // cases are translated; unrelated exceptions propagate unchanged. - log.debug("UserService.registerNewUserAccount: concurrent registration detected for email {}: {}", - newUserDto.getEmail(), e.getClass().getSimpleName()); + log.debug("UserService.persistNewUserAccount: concurrent registration detected for email {}: {}", + user.getEmail(), e.getClass().getSimpleName()); throw new UserAlreadyExistException( - "There is an account with that email address: " + newUserDto.getEmail()); + "There is an account with that email address: " + user.getEmail()); } - // authWithoutPassword(user); - timeLogger.end(); - return user; } /** @@ -609,10 +674,45 @@ public Optional findUserByID(final long id) { * * @param user the user * @param password the password + * + * @implNote This method is {@link Propagation#NOT_SUPPORTED}: the slow bcrypt hash runs with no + * transaction (and no pooled connection) held, and the DB write is delegated to a + * short, separate transaction. As a result this method does not enlist in a + * caller's transaction — if a consumer calls it from inside their own + * {@code @Transactional}, that outer transaction is suspended and the password change + * commits independently, so an outer rollback will not undo it. */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void changeUserPassword(final User user, final String password) { + // Encode the new password with NO transaction active (this method is + // Propagation.NOT_SUPPORTED) so the slow bcrypt hash never holds a pooled DB connection. String encodedPassword = passwordEncoder.encode(password); user.setPassword(encodedPassword); + // Persist through the proxy so the short transaction applies. + self.persistChangedPassword(user, encodedPassword); + } + + /** + * Persists a changed password inside a short transaction. + * + *

+ * The DB-only half of {@link #changeUserPassword(User, String)}: the password has already been + * encoded by the (non-transactional) caller, so no bcrypt work happens while this transaction holds + * a connection. Saves the user, records password history, and invalidates all existing sessions so + * a reset/change forces re-auth everywhere (OWASP). + *

+ * + *

+ * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies, and is not + * intended to be called directly by consumers. + *

+ * + * @param user the user whose password changed (password field already set/encoded) + * @param encodedPassword the already-encoded password to record in history + */ + @Transactional + public void persistChangedPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); // Terminate all existing sessions so a reset/change forces re-auth everywhere (OWASP). @@ -666,17 +766,50 @@ public void removeUserPassword(User user) { * @param user the user to set the password for * @param rawPassword the raw password to encode and save * @throws IllegalStateException if the user already has a password + * + * @implNote This method is {@link Propagation#NOT_SUPPORTED}: the slow bcrypt hash runs with no + * transaction (and no pooled connection) held, and the DB write is delegated to a + * short, separate transaction. As a result this method does not enlist in a + * caller's transaction — if a consumer calls it from inside their own + * {@code @Transactional}, that outer transaction is suspended and the password change + * commits independently, so an outer rollback will not undo it. */ - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void setInitialPassword(User user, String rawPassword) { if (hasPassword(user)) { throw new IllegalStateException("User already has a password"); } + // Encode with NO transaction active (this method is Propagation.NOT_SUPPORTED) so the slow + // bcrypt hash never holds a pooled DB connection. The DB write runs in the proxied persist. String encodedPassword = passwordEncoder.encode(rawPassword); user.setPassword(encodedPassword); + // Persist through the proxy so the short transaction applies. + self.persistInitialPassword(user, encodedPassword); + log.info("Initial password set for user: {}", user.getEmail()); + } + + /** + * Persists an initial password inside a short transaction. + * + *

+ * The DB-only half of {@link #setInitialPassword(User, String)}: the password has already been + * encoded by the (non-transactional) caller, so no bcrypt work happens while this transaction holds + * a connection. Saves the user and records password history. + *

+ * + *

+ * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies, and is not + * intended to be called directly by consumers. + *

+ * + * @param user the user whose initial password is being set (password field already set) + * @param encodedPassword the already-encoded password to record in history + */ + @Transactional + public void persistInitialPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); - log.info("Initial password set for user: {}", user.getEmail()); } /** diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index fad0b39..99a19df 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -21,6 +22,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -108,6 +110,13 @@ void setUp() { // Use centralized test fixtures for consistent test data testUser = TestFixtures.Users.standardUser(); testUserDto = TestFixtures.DTOs.validUserRegistration(); + + // The public entry methods (registerNewUserAccount/changeUserPassword/setInitialPassword) run + // with NO transaction so bcrypt never holds a DB connection, then delegate the DB write to a + // @Transactional persist method invoked through the Spring proxy (the "self" reference). Under + // @InjectMocks there is no proxy and "self" is null, so wire it back to the unit-under-test so + // the real persist logic executes during these unit tests. + ReflectionTestUtils.setField(userService, "self", userService); } @Test @@ -951,6 +960,99 @@ void shouldThrowWhenEmailExists() { } } + @Nested + @DisplayName("Password Hashing Outside Transaction Tests") + class PasswordHashingOutsideTransactionTests { + + /** + * bcrypt is deliberately slow, so it must run BEFORE the connection-holding DB write. Since the + * encode now happens in the non-transactional public entry method and the save happens in the + * proxied @Transactional persist method, asserting that {@code passwordEncoder.encode(...)} + * fires strictly before {@code userRepository.save(...)} proves the hash is computed outside the + * transactional persistence step. + */ + @Test + @DisplayName("registerNewUserAccount - encodes password BEFORE the persisting save (hash outside the DB write)") + void registerNewUserAccount_encodesBeforeSave() { + // Given + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.registerNewUserAccount(testUserDto); + + // Then: encode runs before the repository save (hash computed outside the persist step) + InOrder inOrder = inOrder(passwordEncoder, userRepository); + inOrder.verify(passwordEncoder).encode(anyString()); + inOrder.verify(userRepository).save(any(User.class)); + } + + /** + * The transactional persist method must receive an ALREADY-encoded password: it does no + * encoding itself, confirming the (slow) hash happened in the non-transactional caller. We also + * assert the saved entity carries the encoded value, not the raw password. + */ + @Test + @DisplayName("registerNewUserAccount - the persisted user carries the already-encoded password; persist does not re-encode") + void registerNewUserAccount_persistReceivesEncodedPassword() { + // Given + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(testUserDto.getPassword())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.registerNewUserAccount(testUserDto); + + // Then: the entity handed to save already holds the encoded password (not the raw value), + // and encode was invoked exactly once (only in the non-transactional entry method). + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getPassword()).isEqualTo("encodedPassword"); + verify(passwordEncoder).encode(testUserDto.getPassword()); + } + + @Test + @DisplayName("changeUserPassword - encodes password BEFORE the persisting save (hash outside the DB write)") + void changeUserPassword_encodesBeforeSave() { + // Given + String newPassword = "newTestPassword"; + when(passwordEncoder.encode(newPassword)).thenReturn("encodedNewPassword"); + when(userRepository.save(any(User.class))).thenReturn(testUser); + + // When + userService.changeUserPassword(testUser, newPassword); + + // Then: encode runs before save, and session invalidation still happens (in the persist). + InOrder inOrder = inOrder(passwordEncoder, userRepository, sessionInvalidationService); + inOrder.verify(passwordEncoder).encode(newPassword); + inOrder.verify(userRepository).save(testUser); + inOrder.verify(sessionInvalidationService).invalidateUserSessions(testUser); + } + + @Test + @DisplayName("setInitialPassword - encodes password BEFORE the persisting save (hash outside the DB write)") + void setInitialPassword_encodesBeforeSave() { + // Given + testUser.setPassword(null); + String rawPassword = "NewSecurePassword123!"; + when(passwordEncoder.encode(rawPassword)).thenReturn("encodedNewPassword"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.setInitialPassword(testUser, rawPassword); + + // Then + InOrder inOrder = inOrder(passwordEncoder, userRepository); + inOrder.verify(passwordEncoder).encode(rawPassword); + inOrder.verify(userRepository).save(testUser); + } + } + // Tests temporarily disabled until OAuth2 dependency issue is resolved // @Test // void checkIfValidOldPassword_returnFalseIfInvalid() { From 21f8d992fdcfcb2ac324d923758d94932b643dba Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 21:45:48 -0600 Subject: [PATCH 18/55] perf: give mail its own bounded executor so SMTP stalls don't starve async work --- CONFIG.md | 7 ++ .../user/mail/MailExecutorConfiguration.java | 47 +++++++++ .../spring/user/mail/MailService.java | 4 +- .../mail/MailExecutorConfigurationTest.java | 95 +++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java diff --git a/CONFIG.md b/CONFIG.md index f898087..5e7afa7 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -152,6 +152,13 @@ user: - **From Address (`spring.mail.fromAddress`)**: The email address used as the sender in outgoing emails. +### Mail Executor + +Email is sent asynchronously (`@Async`) with retry/backoff. To prevent an SMTP outage from starving the shared application task executor that other +async features rely on, mail runs on its own dedicated, bounded executor bean named `dsMailExecutor` (core pool 2, max pool 4, queue capacity 50, with +a `CallerRunsPolicy` rejection handler that applies backpressure to the calling thread when the pool and queue are saturated). To change the sizing, +supply your own `dsMailExecutor` bean (a `ThreadPoolTaskExecutor`); the library's default backs off via `@ConditionalOnMissingBean(name = "dsMailExecutor")`. + ## Role and Privileges diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java new file mode 100644 index 0000000..262d40b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java @@ -0,0 +1,47 @@ +package com.digitalsanctuary.spring.user.mail; + +import java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Provides the dedicated, bounded {@code dsMailExecutor} used to run the library's mail-sending {@code @Async} work. + * + *

+ * {@link MailService}'s send methods combine {@code @Async} with {@code @Retryable} (3 attempts with exponential backoff). If those methods shared + * Spring Boot's default application task executor, an SMTP outage could occupy that shared pool for seconds at a time per message and starve every + * other {@code @Async} feature in the library (event publishing, listeners, etc.). To prevent that, mail runs on its own small, bounded pool with a + * bounded queue and a {@link CallerRunsPolicy} rejection handler — so when the mail pool and queue are both saturated, the calling thread runs + * the task itself, applying backpressure rather than dropping mail or letting the queue grow without bound. + *

+ * + *

+ * The pool uses fixed, conservative defaults (core 2, max 4, queue 50). A consuming application that needs different sizing can supply its own + * {@code dsMailExecutor} bean; the library's default then backs off via {@link ConditionalOnMissingBean}. + *

+ */ +@Configuration +public class MailExecutorConfiguration { + + /** + * Creates the dedicated, bounded executor for mail-sending {@code @Async} work. Bounded core/max pool sizes plus a bounded queue and a + * {@link CallerRunsPolicy} rejection handler ensure an SMTP stall applies backpressure to the caller instead of starving the shared default + * async executor or queueing mail without limit. + * + * @return the bounded {@link ThreadPoolTaskExecutor} for mail + */ + @Bean("dsMailExecutor") + @ConditionalOnMissingBean(name = "dsMailExecutor") + public ThreadPoolTaskExecutor dsMailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("ds-mail-"); + executor.setRejectedExecutionHandler(new CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java index 6fcb43c..f2b6fe3 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java @@ -71,7 +71,7 @@ void init() { * @param subject the subject of the email * @param text the text to include as the email message body */ - @Async + @Async("dsMailExecutor") @Retryable(retryFor = {MailException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public void sendSimpleMessage(String to, String subject, String text) { @@ -100,7 +100,7 @@ public void sendSimpleMessage(String to, String subject, String text) { * @param variables a map of variables (key->value) to use in building the dynamic content via the template * @param templatePath the file name, or path and name, for the Thymeleaf template to use to build the dynamic email */ - @Async + @Async("dsMailExecutor") @Retryable(retryFor = {MailException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public void sendTemplateMessage(String to, String subject, Map variables, String templatePath) { diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java new file mode 100644 index 0000000..fa923f9 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java @@ -0,0 +1,95 @@ +package com.digitalsanctuary.spring.user.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.concurrent.ThreadPoolExecutor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Verifies the dedicated bounded {@code dsMailExecutor} so an SMTP stall on the mail retry/backoff path cannot starve the shared default async + * executor that the rest of the library's {@code @Async} work relies on. + * + *

+ * The bean-presence assertions run against a minimal {@link ApplicationContextRunner} that imports only {@link MailExecutorConfiguration}, so the test + * never boots the full JPA/security context and avoids JPA-metamodel pollution. + *

+ */ +@DisplayName("Mail Executor Configuration Tests") +class MailExecutorConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner(); + + @Nested + @DisplayName("Bean presence and bounded configuration") + class BeanPresence { + + @Test + @DisplayName("dsMailExecutor bean exists and is a ThreadPoolTaskExecutor") + void dsMailExecutorBeanExists() { + contextRunner.withUserConfiguration(MailExecutorConfiguration.class).run(context -> { + assertThat(context).hasBean("dsMailExecutor"); + assertThat(context.getBean("dsMailExecutor")).isInstanceOf(ThreadPoolTaskExecutor.class); + }); + } + + @Test + @DisplayName("dsMailExecutor is bounded with CallerRunsPolicy backpressure") + void dsMailExecutorIsBounded() { + contextRunner.withUserConfiguration(MailExecutorConfiguration.class).run(context -> { + ThreadPoolTaskExecutor executor = context.getBean("dsMailExecutor", ThreadPoolTaskExecutor.class); + assertThat(executor.getCorePoolSize()).isEqualTo(2); + assertThat(executor.getMaxPoolSize()).isEqualTo(4); + assertThat(executor.getQueueCapacity()).isEqualTo(50); + assertThat(executor.getThreadPoolExecutor().getRejectedExecutionHandler()) + .isInstanceOf(ThreadPoolExecutor.CallerRunsPolicy.class); + }); + } + } + + @Nested + @DisplayName("Override behavior") + class Override { + + @Test + @DisplayName("Consumer can override dsMailExecutor by name") + void consumerCanOverride() { + ThreadPoolTaskExecutor custom = new ThreadPoolTaskExecutor(); + custom.initialize(); + contextRunner.withUserConfiguration(MailExecutorConfiguration.class) + .withBean("dsMailExecutor", ThreadPoolTaskExecutor.class, () -> custom) + .run(context -> { + assertThat(context).hasSingleBean(ThreadPoolTaskExecutor.class); + assertThat(context.getBean("dsMailExecutor")).isSameAs(custom); + }); + } + } + + @Nested + @DisplayName("MailService @Async wiring") + class AsyncWiring { + + @Test + @DisplayName("sendSimpleMessage runs on the dsMailExecutor") + void sendSimpleMessageQualified() throws Exception { + Method method = MailService.class.getMethod("sendSimpleMessage", String.class, String.class, String.class); + Async async = method.getAnnotation(Async.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("dsMailExecutor"); + } + + @Test + @DisplayName("sendTemplateMessage runs on the dsMailExecutor") + void sendTemplateMessageQualified() throws Exception { + Method method = MailService.class.getMethod("sendTemplateMessage", String.class, String.class, java.util.Map.class, String.class); + Async async = method.getAnnotation(Async.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("dsMailExecutor"); + } + } +} From 32646d85f2f474234eb6bca77a7197dc31f5f525 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 21:55:31 -0600 Subject: [PATCH 19/55] perf/ops: audit log rotation, bounded queries, durability docs --- CONFIG.md | 19 ++- .../spring/user/audit/AuditConfig.java | 23 ++- .../user/audit/FileAuditLogQueryService.java | 66 ++++++--- .../spring/user/audit/FileAuditLogWriter.java | 133 +++++++++++++++++- ...itional-spring-configuration-metadata.json | 24 ++++ .../config/dsspringuserconfig.properties | 16 ++- .../audit/FileAuditLogQueryServiceTest.java | 54 +++++++ .../user/audit/FileAuditLogWriterTest.java | 86 +++++++++++ 8 files changed, 391 insertions(+), 30 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index 5e7afa7..961d582 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -36,9 +36,22 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl ## Audit Logging -- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file. -- **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing. Defaults to `false` for performance. -- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. Prevents memory issues with large logs. Defaults to `10000`. +- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file. If this path is not writable, the system falls back to the system temp directory. +- **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing on every write. Defaults to `false` for performance. See **Durability** below. +- **Flush Rate (`user.audit.flushRate`)**: The interval, in milliseconds, at which the buffered audit log is flushed to disk when `flushOnWrite=false`. Defaults to `30000` (30 seconds). +- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. The query service streams the active log file and retains only the most-recent `maxQueryResults` matching events in a bounded ring buffer, so query memory stays bounded regardless of file size. Defaults to `10000`. +- **Max File Size (`user.audit.maxFileSizeMb`)**: Maximum size, in megabytes, of the active audit log file before it is rotated. When exceeded, the active file is renamed to `.1` (shifting existing archives up to `maxFiles`) and a fresh active file is opened. Set to `0` or a negative value to **disable rotation** (logs grow unbounded). Defaults to `10`. **Rotation is enabled by default** to prevent unbounded disk growth. +- **Max Files (`user.audit.maxFiles`)**: Maximum number of rotated archive files to retain (e.g. `user-audit.log.1` .. `user-audit.log.5`). The oldest archive beyond this count is deleted on rotation. Defaults to `5`. + +### Durability + +The file audit sink uses a buffered writer. With the default `flushOnWrite=false`, audit events are written to an in-memory buffer and flushed to disk periodically on the `flushRate` schedule. On a hard crash, JVM kill (SIGKILL), or power loss, **up to one `flushRate` interval of buffered audit events (plus any un-flushed buffer contents) can be lost**. + +For compliance or security-critical deployments where no audit event may be lost, set `user.audit.flushOnWrite=true`. This flushes to disk after every event, eliminating the durability window at a per-write performance cost (under heavy load). Alternatively, lowering `flushRate` narrows the window without paying the full per-write cost. + +### Query Scope + +Audit queries (used by GDPR export and consent history) read only the **active** log file. Rotated archive files (`.1`, `.2`, ...) are not included in query results. If long-range historical queries are required, use a larger `maxFileSizeMb`/`maxFiles` window or a database-backed `AuditLogWriter`/`AuditLogQueryService`. ## JPA Auditing diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java index 01b1564..c3fab19 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java @@ -42,8 +42,11 @@ public class AuditConfig { private boolean flushOnWrite; /** - * The flush rate. This is the rate at which the audit log buffer is flushed to the log file. The value is in milliseconds and can be set to any - * positive integer. The default value is 1000 (1 second). + * The flush rate, in milliseconds, at which the audit log buffer is flushed to the log file when + * {@link #flushOnWrite} is {@code false}. May be set to any positive integer. The library default + * (from {@code dsspringuserconfig.properties}) is {@code 30000} (30 seconds). Smaller values reduce + * the durability window (the amount of buffered audit data that can be lost on a hard crash) at a + * small performance cost. */ private int flushRate; @@ -55,4 +58,20 @@ public class AuditConfig { */ private int maxQueryResults = 10000; + /** + * Maximum size of the active audit log file, in megabytes, before it is rotated. + * When the active log file exceeds this size, it is rotated: the current file is renamed to + * {@code .1} (shifting any existing {@code .1} to {@code .2}, and so on, up to + * {@link #maxFiles}) and a fresh active file is opened. Set to {@code 0} or a negative value to + * disable rotation (logs grow unbounded). Default is {@code 10} (MB). + */ + private int maxFileSizeMb = 10; + + /** + * Maximum number of rotated audit log files to keep (e.g. {@code user-audit.log.1} .. + * {@code user-audit.log.5}). When rotation produces more than this many archived files, the oldest + * is deleted. Must be at least {@code 1} for rotation to retain any history. Default is {@code 5}. + */ + private int maxFiles = 5; + } diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java index b04554f..9bcd737 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java @@ -10,8 +10,10 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.ArrayDeque; import java.util.Collections; import java.util.Comparator; +import java.util.Deque; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -26,16 +28,18 @@ * File-based implementation of {@link AuditLogQueryService} that parses the * pipe-delimited audit log file created by {@link FileAuditLogWriter}. * - *

This implementation reads and parses the entire log file for each query, - * filtering results by user email or ID. While suitable for small to medium - * audit volumes (<50MB, <100K events), applications with high audit volumes - * or frequent export requests should consider implementing a database-backed - * query service for better performance. + *

This implementation streams the active log file once per query, filtering + * results by user email or ID. To bound memory and CPU on large files, it retains + * only the most recent {@code user.audit.maxQueryResults} matching events in a + * bounded ring buffer rather than loading and sorting the whole file. While + * suitable for small to medium audit volumes (<50MB, <100K events), + * applications with high audit volumes or frequent export requests should consider + * implementing a database-backed query service for better performance. * *

Performance Note: GDPR export operations call this service - * multiple times (findByUser, findByUserAndAction) which results in reading - * and parsing the entire log file for each call. For production deployments - * with large audit logs, consider: + * multiple times (findByUser, findByUserAndAction); each call streams the active + * log file once. Memory per call is bounded to {@code maxQueryResults} events. For + * production deployments with large audit logs, consider: *

    *
  • Implementing a database-backed {@link AuditLogQueryService}
  • *
  • Adding log rotation to keep file sizes manageable
  • @@ -85,12 +89,26 @@ public List findByUserAndAction(User user, String action) { /** * Internal method to find audit events with optional filtering. - * Uses Java Streams for efficient memory handling with large log files. + * + *

    Bounded memory/CPU: The log file is written in append order (oldest first, + * newest last). Rather than parsing and sorting the entire file in memory, this method streams the + * file once and retains only the last {@code maxQueryResults} matching raw lines in a bounded + * {@link ArrayDeque} ring buffer. Only that bounded window is then parsed and sorted by timestamp + * descending. Memory is therefore {@code O(maxQueryResults)} regardless of file size, and the sort + * cost is bounded to the result window rather than the whole file. + * + *

    For result sets {@code <= maxQueryResults} the observable output (filters + newest-first + * ordering) is identical to the previous full-file implementation. For larger sets, the most-recent + * {@code maxQueryResults} matches (by file/append order) are returned, then ordered newest-first. + * + *

    Scope: Only the active log file is queried; rotated archive files + * ({@code .1}, {@code .2}, ...) are not included. This preserves the prior behavior; query + * results reflect only the currently-active audit log. * * @param user the user to filter by * @param since optional timestamp filter * @param action optional action filter - * @return filtered list of audit events + * @return filtered list of audit events, newest first, capped at {@code maxQueryResults} */ private List findByUser(User user, Instant since, String action) { if (user == null) { @@ -108,28 +126,34 @@ private List findByUser(User user, Instant since, String action) int maxResults = auditConfig.getMaxQueryResults(); + // Bounded ring buffer of the most recent matching parsed events in file (append) order. + // When maxResults <= 0 the limit is disabled and all matching events are retained. + Deque window = new ArrayDeque<>(); + try (Stream lines = Files.lines(logPath)) { - Stream stream = lines - .skip(1) // Skip header line + lines.skip(1) // Skip header line .map(this::parseLine) .filter(Objects::nonNull) .filter(event -> matchesUser(event, userEmail, userId)) .filter(event -> since == null || event.getTimestamp() == null || !event.getTimestamp().isBefore(since)) .filter(event -> action == null || action.equals(event.getAction())) - .sorted(Comparator.comparing(AuditEventDTO::getTimestamp, - Comparator.nullsLast(Comparator.reverseOrder()))); - - // Apply limit if configured to prevent unbounded memory usage - if (maxResults > 0) { - stream = stream.limit(maxResults); - } - - return stream.collect(Collectors.toList()); + .forEach(event -> { + window.addLast(event); + if (maxResults > 0 && window.size() > maxResults) { + window.removeFirst(); // evict oldest to keep only the most recent N + } + }); } catch (IOException e) { log.error("FileAuditLogQueryService.findByUser: Error reading audit log file", e); return Collections.emptyList(); } + + // Sort only the bounded window by timestamp descending (newest first). + return window.stream() + .sorted(Comparator.comparing(AuditEventDTO::getTimestamp, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); } /** diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java index a22ebc9..20aa7d0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java @@ -6,6 +6,7 @@ import java.nio.file.Files; import java.nio.file.OpenOption; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.text.MessageFormat; import org.springframework.util.StringUtils; @@ -42,6 +43,28 @@ public class FileAuditLogWriter implements AuditLogWriter { private final AuditConfig auditConfig; private BufferedWriter bufferedWriter; + /** Absolute/relative path of the currently-open active log file (set by {@link #tryOpenLogFile}). */ + private String activeFilePath; + + /** + * Approximate number of bytes written to the active log file since it was opened. Tracked + * incrementally (rather than calling {@code Files.size} on every write) to keep the hot write path + * cheap. The estimate uses {@link String#length()} as a byte approximation; this is sufficient for a + * size-based rotation trigger and intentionally avoids per-write {@code stat} syscalls. Resets on open + * and rotation. + */ + private long currentFileBytes = 0L; + + /** + * Effective rotation threshold in bytes, derived from {@link AuditConfig#getMaxFileSizeMb()} at open + * time. A value {@code <= 0} disables rotation. Package-private so tests can set a tiny threshold + * without writing megabytes of data. + */ + long maxFileSizeBytes = 0L; + + /** When true, {@link #maxFileSizeBytes} was set via the test hook and must not be overwritten on (re)open. */ + private boolean maxFileSizeBytesOverridden = false; + /** * Constructs the writer with the audit configuration it depends on. * @@ -51,6 +74,17 @@ public FileAuditLogWriter(AuditConfig auditConfig) { this.auditConfig = auditConfig; } + /** + * Test-only hook to override the effective rotation threshold (in bytes) so rotation can be exercised + * without writing the full configured {@code maxFileSizeMb} of data. Not part of the public API. + * + * @param bytes the effective byte threshold; {@code <= 0} disables rotation + */ + void setMaxFileSizeBytesForTesting(long bytes) { + this.maxFileSizeBytes = bytes; + this.maxFileSizeBytesOverridden = true; + } + /** * Initializes the log file writer. This method is called after the bean is constructed. It validates the configuration and opens the log file for * writing. @@ -113,9 +147,11 @@ public synchronized void writeLog(AuditEvent event) { event.getExtraData()); bufferedWriter.write(output); bufferedWriter.newLine(); + currentFileBytes += output.length() + 1L; // +1 approximates the newline if (auditConfig.isFlushOnWrite()) { bufferedWriter.flush(); } + rotateIfNeeded(); } catch (IOException e) { log.error("FileAuditLogWriter.writeLog: IOException writing to log file: {}", auditConfig.getLogFilePath(), e); } catch (Exception e) { @@ -206,11 +242,20 @@ private boolean tryOpenLogFile(String filePath) { OpenOption[] fileOptions = {StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE}; boolean newFile = Files.notExists(path); bufferedWriter = Files.newBufferedWriter(path, fileOptions); - + this.activeFilePath = filePath; + + // Initialize the rotation threshold (derived from MB config) and the byte counter. For an + // existing (appended) file, seed the counter from its current size so rotation accounts for + // pre-existing content. + if (!maxFileSizeBytesOverridden) { + this.maxFileSizeBytes = (long) auditConfig.getMaxFileSizeMb() * 1024L * 1024L; + } + this.currentFileBytes = newFile ? 0L : sizeQuietly(path); + if (newFile) { writeHeader(); } - + log.info("FileAuditLogWriter.setup: Log file opened successfully: {}", filePath); return true; @@ -220,6 +265,89 @@ private boolean tryOpenLogFile(String filePath) { } } + /** + * Checks the tracked size of the active log file and rotates it if it has exceeded the configured + * threshold. Called from within the synchronized {@link #writeLog(AuditEvent)} after each write. + * + *

    Rotation is disabled when {@link #maxFileSizeBytes} is {@code <= 0} (i.e. + * {@link AuditConfig#getMaxFileSizeMb()} is {@code <= 0}). Rotation failures are caught and logged so + * that audit writing is never interrupted by a rotation problem. + */ + private void rotateIfNeeded() { + if (maxFileSizeBytes <= 0 || activeFilePath == null) { + return; // rotation disabled or no active file + } + if (currentFileBytes < maxFileSizeBytes) { + return; + } + try { + rotateLogFiles(); + } catch (Exception e) { + // Rotation must never break audit writing; log and continue with the current file. + log.error("FileAuditLogWriter.rotateIfNeeded: Failed to rotate audit log file '{}' (continuing without rotation): {}", + activeFilePath, e.getMessage(), e); + } + } + + /** + * Performs size-based rotation of the active log file: flushes and closes the current writer, shifts + * existing archives ({@code name.(N-1) -> name.N}, deleting the oldest beyond {@code maxFiles}), + * renames the active file to {@code name.1}, then reopens a fresh active file (writing the header + * again via {@link #openLogFile()} semantics). + * + * @throws IOException if the file shuffling fails irrecoverably + */ + private void rotateLogFiles() throws IOException { + String basePath = activeFilePath; + int maxFiles = Math.max(1, auditConfig.getMaxFiles()); + + // Flush and close the current writer before moving the file. + closeLogFile(); + bufferedWriter = null; + + // Delete the oldest archive that would be pushed out of the retention window. + Path oldest = Path.of(basePath + "." + maxFiles); + Files.deleteIfExists(oldest); + + // Shift archives upward: name.(N-1) -> name.N for N from maxFiles down to 2. + for (int i = maxFiles - 1; i >= 1; i--) { + Path src = Path.of(basePath + "." + i); + Path dst = Path.of(basePath + "." + (i + 1)); + if (Files.exists(src)) { + Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING); + } + } + + // Move the current active file to name.1. + Path active = Path.of(basePath); + if (Files.exists(active)) { + Files.move(active, Path.of(basePath + ".1"), StandardCopyOption.REPLACE_EXISTING); + } + + // Reopen a fresh active file at the same configured path (rewrites the header for the new file). + currentFileBytes = 0L; + if (!tryOpenLogFile(basePath)) { + log.error("FileAuditLogWriter.rotateLogFiles: Unable to reopen audit log file after rotation: {}", basePath); + } else { + log.info("FileAuditLogWriter.rotateLogFiles: Rotated audit log file: {}", basePath); + } + } + + /** + * Returns the size of the given file, or {@code 0} if it cannot be determined. Used only to seed the + * byte counter when appending to a pre-existing file. + * + * @param path the file to measure + * @return the file size in bytes, or {@code 0} on error + */ + private long sizeQuietly(Path path) { + try { + return Files.exists(path) ? Files.size(path) : 0L; + } catch (IOException e) { + return 0L; + } + } + /** * Closes the log file to ensure all data is flushed and resources are released. */ @@ -245,6 +373,7 @@ private void writeHeader() { bufferedWriter.write(output); bufferedWriter.newLine(); bufferedWriter.flush(); + currentFileBytes += output.length() + 1L; // count header toward rotation threshold } catch (IOException e) { log.error("FileAuditLogWriter.writeHeader: IOException writing header: {}", output, e); } diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index f978735..c8530aa 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -211,6 +211,30 @@ "type": "java.lang.String", "description": "Path for audit log file" }, + { + "name": "user.audit.flushRate", + "type": "java.lang.Integer", + "description": "Interval in milliseconds at which the buffered audit log is flushed to disk when flushOnWrite=false. Smaller values reduce the durability window at a small performance cost.", + "defaultValue": 30000 + }, + { + "name": "user.audit.maxQueryResults", + "type": "java.lang.Integer", + "description": "Maximum number of audit events returned from a single query. The query service retains only the most-recent matching events in a bounded ring buffer, bounding memory regardless of file size. Set to 0 or negative to disable the limit (not recommended).", + "defaultValue": 10000 + }, + { + "name": "user.audit.maxFileSizeMb", + "type": "java.lang.Integer", + "description": "Maximum size in megabytes of the active audit log file before it is rotated. When exceeded, the active file is renamed to .1 (shifting archives up to maxFiles) and a fresh file is opened. Set to 0 or negative to disable rotation (unbounded growth).", + "defaultValue": 10 + }, + { + "name": "user.audit.maxFiles", + "type": "java.lang.Integer", + "description": "Maximum number of rotated audit log archive files to retain (e.g. user-audit.log.1 .. user-audit.log.5). The oldest archive beyond this count is deleted on rotation.", + "defaultValue": 5 + }, { "name": "user.security.bcryptStrength", "type": "java.lang.Integer", diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 57dd021..b626928 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -18,12 +18,24 @@ spring.messages.basename=messages/messages,messages/dsspringusermessages # If this path is not writable, the system will automatically fall back to using the system temp directory. user.audit.logFilePath=./logs/user-audit.log -# If true, the audit log will be flushed to disk after every write (less performant). If false, the audit log will be flushed to disk every 10 seconds (more performant). +# If true, the audit log buffer is flushed to disk after every write (less performant, but durable: no +# events are lost on a crash). If false, the buffer is flushed periodically on the user.audit.flushRate +# schedule (more performant, but up to flushRate worth of buffered events can be lost on a hard crash). user.audit.flushOnWrite=false -# The rate at which the audit log will be flushed to disk in milliseconds. +# The rate, in milliseconds, at which the audit log buffer is flushed to disk when flushOnWrite=false. +# Smaller values reduce the durability window (events at risk on a crash) at a small performance cost. user.audit.flushRate=30000 +# Maximum size, in megabytes, of the active audit log file before it is rotated. When exceeded, the +# current file is rotated to .1 (shifting older archives up to maxFiles) and a fresh file is opened. +# Set to 0 or negative to disable rotation (logs grow unbounded). Default is 10. +user.audit.maxFileSizeMb=10 + +# Maximum number of rotated audit log files to retain (e.g. user-audit.log.1 .. user-audit.log.5). The +# oldest archive beyond this count is deleted on rotation. Default is 5. +user.audit.maxFiles=5 + # If true, all events will be logged. user.audit.logEvents=true diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java index a9bd461..a70a990 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java @@ -135,6 +135,60 @@ void returnsEvents_sortedByTimestampDescending() throws IOException { } } + @Nested + @DisplayName("Bounded query") + class BoundedQuery { + + @Test + @DisplayName("caps results at maxQueryResults and returns the most recent window") + void capsResults_andReturnsMostRecent() throws IOException { + // Given - small cap, and more matching lines than the cap + setupLogFilePath(); + when(auditConfig.getMaxQueryResults()).thenReturn(5); + + StringBuilder sb = new StringBuilder(); + sb.append("Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data\n"); + // 20 events, increasing timestamps; the newest 5 are minutes 15..19 + for (int i = 0; i < 20; i++) { + String ts = String.format("2025-01-15T10:%02d:00Z", i); + sb.append(ts).append("|Action").append(i) + .append("|Success|1|test@example.com|127.0.0.1|sess").append(i) + .append("|msg|Mozilla/5.0|null\n"); + } + Files.writeString(logFile, sb.toString()); + + // When + List result = queryService.findByUser(testUser); + + // Then - exactly maxQueryResults, newest first (Action19 .. Action15) + assertThat(result).hasSize(5); + assertThat(result).extracting(AuditEventDTO::getAction) + .containsExactly("Action19", "Action18", "Action17", "Action16", "Action15"); + } + + @Test + @DisplayName("returns all matches when fewer than maxQueryResults, newest first") + void returnsAll_whenUnderCap() throws IOException { + // Given - cap larger than the number of matching lines + setupLogFilePath(); + when(auditConfig.getMaxQueryResults()).thenReturn(100); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + 2025-01-15T08:00:00Z|Login|Success|1|test@example.com|127.0.0.1|sess1|First|Mozilla/5.0|null + 2025-01-15T12:00:00Z|Logout|Success|1|test@example.com|127.0.0.1|sess2|Third|Mozilla/5.0|null + 2025-01-15T10:00:00Z|PasswordUpdate|Success|1|test@example.com|127.0.0.1|sess3|Second|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then - preserves newest-first ordering of the full result set + assertThat(result).extracting(AuditEventDTO::getAction) + .containsExactly("Logout", "PasswordUpdate", "Login"); + } + } + @Nested @DisplayName("findByUserAndAction") class FindByUserAndAction { diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java index a5bd07a..0f049c8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java @@ -334,6 +334,92 @@ void writeLog_flushesWhenConfigured() throws IOException { } } + @Nested + @DisplayName("Rotation Tests") + class RotationTests { + + private AuditEvent event(String action) { + return AuditEvent.builder() + .source(this) + .action(action) + .actionStatus("Success") + .message("rotation test message padding padding padding") + .build(); + } + + @Test + @DisplayName("rotates the active file when the size threshold is exceeded") + void rotatesActiveFile_whenThresholdExceeded() throws IOException { + // Given - a tiny effective byte threshold so a few writes trigger rotation + when(auditConfig.isFlushOnWrite()).thenReturn(true); + when(auditConfig.getMaxFileSizeMb()).thenReturn(10); // positive => rotation enabled + when(auditConfig.getMaxFiles()).thenReturn(5); + fileAuditLogWriter.setup(); + fileAuditLogWriter.setMaxFileSizeBytesForTesting(120L); // exceeded quickly + + Path active = Path.of(logFilePath); + Path rotated1 = Path.of(logFilePath + ".1"); + + // When - write enough to exceed the threshold + for (int i = 0; i < 10; i++) { + fileAuditLogWriter.writeLog(event("Action" + i)); + } + + // Then - a rotated file exists and the active file is fresh (header + recent writes only) + assertTrue(Files.exists(rotated1), "rotated file .1 should exist after rotation"); + assertTrue(Files.exists(active), "active log file should be reopened after rotation"); + String activeContent = Files.readString(active); + assertTrue(activeContent.contains("Date|Action|Action Status"), + "freshly reopened active file should contain the header"); + assertTrue(Files.size(active) < Files.size(rotated1) + 4096, + "active file should be smaller than total rotated content"); + } + + @Test + @DisplayName("respects maxFiles by deleting the oldest rotated file") + void respectsMaxFiles_deletingOldest() throws IOException { + // Given - keep only 2 rotated files + when(auditConfig.isFlushOnWrite()).thenReturn(true); + when(auditConfig.getMaxFileSizeMb()).thenReturn(10); + when(auditConfig.getMaxFiles()).thenReturn(2); + fileAuditLogWriter.setup(); + fileAuditLogWriter.setMaxFileSizeBytesForTesting(80L); + + Path rotated1 = Path.of(logFilePath + ".1"); + Path rotated2 = Path.of(logFilePath + ".2"); + Path rotated3 = Path.of(logFilePath + ".3"); + + // When - force several rotations + for (int i = 0; i < 40; i++) { + fileAuditLogWriter.writeLog(event("Action" + i)); + } + + // Then - at most maxFiles rotated files are kept; .3 must never exist + assertTrue(Files.exists(rotated1), ".1 should exist"); + assertTrue(Files.exists(rotated2), ".2 should exist"); + assertTrue(!Files.exists(rotated3), ".3 should have been deleted (exceeds maxFiles)"); + } + + @Test + @DisplayName("does not rotate when rotation is disabled (maxFileSizeMb <= 0)") + void doesNotRotate_whenDisabled() throws IOException { + // Given - rotation disabled + when(auditConfig.isFlushOnWrite()).thenReturn(true); + when(auditConfig.getMaxFileSizeMb()).thenReturn(0); + fileAuditLogWriter.setup(); + + Path rotated1 = Path.of(logFilePath + ".1"); + + // When - write a lot + for (int i = 0; i < 50; i++) { + fileAuditLogWriter.writeLog(event("Action" + i)); + } + + // Then - no rotation occurred + assertTrue(!Files.exists(rotated1), "no rotated file should exist when rotation is disabled"); + } + } + @Nested @DisplayName("Complete Event Data Tests") class CompleteEventDataTests { From d8d7115dc34ceb23b7f64cba50c9ab796d9e5fed Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 22:02:01 -0600 Subject: [PATCH 20/55] fix(session): make User Serializable for clustered/persistent sessions --- .../user/persistence/model/Privilege.java | 6 +- .../spring/user/persistence/model/Role.java | 7 +- .../spring/user/persistence/model/User.java | 12 ++- .../DSUserDetailsSerializationTest.java | 79 +++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java index 691697f..9c99ab7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.persistence.model; +import java.io.Serializable; import java.util.Collection; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -19,7 +20,10 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @Entity -public class Privilege { +public class Privilege implements Serializable { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; /** The id. */ @Id diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java index c18a572..516414e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.persistence.model; +import java.io.Serializable; import java.util.HashSet; import java.util.Set; import jakarta.persistence.CascadeType; @@ -24,7 +25,11 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @Entity -public class Role { +public class Role implements Serializable { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + /** The id. */ @Id @GeneratedValue(strategy = GenerationType.AUTO) diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java index 063e06d..2f9360c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.persistence.model; +import java.io.Serializable; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; @@ -16,12 +17,21 @@ /** * The User Entity. Part of the basic User ->> Role ->> Privilege structure. This is the primary user data object. You can add to this, or add * referenced types as needed. Leverages the Spring JPA Auditing framework to automatically manage the registrationDate and lastActivityDate fields. + * + *

    + * This entity implements {@link Serializable} so it can be stored in the HTTP session as part of the authenticated principal + * ({@code DSUserDetails}) and serialized by distributed/persistent session stores such as Spring Session JDBC or Redis. Consumers using + * distributed sessions must ensure any custom profile or data reachable from the session-stored principal is also {@link Serializable}. + *

    */ @Data @Entity @EntityListeners(AuditingEntityListener.class) @Table(name = "user_account") -public class User { +public class User implements Serializable { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; /** * Enum representing the available login providers. diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java new file mode 100644 index 0000000..b463550 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java @@ -0,0 +1,79 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import com.digitalsanctuary.spring.user.persistence.model.Privilege; +import com.digitalsanctuary.spring.user.persistence.model.Role; +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Verifies that the authenticated principal graph stored in the HTTP session is serializable. + * + *

    This is required for distributed/persistent session stores (e.g. Spring Session JDBC/Redis), + * where the {@link DSUserDetails} principal and its reachable object graph ({@link User} -> + * {@link Role} -> {@link Privilege}) must round-trip through Java serialization.

    + */ +@DisplayName("DSUserDetails Serialization Tests") +class DSUserDetailsSerializationTest { + + /** + * Builds a fully-populated, eagerly-initialized principal and asserts it survives a Java + * serialization round-trip with its key fields intact. This exercises the entire reachable + * object graph (User -> Role -> Privilege), proving the session-stored principal is serializable. + */ + @Test + @DisplayName("Should round-trip DSUserDetails principal graph through Java serialization") + void shouldSerializeAndDeserializePrincipalGraph() throws Exception { + Privilege readPrivilege = new Privilege("READ_PRIVILEGE", "Read access"); + readPrivilege.setId(100L); + + Role userRole = new Role("ROLE_USER", "Standard user"); + userRole.setId(10L); + userRole.setPrivileges(Set.of(readPrivilege)); + + User user = new User(); + user.setId(1L); + user.setEmail("serialize@test.com"); + user.setFirstName("Serial"); + user.setLastName("Izable"); + user.setEnabled(true); + user.setRolesAsSet(Set.of(userRole)); + + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_USER"); + DSUserDetails principal = new DSUserDetails(user, List.of(authority)); + + byte[] bytes; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(principal); + oos.flush(); + bytes = baos.toByteArray(); + } + + DSUserDetails roundTripped; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + roundTripped = (DSUserDetails) ois.readObject(); + } + + assertThat(roundTripped).isNotNull(); + assertThat(roundTripped.getUsername()).isEqualTo("serialize@test.com"); + assertThat(roundTripped.getUser().getEmail()).isEqualTo("serialize@test.com"); + assertThat(roundTripped.getUser().getFirstName()).isEqualTo("Serial"); + assertThat(roundTripped.isEnabled()).isTrue(); + assertThat(roundTripped.getAuthorities()).extracting(GrantedAuthority::getAuthority).containsExactly("ROLE_USER"); + assertThat(roundTripped.getUser().getRoles()).extracting(Role::getName).containsExactly("ROLE_USER"); + assertThat(roundTripped.getUser().getRolesAsSet().iterator().next().getPrivileges()).extracting(Privilege::getName) + .containsExactly("READ_PRIVILEGE"); + } +} From 653ac443f2ab5f53eab362319a67d175a6f9a05c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 22:22:44 -0600 Subject: [PATCH 21/55] fix(extensibility): enforce RegistrationGuard in the service for all paths; support composite guards --- REGISTRATION-GUARD.md | 46 ++++- .../spring/user/api/UserAPI.java | 33 ++- .../CompositeRegistrationGuard.java | 70 +++++++ .../RegistrationDeniedException.java | 43 ++++ .../RegistrationGuardConfiguration.java | 53 ++++- .../user/service/DSOAuth2UserService.java | 21 +- .../user/service/DSOidcUserService.java | 21 +- .../spring/user/service/UserService.java | 67 +++++++ .../api/UserAPIRegistrationGuardTest.java | 22 +- .../spring/user/api/UserAPIUnitTest.java | 8 - .../CompositeRegistrationGuardTest.java | 132 ++++++++++++ .../RegistrationGuardConfigurationTest.java | 127 ++++++++++++ ...Auth2UserServiceRegistrationGuardTest.java | 28 ++- .../user/service/DSOAuth2UserServiceTest.java | 7 +- ...SOidcUserServiceRegistrationGuardTest.java | 28 ++- .../user/service/DSOidcUserServiceTest.java | 7 +- .../service/TokenHashingSecurityTest.java | 2 +- .../UserServiceRegistrationGuardTest.java | 189 ++++++++++++++++++ .../spring/user/service/UserServiceTest.java | 10 + 19 files changed, 813 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java diff --git a/REGISTRATION-GUARD.md b/REGISTRATION-GUARD.md index 057f4ae..0fac5b2 100644 --- a/REGISTRATION-GUARD.md +++ b/REGISTRATION-GUARD.md @@ -14,6 +14,7 @@ This guide explains how to use the Registration Guard SPI in Spring User Framewo - [Invite-Only with OAuth2 Bypass](#invite-only-with-oauth2-bypass) - [Beta Access / Waitlist](#beta-access--waitlist) - [Denial Behavior](#denial-behavior) + - [Composing Multiple Guards](#composing-multiple-guards) - [Key Constraints](#key-constraints) - [Troubleshooting](#troubleshooting) @@ -54,11 +55,19 @@ The Registration Guard SPI consists of these types in the `com.digitalsanctuary. 4. **`RegistrationSource`** — Enum identifying the registration path: `FORM`, `PASSWORDLESS`, `OAUTH2`, `OIDC` -5. **`DefaultRegistrationGuard`** — The built-in permit-all fallback. Automatically registered via `@ConditionalOnMissingBean` when no custom guard bean exists. +5. **`DefaultRegistrationGuard`** — The built-in permit-all fallback. Automatically registered via `@ConditionalOnMissingBean` when no custom guard bean exists, so the composite always has at least one delegate. + +6. **`CompositeRegistrationGuard`** — The primary (`@Primary`) guard the framework injects everywhere. It wraps **all** `RegistrationGuard` beans and evaluates them in order with **first-deny-wins** semantics (see [Composing Multiple Guards](#composing-multiple-guards)). You normally never reference it directly. + +7. **`RegistrationDeniedException`** — A `RuntimeException` carrying the denial `reason`. The framework throws this from the service layer when a guard denies, and translates it into the appropriate response per path (see [Denial Behavior](#denial-behavior)). Consumers rarely need to catch it. + +### Where the guard runs + +The guard is enforced **inside `UserService`** (and, for first-time social sign-ups, via `UserService.enforceRegistrationGuard(...)` called by the OAuth2/OIDC user services). Because enforcement lives in the service rather than the controller, **every** registration path — REST API, OAuth2, OIDC, and any direct call to `UserService.registerNewUserAccount(...)` / `registerPasswordlessAccount(...)` — is guarded exactly once and cannot be bypassed. The guard runs only for **new** registrations; existing users logging in are never evaluated. ## Implementation Guide -Create a `@Component` that implements `RegistrationGuard`. That's it — the default guard is automatically replaced. +Create a `@Component` that implements `RegistrationGuard`. That's it — the default guard is automatically replaced, and your guard is wrapped by the composite. ```java @Component @@ -158,11 +167,34 @@ The JSON error code `6` identifies a registration guard denial specifically, dis For OAuth2/OIDC denials, customize the user experience by configuring Spring Security's OAuth2 login failure handler to inspect the error code and display an appropriate message. -All denied registrations are logged at INFO level with the email, source, and denial reason. +All denied registrations are logged at INFO level with the source and denial reason. + +Internally, a denial surfaces from the service layer as a `RegistrationDeniedException` carrying the reason. The REST API catches it and returns the form/passwordless JSON above; the OAuth2/OIDC user services catch it and re-throw the `OAuth2AuthenticationException` shown above. The HTTP contract is identical regardless of how registration was triggered. + +## Composing Multiple Guards + +You may define **more than one** `RegistrationGuard` bean. The framework wraps them all in a `CompositeRegistrationGuard` (registered as `@Primary`) that evaluates them **in order, first-deny-wins**: + +- Guards are consulted in `@Order` / `org.springframework.core.Ordered` order (lowest value first; unordered beans come last). +- The **first** guard to return `deny(...)` short-circuits — later guards are not consulted — and its reason is propagated. +- If every guard allows (or you define no guards at all, leaving only the permit-all default), registration proceeds. + +This lets you layer independent policies — for example an invite-only guard **and** a domain-allowlist guard — where any single denial blocks the registration. Each guard stays small and single-purpose: + +```java +@Component +@Order(1) +public class InviteOnlyGuard implements RegistrationGuard { /* ... */ } + +@Component +@Order(2) +public class DomainAllowlistGuard implements RegistrationGuard { /* ... */ } +``` ## Key Constraints -- **Single-bean SPI** — Only one `RegistrationGuard` bean may be active at a time. This is not a chain or filter pattern; define exactly one guard. +- **Composable SPI** — One or more `RegistrationGuard` beans may be active; they are composed with first-deny-wins ordering. (You can still define exactly one guard — that is just a composite of size one.) +- **Enforced in the service** — The guard runs inside `UserService`, so direct callers of the service registration methods are guarded too; the SPI cannot be bypassed by skipping the controller. - **Thread safety required** — The guard may be invoked concurrently from multiple request threads. Ensure your implementation is thread-safe. - **No configuration properties** — The guard is activated entirely by bean presence. There are no `user.*` properties involved. - **Existing users unaffected** — The guard only runs for new registrations. Existing users logging in via OAuth2/OIDC are not evaluated. @@ -176,8 +208,8 @@ All denied registrations are logged at INFO level with the email, source, and de - You can also check the active guard via `/actuator/beans` (if enabled) or your IDE's Spring tooling. **Multiple Guards Defined** -- Only one `RegistrationGuard` bean is allowed. If multiple beans are defined, Spring will throw a `NoUniqueBeanDefinitionException` at startup. -- If you need to compose multiple rules, implement a single guard that delegates internally. +- Multiple `RegistrationGuard` beans are fully supported — they are composed automatically with first-deny-wins ordering (see [Composing Multiple Guards](#composing-multiple-guards)). Use `@Order` to control evaluation order. +- The framework injects the `@Primary` `CompositeRegistrationGuard` everywhere, so defining several guards does **not** cause a `NoUniqueBeanDefinitionException`. **OAuth2/OIDC Denial UX** - By default, OAuth2/OIDC denials redirect to Spring Security's default failure URL with a generic error. @@ -197,6 +229,6 @@ All denied registrations are logged at INFO level with the email, source, and de --- -This SPI provides a clean extension point for controlling registration without modifying framework internals. Implement a single bean, return allow or deny, and the framework handles the rest across all registration paths. +This SPI provides a clean extension point for controlling registration without modifying framework internals. Implement one or more beans, return allow or deny, and the framework composes them and handles the rest across all registration paths. For a complete working example, refer to the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp). diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java index fc478c7..1dd2dfb 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -31,10 +31,8 @@ import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.registration.RegistrationGuard; -import com.digitalsanctuary.spring.user.registration.RegistrationSource; import com.digitalsanctuary.spring.user.service.DSUserDetails; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; @@ -74,7 +72,6 @@ public class UserAPI { private final ApplicationEventPublisher eventPublisher; private final PasswordPolicyService passwordPolicyService; private final ObjectProvider webAuthnCredentialManagementServiceProvider; - private final RegistrationGuard registrationGuard; @Value("${user.security.registrationPendingURI}") private String registrationPendingURI; @@ -112,13 +109,10 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User return buildErrorResponse(String.join(" ", errors), 1, HttpStatus.BAD_REQUEST); } - RegistrationDecision decision = registrationGuard.evaluate( - new RegistrationContext(userDto.getEmail(), RegistrationSource.FORM, null)); - if (!decision.allowed()) { - log.info("Registration denied for email: {} source: FORM reason: {}", userDto.getEmail(), decision.reason()); - return buildErrorResponse(decision.reason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); - } - + // The RegistrationGuard is now enforced inside UserService.registerNewUserAccount so that every + // registration path is guarded exactly once and direct service callers cannot bypass it. A + // denial surfaces as RegistrationDeniedException, translated below into the same + // REGISTRATION_DENIED response this endpoint returned previously. User registeredUser = userService.registerNewUserAccount(userDto); publishRegistrationEvent(registeredUser, request); logAuditEvent("Registration", "Success", "Registration Successful", registeredUser, request); @@ -126,6 +120,9 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; return buildSuccessResponse("Registration Successful!", nextURL); + } catch (RegistrationDeniedException ex) { + log.info("Registration denied for email: {} source: FORM reason: {}", userDto.getEmail(), ex.getReason()); + return buildErrorResponse(ex.getReason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); } catch (UserAlreadyExistException ex) { log.warn("User already exists with email: {}", userDto.getEmail()); logAuditEvent("Registration", "Failure", "User Already Exists", null, request); @@ -417,13 +414,10 @@ public ResponseEntity registerPasswordlessAccount(@Valid @RequestB return buildErrorResponse("Passwordless registration is not available", 1, HttpStatus.BAD_REQUEST); } try { - RegistrationDecision decision = registrationGuard.evaluate( - new RegistrationContext(dto.getEmail(), RegistrationSource.PASSWORDLESS, null)); - if (!decision.allowed()) { - log.info("Registration denied for email: {} source: PASSWORDLESS reason: {}", dto.getEmail(), decision.reason()); - return buildErrorResponse(decision.reason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); - } - + // The RegistrationGuard is now enforced inside UserService.registerPasswordlessAccount so that + // every registration path is guarded exactly once and direct service callers cannot bypass it. + // A denial surfaces as RegistrationDeniedException, translated below into the same + // REGISTRATION_DENIED response this endpoint returned previously. User registeredUser = userService.registerPasswordlessAccount(dto); publishRegistrationEvent(registeredUser, request); logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request); @@ -431,6 +425,9 @@ public ResponseEntity registerPasswordlessAccount(@Valid @RequestB String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; return buildSuccessResponse("Registration Successful!", nextURL); + } catch (RegistrationDeniedException ex) { + log.info("Registration denied for email: {} source: PASSWORDLESS reason: {}", dto.getEmail(), ex.getReason()); + return buildErrorResponse(ex.getReason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); } catch (UserAlreadyExistException ex) { log.warn("User already exists with email: {}", dto.getEmail()); logAuditEvent("PasswordlessRegistration", "Failure", "User Already Exists", null, request); diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java new file mode 100644 index 0000000..08f75fa --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java @@ -0,0 +1,70 @@ +package com.digitalsanctuary.spring.user.registration; + +import java.util.ArrayList; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +/** + * A {@link RegistrationGuard} that composes multiple delegate guards and evaluates them in order, + * applying first-deny-wins semantics. + * + *

    The delegates are consulted sequentially. The first delegate that returns a + * {@link RegistrationDecision#deny(String) deny} decision short-circuits evaluation — subsequent + * delegates are not consulted — and its reason is propagated. If every delegate allows (or there are + * no delegates at all), the composite allows the registration.

    + * + *

    This enables layered policies, e.g. an invite-only guard and a domain-allowlist guard, + * where any single denial blocks the registration. The order of evaluation follows the order of the + * injected list; consumers can control it with Spring's {@code @Order}/{@link org.springframework.core.Ordered}.

    + * + *

    Thread Safety: This class holds an immutable snapshot of its delegates and is + * therefore thread-safe provided the delegates themselves are thread-safe (as required by the SPI).

    + * + * @see RegistrationGuard + * @see RegistrationGuardConfiguration + */ +@Slf4j +public class CompositeRegistrationGuard implements RegistrationGuard { + + /** The ordered, immutable list of delegate guards. Never {@code null}; may be empty. */ + private final List delegates; + + /** + * Instantiates a new composite registration guard. + * + * @param delegates the ordered delegate guards to consult; a {@code null} list is treated as empty + */ + public CompositeRegistrationGuard(final List delegates) { + this.delegates = delegates == null ? List.of() : List.copyOf(delegates); + log.debug("CompositeRegistrationGuard initialized with {} delegate guard(s)", this.delegates.size()); + } + + /** + * Evaluates each delegate in order, returning the first denial encountered (first-deny-wins). If no + * delegate denies, the registration is allowed. + * + * @param context the registration context describing the attempt + * @return the first denying {@link RegistrationDecision}, or {@link RegistrationDecision#allow()} if + * all delegates allow + */ + @Override + public RegistrationDecision evaluate(final RegistrationContext context) { + for (RegistrationGuard delegate : delegates) { + RegistrationDecision decision = delegate.evaluate(context); + if (decision != null && !decision.allowed()) { + return decision; + } + } + return RegistrationDecision.allow(); + } + + /** + * Returns the delegate guards composed by this guard, in evaluation order. + * + * @return an immutable list of delegate guards + */ + public List getDelegates() { + return new ArrayList<>(delegates); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java new file mode 100644 index 0000000..0bb4ada --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java @@ -0,0 +1,43 @@ +package com.digitalsanctuary.spring.user.registration; + +/** + * Thrown when a {@link RegistrationGuard} denies a registration attempt. + * + *

    This exception is raised from the service layer (e.g. {@code UserService}) so that the guard is + * enforced exactly once for every registration path — form, passwordless, OAuth2, and OIDC — and can + * never be bypassed by direct callers of the service registration methods. Callers (such as the REST + * controller or the OAuth/OIDC user services) catch this exception and translate it into the + * appropriate denial response for their transport.

    + * + *

    The {@link #getReason() reason} carries the human-readable denial message produced by the guard + * via {@link RegistrationDecision#deny(String)}.

    + * + * @see RegistrationGuard + * @see RegistrationDecision + */ +public class RegistrationDeniedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** The human-readable reason the registration was denied. */ + private final String reason; + + /** + * Instantiates a new registration denied exception. + * + * @param reason the human-readable denial reason from the guard + */ + public RegistrationDeniedException(final String reason) { + super(reason); + this.reason = reason; + } + + /** + * Gets the human-readable reason the registration was denied. + * + * @return the denial reason + */ + public String getReason() { + return reason; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java index 403c333..d9d09e0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java @@ -1,25 +1,70 @@ package com.digitalsanctuary.spring.user.registration; +import java.util.List; + import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import lombok.extern.slf4j.Slf4j; /** * Auto-configuration for the {@link RegistrationGuard} SPI. * - *

    Registers a {@link DefaultRegistrationGuard} (permit-all) when no custom - * {@link RegistrationGuard} bean is defined by the consuming application.

    + *

    Two beans are registered:

    + *
      + *
    • A {@link DefaultRegistrationGuard} (permit-all) named {@code defaultRegistrationGuard}, created + * only when the consuming application defines no other "custom" {@link RegistrationGuard} bean. + * This guarantees the composite always has at least one delegate so registration is never + * silently un-guarded.
    • + *
    • A {@link Primary} {@link CompositeRegistrationGuard} that wraps every {@link RegistrationGuard} + * bean (the default, or one-or-more consumer guards) and evaluates them in order with + * first-deny-wins semantics. Because it is {@code @Primary}, all single-valued + * {@code RegistrationGuard} injection points (e.g. {@code UserService}) resolve to it.
    • + *
    + * + *

    The {@code List} injected into the composite factory method is populated by + * Spring with all {@link RegistrationGuard} beans except the composite itself (which is still under + * construction), ordered by {@code @Order}/{@link org.springframework.core.Ordered} where present.

    */ @Slf4j @Configuration public class RegistrationGuardConfiguration { + /** + * Registers the permit-all {@link DefaultRegistrationGuard} when the consuming application supplies + * no other {@link RegistrationGuard} bean. + * + *

    The {@code @ConditionalOnMissingBean} explicitly {@code ignored}s the + * {@link CompositeRegistrationGuard} declared in this same configuration. Without that, the + * composite (which itself implements {@link RegistrationGuard}) could satisfy the condition and + * suppress the default — leaving the composite with an empty delegate list when the consumer + * provides no guards. By ignoring the composite, the default is created when (and only when) the + * consumer provides no {@link RegistrationGuard}, guaranteeing the composite always has at + * least one delegate.

    + * + * @return a permit-all registration guard + */ @Bean - @ConditionalOnMissingBean(RegistrationGuard.class) - public RegistrationGuard registrationGuard() { + @ConditionalOnMissingBean(value = RegistrationGuard.class, ignored = CompositeRegistrationGuard.class) + public DefaultRegistrationGuard defaultRegistrationGuard() { log.info("No custom RegistrationGuard bean found — using DefaultRegistrationGuard (permit-all)"); return new DefaultRegistrationGuard(); } + + /** + * Registers the primary {@link CompositeRegistrationGuard} that composes all available + * {@link RegistrationGuard} delegates with first-deny-wins ordering. + * + * @param guards all {@link RegistrationGuard} beans (the default permit-all, or one-or-more consumer + * guards), ordered by {@code @Order}/{@link org.springframework.core.Ordered} + * @return the primary composite registration guard + */ + @Bean + @Primary + public CompositeRegistrationGuard compositeRegistrationGuard(final List guards) { + log.info("Registering CompositeRegistrationGuard with {} delegate guard(s)", guards.size()); + return new CompositeRegistrationGuard(guards); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index 7e7d9f6..d276104 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -14,9 +14,7 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.registration.RegistrationSource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,7 +45,8 @@ public class DSOAuth2UserService implements OAuth2UserServiceThis centralizes guard enforcement in the service so that every registration path — + * including direct callers of the public registration methods — is guarded exactly once with the + * correct {@link RegistrationSource}. The injected guard is the primary + * {@link com.digitalsanctuary.spring.user.registration.CompositeRegistrationGuard composite}, so all + * configured guards are applied with first-deny-wins semantics.

    + * + * @param email the email address of the registration attempt (may be {@code null}) + * @param source the registration source; never {@code null} + * @param providerName the OAuth2/OIDC provider registration id, or {@code null} for form/passwordless + * @throws RegistrationDeniedException if the guard denies the registration + */ + private void evaluateRegistrationGuard(final String email, final RegistrationSource source, final String providerName) { + RegistrationDecision decision = registrationGuard.evaluate(new RegistrationContext(email, source, providerName)); + if (!decision.allowed()) { + log.info("Registration denied for source: {} provider: {} reason: {}", source, providerName, decision.reason()); + throw new RegistrationDeniedException(decision.reason()); + } + } + + /** + * Enforces the configured {@link RegistrationGuard} for a first-time OAuth2/OIDC social registration. + * + *

    The OAuth2 and OIDC user services build and persist new social users themselves (with + * provider-specific role assignment and audit events). To keep guard enforcement centralized in this + * service — so the guard SPI lives in exactly one place and direct callers of the registration paths + * cannot bypass it — those services delegate the guard check here at the point a NEW social user is + * about to be created (never on login of an existing OAuth/OIDC user). On denial this throws + * {@link RegistrationDeniedException}, which the OAuth/OIDC services translate into the appropriate + * {@code OAuth2AuthenticationException}.

    + * + * @param email the email address from the OAuth2/OIDC provider + * @param source the registration source ({@link RegistrationSource#OAUTH2} or + * {@link RegistrationSource#OIDC}) + * @param providerName the OAuth2/OIDC provider registration id (e.g. {@code "google"}, {@code "keycloak"}) + * @throws RegistrationDeniedException if the guard denies the registration + */ + public void enforceRegistrationGuard(final String email, final RegistrationSource source, final String providerName) { + evaluateRegistrationGuard(email, source, providerName); + } + /** * Validate password reset token. * diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java index 73e7840..e26c893 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java @@ -28,11 +28,8 @@ import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; @@ -67,9 +64,6 @@ class UserAPIRegistrationGuardTest { @Mock private ObjectProvider webAuthnCredentialManagementServiceProvider; - @Mock - private RegistrationGuard registrationGuard; - @Mock private WebAuthnCredentialManagementService webAuthnService; @@ -102,8 +96,9 @@ void shouldRejectFormRegistrationWhenGuardDenies() throws Exception { when(passwordPolicyService.validate(any(), anyString(), anyString(), any(Locale.class))) .thenReturn(Collections.emptyList()); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Registration is by invitation only")); + // The guard now fires inside the service: a denial surfaces as RegistrationDeniedException. + when(userService.registerNewUserAccount(any(UserDto.class))) + .thenThrow(new RegistrationDeniedException("Registration is by invitation only")); mockMvc.perform(post("/user/registration") .contentType(MediaType.APPLICATION_JSON) @@ -133,8 +128,6 @@ void shouldAllowFormRegistrationWhenGuardAllows() throws Exception { when(passwordPolicyService.validate(any(), anyString(), anyString(), any(Locale.class))) .thenReturn(Collections.emptyList()); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); when(userService.registerNewUserAccount(any(UserDto.class))).thenReturn(registeredUser); mockMvc.perform(post("/user/registration") @@ -159,8 +152,9 @@ void shouldRejectPasswordlessRegistrationWhenGuardDenies() throws Exception { dto.setLastName("User"); when(webAuthnCredentialManagementServiceProvider.getIfAvailable()).thenReturn(webAuthnService); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Beta access required")); + // The guard now fires inside the service: a denial surfaces as RegistrationDeniedException. + when(userService.registerPasswordlessAccount(any(PasswordlessRegistrationDto.class))) + .thenThrow(new RegistrationDeniedException("Beta access required")); mockMvc.perform(post("/user/registration/passwordless") .contentType(MediaType.APPLICATION_JSON) @@ -186,8 +180,6 @@ void shouldAllowPasswordlessRegistrationWhenGuardAllows() throws Exception { .build(); when(webAuthnCredentialManagementServiceProvider.getIfAvailable()).thenReturn(webAuthnService); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); when(userService.registerPasswordlessAccount(any(PasswordlessRegistrationDto.class))) .thenReturn(registeredUser); diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java index e463026..62be1f0 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -17,8 +17,6 @@ import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; import com.digitalsanctuary.spring.user.service.DSUserDetails; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; @@ -93,9 +91,6 @@ public JSONResponse handleSecurityException(SecurityException e) { @Mock private PasswordPolicyService passwordPolicyService; - @Mock - private RegistrationGuard registrationGuard; - @InjectMocks private UserAPI userAPI; @@ -123,9 +118,6 @@ void setUp() { testUserDto.setRole(1); testUserDetails = new DSUserDetails(testUser); - - // Default guard allows all registrations - lenient().when(registrationGuard.evaluate(any())).thenReturn(RegistrationDecision.allow()); // Set field values using reflection ReflectionTestUtils.setField(userAPI, "registrationPendingURI", "/user/registration-pending.html"); diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java new file mode 100644 index 0000000..71bd0ba --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java @@ -0,0 +1,132 @@ +package com.digitalsanctuary.spring.user.registration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CompositeRegistrationGuard} verifying ordered, first-deny-wins composition. + */ +@DisplayName("CompositeRegistrationGuard Tests") +class CompositeRegistrationGuardTest { + + private static final RegistrationContext CONTEXT = + new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + + @Nested + @DisplayName("Cardinality") + class Cardinality { + + @Test + @DisplayName("Zero custom guards (only default) allows") + void zeroGuardsAllows() { + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of()); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + } + + @Test + @DisplayName("Null delegate list is treated as empty and allows") + void nullDelegatesAllows() { + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(null); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + } + + @Test + @DisplayName("Single allowing guard allows") + void singleAllowingGuardAllows() { + RegistrationGuard guard = ctx -> RegistrationDecision.allow(); + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(guard)); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + } + + @Test + @DisplayName("Single denying guard denies and propagates reason") + void singleDenyingGuardDenies() { + RegistrationGuard guard = ctx -> RegistrationDecision.deny("nope"); + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(guard)); + + RegistrationDecision decision = composite.evaluate(CONTEXT); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("nope"); + } + } + + @Nested + @DisplayName("Ordering (first-deny-wins)") + class Ordering { + + @Test + @DisplayName("All-allow guards proceed (allow)") + void allAllowProceeds() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + when(second.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + verify(first).evaluate(CONTEXT); + verify(second).evaluate(CONTEXT); + } + + @Test + @DisplayName("First guard denies: second is NOT consulted; first reason wins") + void firstDenyShortCircuits() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.deny("first denied")); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + RegistrationDecision decision = composite.evaluate(CONTEXT); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("first denied"); + verify(first).evaluate(CONTEXT); + // Short-circuit: the second guard must never be consulted once the first denies. + verify(second, never()).evaluate(CONTEXT); + } + + @Test + @DisplayName("Second guard denies when first allows; second reason propagates") + void secondDenyAfterFirstAllow() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + when(second.evaluate(CONTEXT)).thenReturn(RegistrationDecision.deny("second denied")); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + RegistrationDecision decision = composite.evaluate(CONTEXT); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("second denied"); + verify(first).evaluate(CONTEXT); + verify(second).evaluate(CONTEXT); + } + } + + @Test + @DisplayName("getDelegates returns the composed guards in order") + void getDelegatesReturnsGuardsInOrder() { + RegistrationGuard first = ctx -> RegistrationDecision.allow(); + RegistrationGuard second = ctx -> RegistrationDecision.allow(); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + assertThat(composite.getDelegates()).containsExactly(first, second); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java new file mode 100644 index 0000000..ad97c42 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java @@ -0,0 +1,127 @@ +package com.digitalsanctuary.spring.user.registration; + +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.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; + +/** + * Verifies the wiring contract of {@link RegistrationGuardConfiguration}: + * + *
      + *
    • With no consumer guard, the permit-all {@link DefaultRegistrationGuard} is registered so the + * composite always has a delegate.
    • + *
    • The {@link CompositeRegistrationGuard} is always registered and is the {@code @Primary} bean, so + * single-valued {@link RegistrationGuard} injection points resolve to it.
    • + *
    • With one or more consumer guards, the default is NOT added and the composite delegates to the + * consumer guards in {@code @Order} with first-deny-wins semantics.
    • + *
    + */ +@DisplayName("RegistrationGuardConfiguration Wiring Tests") +class RegistrationGuardConfigurationTest { + + private static final RegistrationContext CONTEXT = + new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + + // Register RegistrationGuardConfiguration as an auto-configuration so its @ConditionalOnMissingBean + // evaluates AFTER any consumer-supplied guard beans — mirroring production, where this configuration + // is component-scanned by the UserConfiguration auto-configuration (loaded after consumer beans). + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RegistrationGuardConfiguration.class)); + + @Test + @DisplayName("No consumer guard: default permit-all registered, composite is primary and allows") + void noConsumerGuard() { + runner.run(ctx -> { + assertThat(ctx).hasSingleBean(DefaultRegistrationGuard.class); + assertThat(ctx).hasSingleBean(CompositeRegistrationGuard.class); + // The primary RegistrationGuard injection point resolves to the composite. + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + assertThat(primary).isInstanceOf(CompositeRegistrationGuard.class); + assertThat(primary.evaluate(CONTEXT).allowed()).isTrue(); + }); + } + + @Test + @DisplayName("One consumer guard: default NOT registered, composite delegates to the consumer guard") + void oneConsumerGuard() { + runner.withUserConfiguration(OneDenyingGuardConfig.class).run(ctx -> { + assertThat(ctx).doesNotHaveBean(DefaultRegistrationGuard.class); + assertThat(ctx).hasSingleBean(CompositeRegistrationGuard.class); + + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + assertThat(primary).isInstanceOf(CompositeRegistrationGuard.class); + + RegistrationDecision decision = primary.evaluate(CONTEXT); + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("consumer denied"); + }); + } + + @Test + @DisplayName("Many consumer guards: ordered first-deny-wins; later guard not consulted") + void manyConsumerGuardsFirstDenyWins() { + runner.withUserConfiguration(TwoOrderedGuardsConfig.class).run(ctx -> { + assertThat(ctx).doesNotHaveBean(DefaultRegistrationGuard.class); + + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + RegistrationDecision decision = primary.evaluate(CONTEXT); + + // The @Order(1) guard denies first and short-circuits the @Order(2) guard. + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("first"); + }); + } + + @Test + @DisplayName("Many consumer guards all allowing: registration proceeds") + void manyConsumerGuardsAllAllow() { + runner.withUserConfiguration(TwoAllowingGuardsConfig.class).run(ctx -> { + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + assertThat(primary.evaluate(CONTEXT).allowed()).isTrue(); + }); + } + + @Configuration + static class OneDenyingGuardConfig { + @Bean + RegistrationGuard consumerGuard() { + return context -> RegistrationDecision.deny("consumer denied"); + } + } + + @Configuration + static class TwoOrderedGuardsConfig { + @Bean + @Order(1) + RegistrationGuard firstGuard() { + return context -> RegistrationDecision.deny("first"); + } + + @Bean + @Order(2) + RegistrationGuard secondGuard() { + return context -> RegistrationDecision.deny("second"); + } + } + + @Configuration + static class TwoAllowingGuardsConfig { + @Bean + @Order(1) + RegistrationGuard firstAllow() { + return context -> RegistrationDecision.allow(); + } + + @Bean + @Order(2) + RegistrationGuard secondAllow() { + return context -> RegistrationDecision.allow(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java index e348705..90c9140 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java @@ -3,6 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -24,10 +28,14 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; - +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; + +/** + * Verifies that {@link DSOAuth2UserService} enforces the centralized {@link com.digitalsanctuary.spring.user.registration.RegistrationGuard} + * (via {@link UserService#enforceRegistrationGuard}) on first-time OAuth2 registration only, and translates a + * {@link RegistrationDeniedException} into the same {@code registration_denied} {@link OAuth2AuthenticationException}. + */ @ExtendWith(MockitoExtension.class) @DisplayName("DSOAuth2UserService RegistrationGuard Tests") class DSOAuth2UserServiceRegistrationGuardTest { @@ -42,7 +50,7 @@ class DSOAuth2UserServiceRegistrationGuardTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -70,8 +78,8 @@ void shouldRejectNewOAuth2UserWhenGuardDenies() { .build(); when(userRepository.findByEmail("new@gmail.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Domain not allowed")); + doThrow(new RegistrationDeniedException("Domain not allowed")) + .when(userService).enforceRegistrationGuard(eq("new@gmail.com"), eq(RegistrationSource.OAUTH2), anyString()); assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser)) .isInstanceOf(OAuth2AuthenticationException.class) @@ -92,8 +100,8 @@ void shouldAllowNewOAuth2UserWhenGuardAllows() { .build(); when(userRepository.findByEmail("allowed@gmail.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); + doNothing().when(userService) + .enforceRegistrationGuard(eq("allowed@gmail.com"), eq(RegistrationSource.OAUTH2), anyString()); when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); User result = service.handleOAuthLoginSuccess("google", googleUser); @@ -123,6 +131,6 @@ void shouldNotCallGuardForExistingOAuth2User() { User result = service.handleOAuthLoginSuccess("google", googleUser); assertThat(result).isNotNull(); - verifyNoInteractions(registrationGuard); + verifyNoInteractions(userService); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java index 0a2b205..df90163 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java @@ -35,8 +35,6 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; /** * Comprehensive unit tests for DSOAuth2UserService that verify actual business logic, @@ -57,7 +55,7 @@ class DSOAuth2UserServiceTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -73,7 +71,8 @@ void setUp() { userRole.setName("ROLE_USER"); userRole.setId(1L); lenient().when(roleRepository.findByName("ROLE_USER")).thenReturn(userRole); - lenient().when(registrationGuard.evaluate(any())).thenReturn(RegistrationDecision.allow()); + // userService.enforceRegistrationGuard is a void method: by default the mock does nothing, + // which represents an allow decision (no RegistrationDeniedException thrown). } @Nested diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java index 27a8f93..e09e334 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java @@ -3,6 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -24,10 +28,14 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; - +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; + +/** + * Verifies that {@link DSOidcUserService} enforces the centralized {@link com.digitalsanctuary.spring.user.registration.RegistrationGuard} + * (via {@link UserService#enforceRegistrationGuard}) on first-time OIDC registration only, and translates a + * {@link RegistrationDeniedException} into the same {@code registration_denied} {@link OAuth2AuthenticationException}. + */ @ExtendWith(MockitoExtension.class) @DisplayName("DSOidcUserService RegistrationGuard Tests") class DSOidcUserServiceRegistrationGuardTest { @@ -42,7 +50,7 @@ class DSOidcUserServiceRegistrationGuardTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -70,8 +78,8 @@ void shouldRejectNewOidcUserWhenGuardDenies() { .build(); when(userRepository.findByEmail("new@company.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Organization not whitelisted")); + doThrow(new RegistrationDeniedException("Organization not whitelisted")) + .when(userService).enforceRegistrationGuard(eq("new@company.com"), eq(RegistrationSource.OIDC), anyString()); assertThatThrownBy(() -> service.handleOidcLoginSuccess("keycloak", keycloakUser)) .isInstanceOf(OAuth2AuthenticationException.class) @@ -92,8 +100,8 @@ void shouldAllowNewOidcUserWhenGuardAllows() { .build(); when(userRepository.findByEmail("allowed@company.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); + doNothing().when(userService) + .enforceRegistrationGuard(eq("allowed@company.com"), eq(RegistrationSource.OIDC), anyString()); when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); @@ -123,6 +131,6 @@ void shouldNotCallGuardForExistingOidcUser() { User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); assertThat(result).isNotNull(); - verifyNoInteractions(registrationGuard); + verifyNoInteractions(userService); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java index 95dc7a7..7fb3d5d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java @@ -34,8 +34,6 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; /** * Comprehensive unit tests for DSOidcUserService that verify actual business logic @@ -55,7 +53,7 @@ class DSOidcUserServiceTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -71,7 +69,8 @@ void setUp() { userRole.setName("ROLE_USER"); userRole.setId(1L); lenient().when(roleRepository.findByName("ROLE_USER")).thenReturn(userRole); - lenient().when(registrationGuard.evaluate(any())).thenReturn(RegistrationDecision.allow()); + // userService.enforceRegistrationGuard is a void method: by default the mock does nothing, + // which represents an allow decision (no RegistrationDeniedException thrown). } @Nested diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java index 9494599..44762a3 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -134,7 +134,7 @@ class PasswordResetLookupTests { @BeforeEach void initService() { userService = new UserService(null, null, passwordTokenRepository, null, null, null, null, null, null, null, - null, null, null, tokenHasher); + null, null, null, tokenHasher, null); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java new file mode 100644 index 0000000..c1d22fc --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java @@ -0,0 +1,189 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; +import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.registration.RegistrationContext; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; + +/** + * Verifies that the {@link com.digitalsanctuary.spring.user.registration.RegistrationGuard} is enforced + * INSIDE {@link UserService} so that direct callers of the registration methods cannot bypass it (the + * guard-bypass this task closes), and that the correct {@link RegistrationSource} is supplied per path. + */ +@ServiceTest +class UserServiceRegistrationGuardTest { + + private static final String USER_ROLE_NAME = "ROLE_USER"; + + @Mock + private UserRepository userRepository; + @Mock + private VerificationTokenRepository tokenRepository; + @Mock + private PasswordResetTokenRepository passwordTokenRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private RoleRepository roleRepository; + @Mock + private SessionRegistry sessionRegistry; + @Mock + private UserEmailService userEmailService; + @Mock + private UserVerificationService userVerificationService; + @Mock + private DSUserDetailsService dsUserDetailsService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private AuthorityService authorityService; + @Mock + private PasswordHistoryRepository passwordHistoryRepository; + @Mock + private SessionInvalidationService sessionInvalidationService; + @Mock + private TokenHasher tokenHasher; + @Mock + private RegistrationGuard registrationGuard; + + @InjectMocks + private UserService userService; + + @BeforeEach + void setUp() { + // The public registration entry methods delegate the DB write to a @Transactional persist method + // invoked through the Spring proxy ("self"). Under @InjectMocks there is no proxy, so wire self + // back to the unit-under-test. + ReflectionTestUtils.setField(userService, "self", userService); + } + + private UserDto formDto() { + UserDto dto = new UserDto(); + dto.setEmail("blocked@example.com"); + dto.setFirstName("Blocked"); + dto.setLastName("User"); + dto.setPassword("password123"); + dto.setMatchingPassword("password123"); + return dto; + } + + private PasswordlessRegistrationDto passwordlessDto() { + PasswordlessRegistrationDto dto = new PasswordlessRegistrationDto(); + dto.setEmail("blocked@example.com"); + dto.setFirstName("Blocked"); + dto.setLastName("User"); + return dto; + } + + @Test + @DisplayName("Direct call to registerNewUserAccount is DENIED when guard denies (bypass closed, FORM source)") + void formRegistrationDeniedWhenGuardDenies() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.deny("Registration is by invitation only")); + + assertThatThrownBy(() -> userService.registerNewUserAccount(formDto())) + .isInstanceOf(RegistrationDeniedException.class) + .hasMessageContaining("Registration is by invitation only"); + + // No user was persisted — the bypass is closed. + verify(userRepository, never()).save(any(User.class)); + + // The guard received the correct source (FORM) and email. + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.FORM); + assertThat(captor.getValue().email()).isEqualTo("blocked@example.com"); + } + + @Test + @DisplayName("Direct call to registerPasswordlessAccount is DENIED when guard denies (bypass closed, PASSWORDLESS source)") + void passwordlessRegistrationDeniedWhenGuardDenies() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.deny("Beta access required")); + + assertThatThrownBy(() -> userService.registerPasswordlessAccount(passwordlessDto())) + .isInstanceOf(RegistrationDeniedException.class) + .hasMessageContaining("Beta access required"); + + verify(userRepository, never()).save(any(User.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.PASSWORDLESS); + } + + @Test + @DisplayName("enforceRegistrationGuard throws RegistrationDeniedException for denied OAuth registration") + void oauthEnforceThrowsWhenGuardDenies() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.deny("Domain not allowed")); + + assertThatThrownBy(() -> + userService.enforceRegistrationGuard("social@example.com", RegistrationSource.OAUTH2, "google")) + .isInstanceOf(RegistrationDeniedException.class) + .hasMessageContaining("Domain not allowed"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.OAUTH2); + assertThat(captor.getValue().providerName()).isEqualTo("google"); + } + + @Test + @DisplayName("enforceRegistrationGuard is a no-op when the guard allows") + void oauthEnforceAllows() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.allow()); + + userService.enforceRegistrationGuard("social@example.com", RegistrationSource.OIDC, "keycloak"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.OIDC); + } + + @Test + @DisplayName("Form registration proceeds to persistence when the guard allows") + void formRegistrationProceedsWhenGuardAllows() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.allow()); + when(passwordEncoder.encode(any())).thenReturn("encoded"); + when(roleRepository.findByName(USER_ROLE_NAME)) + .thenReturn(com.digitalsanctuary.spring.user.test.builders.RoleTestDataBuilder.aUserRole().build()); + when(userRepository.findByEmail(any())).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User saved = userService.registerNewUserAccount(formDto()); + + assertThat(saved).isNotNull(); + verify(userRepository).save(any(User.class)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index 99a19df..1bdeac8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -60,6 +60,8 @@ import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationGuard; import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; import com.digitalsanctuary.spring.user.test.builders.RoleTestDataBuilder; import com.digitalsanctuary.spring.user.test.builders.TokenTestDataBuilder; @@ -100,6 +102,8 @@ public class UserServiceTest { private SessionInvalidationService sessionInvalidationService; @Mock private TokenHasher tokenHasher; + @Mock + private RegistrationGuard registrationGuard; @InjectMocks private UserService userService; private User testUser; @@ -117,6 +121,12 @@ void setUp() { // @InjectMocks there is no proxy and "self" is null, so wire it back to the unit-under-test so // the real persist logic executes during these unit tests. ReflectionTestUtils.setField(userService, "self", userService); + + // The RegistrationGuard is now enforced inside the registration entry points. Default it to + // allow so existing registration tests are unaffected; guard-denial behavior is exercised by + // the dedicated UserServiceRegistrationGuardTest. + org.mockito.Mockito.lenient().when(registrationGuard.evaluate(any())) + .thenReturn(RegistrationDecision.allow()); } @Test From e16c14842ca2025d0c2077f9795f2cf18fa08dc0 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 22:40:45 -0600 Subject: [PATCH 22/55] fix(extensibility): correct BaseSessionProfile scoping (H7); publish registration/disable/webauthn-auth events --- PROFILE.md | 15 ++- .../spring/user/event/UserDisabledEvent.java | 74 +++++++++++ .../user/listener/RegistrationListener.java | 10 ++ .../profile/session/BaseSessionProfile.java | 27 +++- .../profile/session/SessionScopedProfile.java | 62 +++++++++ .../WebAuthnAuthenticationSuccessHandler.java | 58 ++++++++- .../user/security/WebSecurityConfig.java | 3 +- .../user/service/DSOAuth2UserService.java | 9 ++ .../user/service/DSOidcUserService.java | 8 ++ .../spring/user/service/UserService.java | 33 +++-- .../user/event/UserDisabledEventTest.java | 62 +++++++++ .../listener/RegistrationListenerTest.java | 40 +++++- .../session/SessionScopedProfileTest.java | 123 ++++++++++++++++++ ...AuthnAuthenticationSuccessHandlerTest.java | 56 +++++++- .../user/service/DSOAuth2UserServiceTest.java | 8 ++ .../user/service/DSOidcUserServiceTest.java | 9 ++ .../spring/user/service/UserServiceTest.java | 46 ++++++- 17 files changed, 619 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java diff --git a/PROFILE.md b/PROFILE.md index 1ffd376..379bc2d 100644 --- a/PROFILE.md +++ b/PROFILE.md @@ -165,13 +165,26 @@ public class AppUserProfileService implements UserProfileService ### Step 4: Create a Session Profile Manager -Create a session-scoped component to access the current user's profile: +Create a session-scoped component to access the current user's profile. + +> **⚠️ IMPORTANT — `@Scope` is NOT inherited.** Spring does not propagate the `@Scope` declared on +> `BaseSessionProfile` to your subclass. If you annotate your subclass with only `@Component` (and omit +> `@Scope`), it becomes a **singleton shared across all HTTP sessions**, leaking one user's profile to every +> other user — a serious security bug. Always declare session scoping on the subclass itself, either with the +> explicit `@Scope` shown below, or with the convenience meta-annotation `@SessionScopedProfile` +> (`com.digitalsanctuary.spring.user.profile.session.SessionScopedProfile`), which carries `@Component` and the +> correct `@Scope` in a single annotation. ```java +// Option A — explicit @Scope: @Component @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) public class AppSessionProfile extends BaseSessionProfile { +// Option B (equivalent) — the convenience meta-annotation: +// @SessionScopedProfile +// public class AppSessionProfile extends BaseSessionProfile { + // Add custom accessor methods for your application public String getDisplayName() { return getUserProfile() != null ? getUserProfile().getDisplayName() : null; diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java new file mode 100644 index 0000000..127c0c2 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java @@ -0,0 +1,74 @@ +package com.digitalsanctuary.spring.user.event; + +import org.springframework.context.ApplicationEvent; + +/** + * Event published after a user account has been disabled (soft-deleted). + * + *

    When {@code user.actuallyDeleteAccount} is {@code false} (the default), an account + * "deletion" request disables the user rather than removing the row. This event makes that + * default soft-delete path observable so consuming applications can react (e.g. revoke + * external access, notify downstream systems, or update analytics). + * + *

    Like {@link UserDeletedEvent}, this event is delivered after the disabling + * transaction has committed. Listeners (including {@code @Async} ones) are therefore + * guaranteed to observe a committed change and will never act on a not-yet-committed update. + * Delivery-after-commit is achieved by the publisher itself: the event is published from a + * registered {@code TransactionSynchronization.afterCommit} callback. Because publication is + * already deferred, consumers do not need {@code @TransactionalEventListener} + * — a plain {@code @EventListener} (or an {@code @Async @EventListener}) will already + * receive the event post-commit. When no transaction synchronization is active (e.g. a + * non-transactional caller), the event is published immediately as a fallback. + * + *

    Note: To mirror {@link UserDeletedEvent} and avoid handing listeners a live, detached, or + * mutated entity, only the user's ID and email are retained in this event. + * + * @see UserDeletedEvent + * @see UserPreDeleteEvent + */ +public class UserDisabledEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** + * The ID of the disabled user. + */ + private final Long userId; + + /** + * The email of the disabled user. + */ + private final String userEmail; + + /** + * Creates a new UserDisabledEvent. + * + * @param source the object on which the event initially occurred + * @param userId the ID of the disabled user + * @param userEmail the email of the disabled user + */ + public UserDisabledEvent(Object source, Long userId, String userEmail) { + super(source); + this.userId = userId; + this.userEmail = userEmail; + } + + /** + * Gets the ID of the disabled user. + * + * @return the user ID + */ + public Long getUserId() { + return userId; + } + + /** + * Gets the email of the disabled user. + * + * @return the user email + */ + public String getUserEmail() { + return userEmail; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java index 86a2f34..4123941 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java +++ b/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java @@ -42,6 +42,16 @@ public class RegistrationListener { @EventListener public void onApplicationEvent(final OnRegistrationCompleteEvent event) { log.debug("RegistrationListener.onApplicationEvent: called with event: {}", event.toString()); + // Skip sending a verification email to users who are already enabled (e.g. first-time OAuth2/OIDC + // registrations, where the provider has already verified the email and the account is created + // ENABLED). Form registrations that require verification are created DISABLED, so they still receive + // the email. This lets OAuth/OIDC services publish OnRegistrationCompleteEvent so consumers can + // observe social registrations uniformly, without sending those users a pointless verification email. + if (event.getUser() != null && event.getUser().isEnabled()) { + log.debug("RegistrationListener.onApplicationEvent: user {} is already enabled; skipping verification email", + event.getUser().getEmail()); + return; + } if (sendRegistrationVerificationEmail) { this.sendRegistrationVerificationEmail(event); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java b/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java index a819fee..f7f09cb 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java @@ -22,11 +22,35 @@ *

    * *

    - * Example usage: + * IMPORTANT: Spring's {@link Scope @Scope} annotation is NOT inherited by + * subclasses. The {@code @Scope} declared on this base class does not propagate to your concrete + * subclass. If your subclass is annotated only with {@code @Component} (and no {@code @Scope}), it becomes a + * singleton shared across every HTTP session — one user's profile data will leak to all + * other users, which is a serious security vulnerability. Every concrete subclass MUST declare + * session scoping on itself, either by repeating the {@code @Scope} annotation explicitly or by using the + * convenience meta-annotation {@link SessionScopedProfile @SessionScopedProfile}. + *

    + * + *

    + * Example usage — Option A, the convenience meta-annotation (recommended): + *

    + * + *
    {@code
    + * @SessionScopedProfile
    + * public class CustomSessionProfile extends BaseSessionProfile {
    + *     public boolean hasSpecificPermission() {
    + *         return getUserProfile().getPermissions().contains("SPECIFIC_PERMISSION");
    + *     }
    + * }
    + * }
    + * + *

    + * Example usage — Option B, an explicit {@code @Scope} on the subclass (equivalent to Option A): *

    * *
    {@code
      * @Component
    + * @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
      * public class CustomSessionProfile extends BaseSessionProfile {
      *     public boolean hasSpecificPermission() {
      *         return getUserProfile().getPermissions().contains("SPECIFIC_PERMISSION");
    @@ -37,6 +61,7 @@
      * @param  the type of user profile, must extend BaseUserProfile
      *
      * @see BaseUserProfile
    + * @see SessionScopedProfile
      * @see WebApplicationContext#SCOPE_SESSION
      */
     @Data
    diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java b/src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java
    new file mode 100644
    index 0000000..214f336
    --- /dev/null
    +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java
    @@ -0,0 +1,62 @@
    +package com.digitalsanctuary.spring.user.profile.session;
    +
    +import java.lang.annotation.Documented;
    +import java.lang.annotation.ElementType;
    +import java.lang.annotation.Retention;
    +import java.lang.annotation.RetentionPolicy;
    +import java.lang.annotation.Target;
    +import org.springframework.context.annotation.Scope;
    +import org.springframework.context.annotation.ScopedProxyMode;
    +import org.springframework.core.annotation.AliasFor;
    +import org.springframework.stereotype.Component;
    +import org.springframework.web.context.WebApplicationContext;
    +
    +/**
    + * Convenience meta-annotation that registers a Spring bean as a correctly session-scoped component.
    + *
    + * 

    + * Spring's {@link Scope @Scope} annotation is not inherited by subclasses. A concrete subclass of + * {@link BaseSessionProfile} that is annotated only with {@code @Component} (and no {@code @Scope}) becomes a + * singleton shared across every HTTP session — one user's profile data leaks to all other + * users. To avoid this trap, every concrete session profile must declare session scoping on itself. + *

    + * + *

    + * Annotating a subclass with {@code @SessionScopedProfile} is the single-annotation equivalent of writing both: + *

    + * + *
    {@code
    + * @Component
    + * @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
    + * }
    + * + *

    + * Example usage: + *

    + * + *
    {@code
    + * @SessionScopedProfile
    + * public class CustomSessionProfile extends BaseSessionProfile {
    + *     // ...
    + * }
    + * }
    + * + * @see BaseSessionProfile + * @see Scope + * @see WebApplicationContext#SCOPE_SESSION + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) +public @interface SessionScopedProfile { + + /** + * Alias for {@link Component#value()} to allow the bean name to be supplied directly on the meta-annotation. + * + * @return the suggested component name, if any + */ + @AliasFor(annotation = Component.class) + String value() default ""; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java index fc1642c..ca9c7a0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java @@ -1,6 +1,8 @@ package com.digitalsanctuary.spring.user.security; import java.io.IOException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -31,6 +33,22 @@ * The handler delegates to {@link HttpMessageConverterAuthenticationSuccessHandler} to write the JSON response expected by the WebAuthn JavaScript * client ({@code {"authenticated": true, "redirectUrl": "..."}}). *

    + * + *

    + * After converting the principal, this handler publishes an additional {@link InteractiveAuthenticationSuccessEvent} carrying the converted + * authentication. Spring Security's {@code AbstractAuthenticationProcessingFilter} (the superclass of {@code WebAuthnAuthenticationFilter}) already + * publishes an {@code InteractiveAuthenticationSuccessEvent} for passkey logins, but it carries the raw {@code WebAuthnAuthentication} whose principal + * is a {@code PublicKeyCredentialUserEntity}. The framework's {@code BaseAuthenticationListener} (which loads the session-scoped user profile) ignores + * that event because it requires a {@code DSUserDetails} principal. This handler therefore publishes a second event carrying the converted + * {@code DSUserDetails} so that {@code BaseAuthenticationListener} fires for passkey logins exactly as it does for form and OAuth2 logins. + *

    + * + *

    + * Consequence: two {@code InteractiveAuthenticationSuccessEvent}s are emitted per passkey login (the framework's, with the raw principal, and this + * handler's, with {@code DSUserDetails}). The framework's own listeners are principal-type-guarded and unaffected, but a principal-agnostic consumer + * {@code @EventListener(InteractiveAuthenticationSuccessEvent.class)} would observe both. Note this event does not reset brute-force counters; + * those are driven by {@code AuthenticationSuccessEvent} (a sibling event), not by {@code InteractiveAuthenticationSuccessEvent}. + *

    */ @Slf4j public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @@ -39,6 +57,7 @@ public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSucce private final AuthenticationSuccessHandler delegate; private final SecurityContextRepository securityContextRepository; private final SecurityContextHolderStrategy securityContextHolderStrategy; + private final ApplicationEventPublisher eventPublisher; /** * Creates a new handler with the given {@code UserDetailsService} and a default {@link HttpMessageConverterAuthenticationSuccessHandler} delegate. @@ -46,7 +65,18 @@ public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSucce * @param userDetailsService the service to load the full user details */ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService) { - this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler()); + this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler(), null); + } + + /** + * Creates a new handler with the given {@code UserDetailsService} and event publisher, using a default + * {@link HttpMessageConverterAuthenticationSuccessHandler} delegate. + * + * @param userDetailsService the service to load the full user details + * @param eventPublisher the publisher used to fire an {@link InteractiveAuthenticationSuccessEvent} on successful WebAuthn login (may be null) + */ + public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, ApplicationEventPublisher eventPublisher) { + this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler(), eventPublisher); } /** @@ -56,10 +86,23 @@ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsServic * @param delegate the handler to delegate to after principal conversion */ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, AuthenticationSuccessHandler delegate) { + this(userDetailsService, delegate, null); + } + + /** + * Creates a new handler with the given {@code UserDetailsService}, delegate handler, and event publisher. + * + * @param userDetailsService the service to load the full user details + * @param delegate the handler to delegate to after principal conversion + * @param eventPublisher the publisher used to fire an {@link InteractiveAuthenticationSuccessEvent} on successful WebAuthn login (may be null) + */ + public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, AuthenticationSuccessHandler delegate, + ApplicationEventPublisher eventPublisher) { this.userDetailsService = userDetailsService; this.delegate = delegate; this.securityContextRepository = new HttpSessionSecurityContextRepository(); this.securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); + this.eventPublisher = eventPublisher; } @Override @@ -84,6 +127,19 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo securityContextRepository.saveContext(context, request, response); log.info("WebAuthn authentication principal converted to DSUserDetails for user: {}", username); + + // AbstractAuthenticationProcessingFilter (WebAuthnAuthenticationFilter's superclass) already publishes an + // InteractiveAuthenticationSuccessEvent for this login, but it carries the raw WebAuthnAuthentication whose + // principal is a PublicKeyCredentialUserEntity, which BaseAuthenticationListener ignores (it requires a + // DSUserDetails principal). Publish an additional event here with the converted DSUserDetails-bearing + // authentication so BaseAuthenticationListener loads the session profile for passkey logins, just as it does + // for form and OAuth2 logins. This emits two InteractiveAuthenticationSuccessEvents per passkey login; the + // framework's listeners are principal-type-guarded, but principal-agnostic consumer listeners would see both. + // This event does not reset brute-force counters (those react to AuthenticationSuccessEvent, a sibling event). + if (eventPublisher != null) { + eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(convertedAuth, this.getClass())); + } + delegate.onAuthenticationSuccess(request, response, convertedAuth); } else { delegate.onAuthenticationSuccess(request, response, authentication); diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 8f30a25..10793db 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -123,6 +123,7 @@ public class WebSecurityConfig { private final WebAuthnConfigProperties webAuthnConfigProperties; private final MfaConfigProperties mfaConfigProperties; private final Environment environment; + private final ApplicationEventPublisher applicationEventPublisher; /** * Builds the library's security filter chain for Spring Security. @@ -292,7 +293,7 @@ private ObjectPostProcessor webAuthnSuccessHandler return new ObjectPostProcessor() { @Override public O postProcess(O filter) { - filter.setAuthenticationSuccessHandler(new WebAuthnAuthenticationSuccessHandler(userDetailsService)); + filter.setAuthenticationSuccessHandler(new WebAuthnAuthenticationSuccessHandler(userDetailsService, applicationEventPublisher)); return filter; } }; diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index d276104..0c9c905 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.service; import java.util.Arrays; +import java.util.Locale; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; @@ -137,6 +139,13 @@ private User registerNewOAuthUser(String registrationId, User user) { AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(savedUser).action("OAuth2 Registration Success").actionStatus("Success") .message("Registration Confirmed. User logged in.").build(); eventPublisher.publishEvent(registrationAuditEvent); + // Publish a registration event for this first-time social registration so consumers can hook it the + // same way they hook form registrations. This method is only reached for brand-new users (existing + // users take the update branch in handleOAuthLoginSuccess). OAuth2 users are created ENABLED, so the + // RegistrationListener intentionally skips sending them a verification email; the event still fires. + // No HttpServletRequest is available here, so locale defaults and appUrl is null (only the verification + // email, which is skipped for enabled users, would have used appUrl). + eventPublisher.publishEvent(new OnRegistrationCompleteEvent(savedUser, Locale.getDefault(), null)); return savedUser; } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java index 50d45f6..676c2e2 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; @@ -144,6 +145,13 @@ private User registerNewOidcUser(String registrationId, User user) { AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(savedUser).action("OIDC Registration Success").actionStatus("Success") .message("Registration Confirmed. User logged in.").build(); eventPublisher.publishEvent(registrationAuditEvent); + // Publish a registration event for this first-time social registration so consumers can hook it the + // same way they hook form registrations. This method is only reached for brand-new users (existing + // users take the update branch in handleOidcLoginSuccess). OIDC users are created ENABLED, so the + // RegistrationListener intentionally skips sending them a verification email; the event still fires. + // No HttpServletRequest is available here, so locale defaults and appUrl is null (only the verification + // email, which is skipped for enabled users, would have used appUrl). + eventPublisher.publishEvent(new OnRegistrationCompleteEvent(savedUser, Locale.getDefault(), null)); return savedUser; } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 2ee359a..6863e07 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; import org.springframework.dao.CannotAcquireLockException; @@ -35,6 +36,7 @@ import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.event.UserDisabledEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; @@ -522,41 +524,50 @@ public void deleteOrDisableUser(final User user) { // for this event, so rather than annotate a listener we defer publication itself via a // transaction synchronization. If no transaction is active (e.g. called outside a // transactional context), fall back to publishing immediately. - publishUserDeletedEventAfterCommit(userId, userEmail); + publishEventAfterCommit(new UserDeletedEvent(this, userId, userEmail)); } else { log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user.getEmail()); + // Capture user details before the save for the post-commit event, mirroring the delete path. + Long userId = user.getId(); + String userEmail = user.getEmail(); + user.setEnabled(false); userRepository.save(user); log.debug("UserService.deleteOrDisableUser: user {} has been disabled", user.getEmail()); + + // Publish UserDisabledEvent AFTER the surrounding transaction commits so listeners (often + // @Async, in consuming apps) never observe a not-yet-committed change. This makes the default + // soft-delete path observable, matching the hard-delete path's UserDeletedEvent. + publishEventAfterCommit(new UserDisabledEvent(this, userId, userEmail)); } } /** - * Publishes a {@link UserDeletedEvent} after the current transaction commits. + * Publishes the given application event after the current transaction commits. * *

    * If a transaction is active, the event is published from * {@link TransactionSynchronization#afterCommit()} so listeners (especially {@code @Async} - * ones) never act on a deletion that has not yet been committed. If no transaction is active, + * ones) never act on a change that has not yet been committed. If no transaction is active, * the event is published immediately so the behavior is still correct in non-transactional - * callers. + * callers. Used for both {@link UserDeletedEvent} (hard delete) and {@link UserDisabledEvent} + * (soft delete). *

    * - * @param userId the id of the deleted user - * @param userEmail the email of the deleted user + * @param event the event to publish after commit */ - private void publishUserDeletedEventAfterCommit(final Long userId, final String userEmail) { + private void publishEventAfterCommit(final ApplicationEvent event) { if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - log.debug("Publishing UserDeletedEvent after commit"); - eventPublisher.publishEvent(new UserDeletedEvent(UserService.this, userId, userEmail)); + log.debug("Publishing {} after commit", event.getClass().getSimpleName()); + eventPublisher.publishEvent(event); } }); } else { - log.debug("Publishing UserDeletedEvent (no active transaction)"); - eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail)); + log.debug("Publishing {} (no active transaction)", event.getClass().getSimpleName()); + eventPublisher.publishEvent(event); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java new file mode 100644 index 0000000..de3cc04 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java @@ -0,0 +1,62 @@ +package com.digitalsanctuary.spring.user.event; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("UserDisabledEvent Tests") +class UserDisabledEventTest { + + private Object eventSource; + + @BeforeEach + void setUp() { + eventSource = this; + } + + @Test + @DisplayName("Event creation stores user ID and email") + void eventCreation_storesUserIdAndEmail() { + // When + UserDisabledEvent event = new UserDisabledEvent(eventSource, 1L, "test@example.com"); + + // Then + assertThat(event.getUserId()).isEqualTo(1L); + assertThat(event.getUserEmail()).isEqualTo("test@example.com"); + assertThat(event.getSource()).isEqualTo(eventSource); + } + + @Test + @DisplayName("Event with different sources") + void event_withDifferentSources() { + // Given + Object source1 = new Object(); + Object source2 = "Different Source"; + + // When + UserDisabledEvent event1 = new UserDisabledEvent(source1, 1L, "user1@example.com"); + UserDisabledEvent event2 = new UserDisabledEvent(source2, 2L, "user2@example.com"); + + // Then + assertThat(event1.getSource()).isEqualTo(source1); + assertThat(event2.getSource()).isEqualTo(source2); + assertThat(event1.getUserId()).isNotEqualTo(event2.getUserId()); + } + + @Test + @DisplayName("Event timestamp is set on creation") + void event_timestampIsSet() { + // Given + long beforeCreation = System.currentTimeMillis(); + + // When + UserDisabledEvent event = new UserDisabledEvent(eventSource, 1L, "test@example.com"); + + // Then + long afterCreation = System.currentTimeMillis(); + assertThat(event.getTimestamp()).isGreaterThanOrEqualTo(beforeCreation); + assertThat(event.getTimestamp()).isLessThanOrEqualTo(afterCreation); + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java index 9bedfbb..dbef822 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java @@ -35,12 +35,14 @@ class RegistrationListenerTest { @BeforeEach void setUp() { + // Default the shared fixture to a DISABLED (not-yet-verified) user so the "send verification email" + // tests exercise the email-sending path. The skip-for-enabled-users behavior is covered separately. testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") .withFirstName("Test") .withLastName("User") - .enabled() + .disabled() .build(); appUrl = "https://example.com"; locale = Locale.ENGLISH; @@ -51,12 +53,17 @@ void setUp() { class RegistrationEventHandlingTests { @Test - @DisplayName("onApplicationEvent - sends verification email when enabled") + @DisplayName("onApplicationEvent - sends verification email when enabled and user is not yet verified") void onApplicationEvent_sendsVerificationEmailWhenEnabled() { - // Given + // Given - a DISABLED (not yet verified) user, as produced by the form-registration path when + // email verification is required. ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true); + User unverifiedUser = UserTestDataBuilder.aUser() + .withEmail("unverified@example.com") + .disabled() + .build(); OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder() - .user(testUser) + .user(unverifiedUser) .locale(locale) .appUrl(appUrl) .build(); @@ -65,7 +72,30 @@ void onApplicationEvent_sendsVerificationEmailWhenEnabled() { registrationListener.onApplicationEvent(event); // Then - verify(userEmailService).sendRegistrationVerificationEmail(testUser, appUrl); + verify(userEmailService).sendRegistrationVerificationEmail(unverifiedUser, appUrl); + } + + @Test + @DisplayName("onApplicationEvent - skips verification email for already-enabled (OAuth/OIDC) user") + void onApplicationEvent_skipsVerificationEmailForEnabledUser() { + // Given - sending is enabled, but the user is already enabled (e.g. a first-time OAuth2/OIDC + // registration where the provider has already verified the email). They must NOT receive an email. + ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true); + User enabledUser = UserTestDataBuilder.aUser() + .withEmail("oauth@example.com") + .enabled() + .build(); + OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder() + .user(enabledUser) + .locale(locale) + .appUrl(appUrl) + .build(); + + // When + registrationListener.onApplicationEvent(event); + + // Then + verify(userEmailService, never()).sendRegistrationVerificationEmail(any(), any()); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java b/src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java new file mode 100644 index 0000000..803854d --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java @@ -0,0 +1,123 @@ +package com.digitalsanctuary.spring.user.profile.session; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.context.support.SimpleThreadScope; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.context.WebApplicationContext; +import com.digitalsanctuary.spring.user.profile.BaseUserProfile; + +/** + * Tests for the H7 fix: a concrete {@link BaseSessionProfile} subclass must be session-scoped so that one user's + * profile does not leak across sessions. + * + *

    + * Two complementary proofs are provided: + *

    + *
      + *
    • A reflection assertion that the convenience meta-annotation {@link SessionScopedProfile} carries + * {@code @Component} and a session-scoped {@code @Scope} with a {@code TARGET_CLASS} proxy.
    • + *
    • A runtime proof, using a {@code session} scope backed by {@link SimpleThreadScope} (each thread acts as a + * distinct "session"), that correctly scoped subclasses resolve to DISTINCT instances per session, while an + * unscoped subclass falls into the singleton trap and SHARES a single instance across sessions.
    • + *
    + * + *

    + * To avoid comparing the shared scoped proxy (which is the same object regardless of session), the test beans are + * registered with {@link ScopedProxyMode#NO} and resolved on each session thread directly — with + * {@link SimpleThreadScope} this returns the real per-session target instance, so identity comparison is meaningful. + * The meta-annotation reflection test independently confirms the production scope/proxy configuration. + *

    + */ +@DisplayName("Session-scoped profile (H7) Tests") +class SessionScopedProfileTest { + + /** Minimal concrete profile type for the session profile to carry. */ + static class TestUserProfile extends BaseUserProfile { + } + + /** Correctly scoped via the convenience meta-annotation (used by the reflection test). */ + @SessionScopedProfile + static class MetaAnnotatedSessionProfile extends BaseSessionProfile { + } + + /** Correctly session-scoped, no proxy (so per-thread resolution returns the real target for identity checks). */ + @Component + @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.NO) + static class ScopedNoProxySessionProfile extends BaseSessionProfile { + } + + /** INCORRECTLY scoped: only {@code @Component}, no {@code @Scope}. Demonstrates the singleton trap. */ + @Component + static class UnscopedSessionProfile extends BaseSessionProfile { + } + + /** + * Builds a context with a {@code session} scope backed by {@link SimpleThreadScope}, so each thread acts as a + * distinct "session". + */ + private AnnotationConfigApplicationContext sessionScopedContext(Class... beanClasses) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getBeanFactory().registerScope(WebApplicationContext.SCOPE_SESSION, new SimpleThreadScope()); + context.register(beanClasses); + context.refresh(); + return context; + } + + /** Resolves the bean on a fresh thread so that, with {@link SimpleThreadScope}, it represents a new session. */ + private T resolveOnNewSession(AnnotationConfigApplicationContext context, Class type) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Callable task = () -> context.getBean(type); + return executor.submit(task).get(); + } finally { + executor.shutdownNow(); + } + } + + @Test + @DisplayName("meta-annotation carries @Component and session @Scope with TARGET_CLASS proxy") + void metaAnnotationCarriesCorrectScope() { + // @Component is present (so it is a bean) + assertThat(AnnotatedElementUtils.hasAnnotation(MetaAnnotatedSessionProfile.class, Component.class)).isTrue(); + + // @Scope is present and configured for the session with a TARGET_CLASS proxy + Scope scope = AnnotatedElementUtils.findMergedAnnotation(MetaAnnotatedSessionProfile.class, Scope.class); + assertThat(scope).isNotNull(); + assertThat(scope.value()).isEqualTo(WebApplicationContext.SCOPE_SESSION); + assertThat(scope.proxyMode()).isEqualTo(ScopedProxyMode.TARGET_CLASS); + } + + @Test + @DisplayName("correctly session-scoped subclass yields DISTINCT instances per session") + void scopedProfileIsDistinctPerSession() throws Exception { + try (AnnotationConfigApplicationContext context = sessionScopedContext(ScopedNoProxySessionProfile.class)) { + ScopedNoProxySessionProfile sessionA = resolveOnNewSession(context, ScopedNoProxySessionProfile.class); + ScopedNoProxySessionProfile sessionB = resolveOnNewSession(context, ScopedNoProxySessionProfile.class); + + assertThat(sessionA).isNotNull(); + assertThat(sessionB).isNotNull(); + assertThat(sessionA).isNotSameAs(sessionB); + } + } + + @Test + @DisplayName("the singleton trap: an unscoped subclass SHARES one instance across sessions") + void unscopedProfileIsSharedSingletonTrap() throws Exception { + try (AnnotationConfigApplicationContext context = sessionScopedContext(UnscopedSessionProfile.class)) { + UnscopedSessionProfile sessionA = resolveOnNewSession(context, UnscopedSessionProfile.class); + UnscopedSessionProfile sessionB = resolveOnNewSession(context, UnscopedSessionProfile.class); + + // Same instance shared across sessions: this is the H7 vulnerability the fix warns against. + assertThat(sessionA).isSameAs(sessionB); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java index c36bb6d..eda22c8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java @@ -12,9 +12,11 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -40,6 +42,9 @@ class WebAuthnAuthenticationSuccessHandlerTest { @Mock private AuthenticationSuccessHandler delegate; + @Mock + private ApplicationEventPublisher eventPublisher; + private WebAuthnAuthenticationSuccessHandler handler; private MockHttpServletRequest request; @@ -49,7 +54,7 @@ class WebAuthnAuthenticationSuccessHandlerTest { @BeforeEach void setUp() { testUser = TestFixtures.Users.standardUser(); - handler = new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate); + handler = new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate, eventPublisher); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); SecurityContextHolder.clearContext(); @@ -108,6 +113,55 @@ void shouldUpdateSecurityContext() throws Exception { assertThat(((DSUserDetails) contextAuth.getPrincipal()).getUser().getEmail()).isEqualTo(testUser.getEmail()); } + @Test + @DisplayName("should publish InteractiveAuthenticationSuccessEvent for the converted authentication") + void shouldPublishInteractiveAuthenticationSuccessEvent() throws Exception { + // Given + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When + handler.onAuthenticationSuccess(request, response, webAuthnAuth); + + // Then - an InteractiveAuthenticationSuccessEvent is published so BaseAuthenticationListener fires + // for passkey logins, matching form/OAuth2 logins. + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(InteractiveAuthenticationSuccessEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + InteractiveAuthenticationSuccessEvent event = eventCaptor.getValue(); + assertThat(event.getAuthentication()).isInstanceOf(WebAuthnAuthenticationToken.class); + assertThat(event.getAuthentication().getPrincipal()).isInstanceOf(DSUserDetails.class); + assertThat(((DSUserDetails) event.getAuthentication().getPrincipal()).getUser().getEmail()) + .isEqualTo(testUser.getEmail()); + } + + @Test + @DisplayName("should not fail when no event publisher is configured") + void shouldNotFailWithoutEventPublisher() throws Exception { + // Given - handler constructed without an event publisher (null) + WebAuthnAuthenticationSuccessHandler handlerNoPublisher = + new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate); + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When / Then - no exception, delegate still invoked + handlerNoPublisher.onAuthenticationSuccess(request, response, webAuthnAuth); + verify(delegate).onAuthenticationSuccess(org.mockito.ArgumentMatchers.eq(request), + org.mockito.ArgumentMatchers.eq(response), org.mockito.ArgumentMatchers.any(Authentication.class)); + } + @Test @DisplayName("should preserve authorities from WebAuthn authentication") void shouldPreserveAuthorities() throws Exception { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java index df90163..b1598f0 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java @@ -30,6 +30,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.fixtures.OAuth2UserTestDataBuilder; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; @@ -115,6 +116,13 @@ void shouldCreateNewUserFromGoogleOAuth2() { assertThat(auditEvent.getAction()).isEqualTo("OAuth2 Registration Success"); assertThat(auditEvent.getActionStatus()).isEqualTo("Success"); assertThat(auditEvent.getUser().getEmail()).isEqualTo("john.doe@gmail.com"); + + // Verify a registration event was published for the first-time social registration so consumers + // can observe OAuth2 registrations the same way they observe form registrations. + ArgumentCaptor regCaptor = ArgumentCaptor.forClass(OnRegistrationCompleteEvent.class); + verify(eventPublisher).publishEvent(regCaptor.capture()); + assertThat(regCaptor.getValue().getUser().getEmail()).isEqualTo("john.doe@gmail.com"); + assertThat(regCaptor.getValue().getUser().isEnabled()).isTrue(); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java index 7fb3d5d..1733fa7 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java @@ -29,6 +29,7 @@ import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.fixtures.OidcUserTestDataBuilder; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; @@ -109,6 +110,14 @@ void shouldCreateNewUserFromKeycloakOidc() { verify(userRepository).save(any(User.class)); verify(eventPublisher).publishEvent(any(AuditEvent.class)); + + // Verify a registration event was published for the first-time social registration so consumers + // can observe OIDC registrations the same way they observe form registrations. + org.mockito.ArgumentCaptor regCaptor = + org.mockito.ArgumentCaptor.forClass(OnRegistrationCompleteEvent.class); + verify(eventPublisher).publishEvent(regCaptor.capture()); + assertThat(regCaptor.getValue().getUser().getEmail()).isEqualTo("john.doe@company.com"); + assertThat(regCaptor.getValue().getUser().isEnabled()).isTrue(); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index 1bdeac8..ddca985 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -48,6 +48,7 @@ import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.event.UserDisabledEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; @@ -323,9 +324,9 @@ void deleteOrDisableUser_whenActuallyDeleteTrue_deletesUserAndTokens() { } @Test - @DisplayName("deleteOrDisableUser - when actuallyDeleteAccount is false - disables user") + @DisplayName("deleteOrDisableUser - when actuallyDeleteAccount is false - disables user and publishes UserDisabledEvent") void deleteOrDisableUser_whenActuallyDeleteFalse_disablesUser() { - // Given + // Given: no active transaction, so the disable event is published immediately (fallback path) ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", false); when(userRepository.save(any(User.class))).thenReturn(testUser); @@ -336,7 +337,46 @@ void deleteOrDisableUser_whenActuallyDeleteFalse_disablesUser() { assertThat(testUser.isEnabled()).isFalse(); verify(userRepository).save(testUser); verify(userRepository, never()).delete(any()); - verify(eventPublisher, never()).publishEvent(any()); + + // The soft-delete path is now observable via UserDisabledEvent. + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDisabledEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(testUser.getId()); + assertThat(captor.getValue().getUserEmail()).isEqualTo(testUser.getEmail()); + // No delete-path events should be published on the disable branch. + verify(eventPublisher, never()).publishEvent(any(UserPreDeleteEvent.class)); + verify(eventPublisher, never()).publishEvent(any(UserDeletedEvent.class)); + } + + @Test + @DisplayName("deleteOrDisableUser - UserDisabledEvent is deferred until after transaction commit") + void deleteOrDisableUser_publishesUserDisabledEventAfterCommit() { + // Given: an active transaction synchronization (simulating the surrounding @Transactional) + ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", false); + when(userRepository.save(any(User.class))).thenReturn(testUser); + TransactionSynchronizationManager.initSynchronization(); + try { + // When + userService.deleteOrDisableUser(testUser); + + // Then: the disable event must NOT yet be published + verify(eventPublisher, never()).publishEvent(any(UserDisabledEvent.class)); + + // A synchronization was registered for after-commit delivery + List syncs = TransactionSynchronizationManager.getSynchronizations(); + assertThat(syncs).hasSize(1); + + // When the transaction commits, the disable event is delivered + syncs.forEach(TransactionSynchronization::afterCommit); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDisabledEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(testUser.getId()); + assertThat(captor.getValue().getUserEmail()).isEqualTo(testUser.getEmail()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } } @Test From e9ca0ca13f17b5b0ea7b8ecb02796d9a0b9c54e2 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 22:58:19 -0600 Subject: [PATCH 23/55] build: drop unused Guava + dead test deps; add ArchUnit rules --- CLAUDE.md | 2 +- build.gradle | 9 +-- .../user/architecture/ArchitectureTest.java | 58 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 572c861..a61fc5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,7 +164,7 @@ All configuration uses `user.*` prefix in application.yml. Key property groups: ## Testing -Tests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: Testcontainers, WireMock, GreenMail, AssertJ, REST Assured. +Tests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: AssertJ, spring-security-test, Testcontainers (MariaDB/PostgreSQL), Awaitility, ArchUnit, and H2. ### Custom Test Annotations (use these instead of raw Spring annotations) diff --git a/build.gradle b/build.gradle index c9b5680..b3e18c2 100644 --- a/build.gradle +++ b/build.gradle @@ -46,7 +46,6 @@ dependencies { // Other dependencies (moved to test scope for library) implementation 'org.passay:passay:2.0.0' - implementation 'com.google.guava:guava:33.6.0-jre' implementation 'org.apache.commons:commons-text:1.15.0' compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1' compileOnly 'org.springframework.retry:spring-retry:2.0.12' @@ -93,12 +92,14 @@ dependencies { testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.5' testImplementation 'org.testcontainers:testcontainers-mariadb:2.0.5' testImplementation 'org.testcontainers:testcontainers-postgresql:2.0.5' - testImplementation 'com.github.tomakehurst:wiremock:3.0.1' testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' testImplementation 'org.assertj:assertj-core:3.27.7' - testImplementation 'io.rest-assured:rest-assured:6.0.0' - testImplementation 'com.icegreen:greenmail:2.1.8' + // Awaitility is used by the Testcontainers concurrent-registration test testImplementation 'org.awaitility:awaitility:4.3.0' + // Legacy Jackson 2 (com.fasterxml.jackson) for test JSON utilities. Spring Boot 4 ships Jackson 3 + // (tools.jackson), so the com.fasterxml.jackson APIs these tests use must be declared explicitly + // rather than relied upon transitively. Version is managed by the Spring Boot BOM. + testImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' } tasks.named('bootJar') { diff --git a/src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java b/src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java new file mode 100644 index 0000000..be0fe47 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java @@ -0,0 +1,58 @@ +package com.digitalsanctuary.spring.user.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.GeneralCodingRules; + +/** + * Architectural invariants enforced via ArchUnit. These rules document and protect the layering and conventions of the + * library. Every rule here reflects a currently-true property of the codebase; do not add aspirational rules that fail. + * + *

    + * Analysis is scoped to the production package only ({@link ImportOption.DoNotIncludeTests}), so test fixtures and + * scaffolding never affect the results. + */ +@AnalyzeClasses(packages = "com.digitalsanctuary.spring.user", importOptions = ImportOption.DoNotIncludeTests.class) +public class ArchitectureTest { + + /** + * Persistence (JPA entities and repositories) is the lowest layer and must not reach upward into web, API, + * controller, or service code. + */ + @ArchTest + static final ArchRule persistenceDoesNotDependOnUpperLayers = noClasses().that() + .resideInAPackage("com.digitalsanctuary.spring.user.persistence..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.digitalsanctuary.spring.user.api..", "com.digitalsanctuary.spring.user.controller..", + "com.digitalsanctuary.spring.user.service..") + .because("persistence is the lowest layer and must not depend on web/API/service layers"); + + /** + * The service layer holds business logic and must not depend on the web-facing API or MVC controller layers. + * Dependencies flow inward: api/controller may use service, never the reverse. + */ + @ArchTest + static final ArchRule serviceDoesNotDependOnWebLayers = noClasses().that() + .resideInAPackage("com.digitalsanctuary.spring.user.service..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.digitalsanctuary.spring.user.api..", "com.digitalsanctuary.spring.user.controller..") + .because("services contain business logic and must not depend on web-facing layers"); + + /** + * The library uses SLF4J (typically via Lombok's {@code @Slf4j}) for all logging and must not print to the + * standard streams. + */ + @ArchTest + static final ArchRule noAccessToStandardStreams = GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS; + + /** + * The library standardizes on SLF4J and must not use {@code java.util.logging} directly. + */ + @ArchTest + static final ArchRule noJavaUtilLogging = GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING; +} From 3e680081ba82bc94a0a8dfd52ad5eaa095c67e02 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 23:04:10 -0600 Subject: [PATCH 24/55] fix(config): remove test-only CSRF exemption and test@test.com from shipped defaults --- .../spring/user/mail/MailService.java | 3 + .../config/dsspringuserconfig.properties | 6 +- .../spring/user/mail/MailServiceTest.java | 81 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java index f2b6fe3..6da12ff 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java @@ -61,6 +61,9 @@ void init() { resolvedSender = mailSenderProvider.getIfAvailable(); if (resolvedSender == null) { log.warn("JavaMailSender is not configured — email sending is disabled. Set 'spring.mail.host' to enable."); + } else if (fromAddress == null || fromAddress.isBlank()) { + log.warn("JavaMailSender is configured but 'user.mail.fromAddress' is not set — outbound emails will have no valid sender address. " + + "Set 'user.mail.fromAddress' to a valid from address."); } } diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index b626928..7cd4bd4 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -88,7 +88,8 @@ user.security.unprotectedURIs=/,/index.html,/favicon.ico,/css/*,/js/*,/img/*,/us # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. user.security.protectedURIs=/protected.html # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. -user.security.disableCSRFURIs=/no-csrf-test +# Empty by default — no URIs are CSRF-exempt unless the consuming application explicitly lists them here. +user.security.disableCSRFURIs= # The URI for the login page. user.security.loginPageURI=/user/login.html @@ -172,7 +173,8 @@ user.mfa.passwordEntryPointUri=/user/login.html user.mfa.webauthnEntryPointUri=/user/webauthn/login.html # The from address for all emails sent by the application. -user.mail.fromAddress=test@test.com +# Empty by default — the consuming application must set this to a valid address when mail is enabled, otherwise outbound emails will have no valid sender. +user.mail.fromAddress= # The cron expression for the token purge job. This defaults to 3 am every day. user.purgetokens.cron.expression=0 0 3 * * ? # The first year of the copyright. This is used for dispaly of the page footer. diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java index b2dcb7f..86f257a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java @@ -7,16 +7,22 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; @@ -595,4 +601,79 @@ void shouldConfigureMimeMessageHelperForTemplateMessage() throws Exception { preparator.prepare(mimeMessage); } } + + @Nested + @DisplayName("From Address Warning Tests") + class FromAddressWarningTests { + + private static final String EXPECTED_WARNING_FRAGMENT = "user.mail.fromAddress"; + + private Logger mailServiceLogger; + private ListAppender listAppender; + + @BeforeEach + void attachAppender() { + mailServiceLogger = (Logger) LoggerFactory.getLogger(MailService.class); + listAppender = new ListAppender<>(); + listAppender.start(); + mailServiceLogger.addAppender(listAppender); + } + + @AfterEach + void detachAppender() { + mailServiceLogger.detachAppender(listAppender); + } + + @Test + @DisplayName("Should warn when JavaMailSender is present but fromAddress is blank") + void shouldWarnWhenSenderPresentAndFromAddressBlank() { + // Given: sender available (mail enabled) but a blank fromAddress + when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender); + MailService service = new MailService(mailSenderProvider, mailContentBuilder); + ReflectionTestUtils.setField(service, "fromAddress", " "); + + // When + service.init(); + + // Then + assertThat(warningMessages()).anyMatch(msg -> msg.contains(EXPECTED_WARNING_FRAGMENT)); + } + + @Test + @DisplayName("Should NOT warn about fromAddress when sender present and fromAddress is valid") + void shouldNotWarnWhenSenderPresentAndFromAddressValid() { + // Given: sender available and a valid fromAddress + when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender); + MailService service = new MailService(mailSenderProvider, mailContentBuilder); + ReflectionTestUtils.setField(service, "fromAddress", "noreply@example.com"); + + // When + service.init(); + + // Then + assertThat(warningMessages()).noneMatch(msg -> msg.contains(EXPECTED_WARNING_FRAGMENT)); + } + + @Test + @DisplayName("Should NOT warn about fromAddress when sender is absent") + void shouldNotWarnAboutFromAddressWhenSenderAbsent() { + // Given: no sender (mail disabled) and a blank fromAddress + when(mailSenderProvider.getIfAvailable()).thenReturn(null); + MailService service = new MailService(mailSenderProvider, mailContentBuilder); + ReflectionTestUtils.setField(service, "fromAddress", ""); + + // When + service.init(); + + // Then: only the no-sender warning is expected, not the fromAddress warning + assertThat(warningMessages()).noneMatch(msg -> msg.contains(EXPECTED_WARNING_FRAGMENT)); + } + + private java.util.List warningMessages() { + return listAppender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + } } \ No newline at end of file From 2880f45103e3d83bf593c4bec24bde80438a4da4 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 12 Jun 2026 23:24:23 -0600 Subject: [PATCH 25/55] test: re-enable UserApiTest, cover savePassword reset flow and expired-token path --- .../spring/user/api/UserApiTest.java | 495 ++++++++++++++---- .../spring/user/jdbc/ConnectionManager.java | 37 -- .../spring/user/jdbc/Jdbc.java | 86 --- .../RegistrationGuardConfigurationTest.java | 11 + .../service/UserVerificationServiceTest.java | 23 +- 5 files changed, 413 insertions(+), 239 deletions(-) delete mode 100644 src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java index dace41e..f269140 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java @@ -1,155 +1,432 @@ package com.digitalsanctuary.spring.user.api; -import static com.digitalsanctuary.spring.user.api.helper.ApiTestHelper.buildUrlEncodedFormEntity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Order; +import java.util.Calendar; +import java.util.Date; +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.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ArgumentsSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import com.digitalsanctuary.spring.user.api.data.ApiTestData; -import com.digitalsanctuary.spring.user.api.data.DataStatus; -import com.digitalsanctuary.spring.user.api.data.Response; -import com.digitalsanctuary.spring.user.api.helper.AssertionsHelper; -import com.digitalsanctuary.spring.user.api.provider.ApiTestRegistrationArgumentsProvider; -import com.digitalsanctuary.spring.user.api.provider.holder.ApiTestArgumentsHolder; -import com.digitalsanctuary.spring.user.dto.PasswordDto; +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.dto.UserDto; -import com.digitalsanctuary.spring.user.jdbc.Jdbc; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; 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.DSUserDetails; +import com.digitalsanctuary.spring.user.service.TokenHasher; +import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; -import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest; -import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; -import com.digitalsanctuary.spring.user.api.provider.ApiTestUpdatePasswordArgumentsProvider; +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 org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; -import org.springframework.beans.factory.annotation.Autowired; -import org.junit.jupiter.api.Disabled; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; -import java.util.HashMap; -import java.util.Map; -import java.util.Collections; +/** + * Integration tests for {@link UserAPI}. + * + *

    Why this test was previously disabled

    + * + *

    + * The original version relied on a custom {@code jdbc.Jdbc} helper whose {@code ConnectionManager} + * opens a hardcoded MariaDB connection ({@code jdbc:mariadb://127.0.0.1:3306/springuser}). + * That fought the test infrastructure, which runs against an in-memory H2 database: the Jdbc-based + * {@code @AfterAll} cleanup could not connect to (or see) the test slice's H2 data, so the class was + * disabled. It also POSTed {@code application/x-www-form-urlencoded} bodies, but the API now consumes + * JSON ({@code @RequestBody}). + *

    + * + *

    How it was ported to the standard infrastructure

    + * + *

    + * This test boots the same full context the {@code @IntegrationTest} composite annotation provides + * (the five standard test configurations against H2 + {@link MockMvc}), but intentionally + * does NOT use {@code @Transactional}. The password-management service methods + * ({@link UserService#registerNewUserAccount} and {@link UserService#changeUserPassword}) are + * declared {@code @Transactional(propagation = NOT_SUPPORTED)} and commit their work in short, + * independent transactions. Under an ambient test transaction those independent commits interleave + * with the test's suspended persistence context and the password change is masked — a test-only + * artifact, not a production bug (verified: the same flow persists correctly with no ambient + * transaction). Running without {@code @Transactional} mirrors production exactly. + *

    + * + *

    + * Because nothing rolls back automatically, the test data is cleaned up explicitly via + * repository-based deletes (replacing the old {@code Jdbc} helper). Each test uses a unique email and + * cleanup runs both before and after every test for isolation. + *

    + * + *

    Why this class uses an isolated in-memory database

    + * + *

    + * The standard {@code test} profile points every test at the shared in-memory database + * {@code jdbc:h2:mem:testdb}. Because this class is intentionally non-{@code @Transactional} (see + * above), its registration / password rows are committed into that shared database. + * JUnit runs test classes in parallel, and other integration tests (e.g. + * {@code WebAuthnFeatureEnabledIntegrationTest}) call {@code userRepository.deleteAll()}; that delete + * races with this class's committed-but-not-yet-cleaned rows, producing intermittent FK / + * optimistic-lock failures. To remove the race entirely, {@link TestPropertySource} below overrides + * {@code spring.datasource.url} to a dedicated in-memory database + * ({@code jdbc:h2:mem:userapitest}). The URL options are copied verbatim from the shared + * {@code application-test.properties} URL ({@code DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE}); only the + * database name differs. Distinct datasource properties also give this class its own Spring context, + * so its committed rows live in a database no other test's {@code deleteAll()} can see. The schema is + * created automatically because the {@code test} profile sets {@code ddl-auto=create-drop}, which + * applies to whatever datasource URL the context boots. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + // Isolated in-memory DB so this non-@Transactional class's COMMITTED rows are invisible to the + // shared-DB integration tests' deleteAll(). Options copied verbatim from the shared testdb URL + // (application-test.properties) — only the database name (userapitest) differs. + "spring.datasource.url=jdbc:h2:mem:userapitest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" +}) +@Import({ + BaseTestConfiguration.class, + DatabaseTestConfiguration.class, + SecurityTestConfiguration.class, + OAuth2TestConfiguration.class, + MockMailConfiguration.class +}) +@DisplayName("UserAPI Integration Tests") +class UserApiTest { -@Disabled("Temporarily disabled - requires specific database setup that conflicts with current test infrastructure") -@IntegrationTest -public class UserApiTest { private static final String URL = "/user"; + /** + * Passwords that satisfy the default password policy (upper, lower, digit, special, min length + * 8) so registration and password changes succeed. + */ + private static final String VALID_PASSWORD = "ValidPass1!"; + private static final String NEW_VALID_PASSWORD = "NewValidPass2!"; + + @Autowired + private MockMvc mockMvc; + @Autowired private UserService userService; @Autowired - private MockMvc mockMvc; + private UserEmailService userEmailService; - private static final UserDto baseTestUser = ApiTestData.BASE_TEST_USER; + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; - @AfterAll - public static void afterAll() { - Jdbc.deleteTestUser(baseTestUser); + @Autowired + private VerificationTokenRepository verificationTokenRepository; + + @Autowired + private TokenHasher tokenHasher; + + @Autowired + private PlatformTransactionManager transactionManager; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + private TransactionTemplate txTemplate; + private String testEmail; + private UserDto baseTestUser; + + @BeforeEach + void setUp() { + txTemplate = new TransactionTemplate(transactionManager); + // Unique email per test method so a committed registration in one test never collides with + // another. The nanoTime suffix is sufficient for sequential per-method isolation. + testEmail = "api.tester+" + System.nanoTime() + "@example.com"; + + baseTestUser = new UserDto(); + baseTestUser.setFirstName("Api"); + baseTestUser.setLastName("Tester"); + baseTestUser.setEmail(testEmail); + baseTestUser.setPassword(VALID_PASSWORD); + baseTestUser.setMatchingPassword(VALID_PASSWORD); + + deleteTestUser(testEmail); } - protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { - return mockMvc.perform(builder); + @AfterEach + void tearDown() { + deleteTestUser(testEmail); } /** - * - * @param argumentsHolder - * @throws Exception testing with three params: new user data, exist user data and invalid user data + * Hard-deletes the test user and any associated tokens. Tokens are deleted before the user to + * satisfy the FK from the token tables to {@code user_account}. This replaces the custom + * {@code Jdbc} helper the disabled version used. */ - @ParameterizedTest - @ArgumentsSource(ApiTestRegistrationArgumentsProvider.class) - @Order(1) - // correctly run separately - public void registerUserAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception { - ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/registration").contentType(MediaType.APPLICATION_FORM_URLENCODED) - .content(buildUrlEncodedFormEntity(argumentsHolder.getUserDto()))); - - if (argumentsHolder.getStatus() == DataStatus.NEW) { - action.andExpect(status().isOk()); + private void deleteTestUser(String email) { + // This test is not @Transactional, so cleanup must run in its own committed transaction. + txTemplate.executeWithoutResult(status -> { + User user = userRepository.findByEmail(email); + if (user != null) { + passwordResetTokenRepository.deleteByUser(user); + verificationTokenRepository.deleteByUser(user); + userRepository.delete(user); + } + }); + } + + private String json(Object value) { + return objectMapper.writeValueAsString(value); + } + + @Nested + @DisplayName("Registration") + class Registration { + + @Test + @DisplayName("Should register a brand new user account") + void shouldRegisterNewUser() throws Exception { + mockMvc.perform(post(URL + "/registration") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(baseTestUser))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.messages[0]").value("Registration Successful!")); + + assertThat(userService.findUserByEmail(testEmail)).isNotNull(); } - if (argumentsHolder.getStatus() == DataStatus.EXIST) { - action.andExpect(status().isConflict()); + + @Test + @DisplayName("Should return 409 Conflict when the user already exists") + void shouldRejectExistingUser() throws Exception { + // Register once. + userService.registerNewUserAccount(baseTestUser); + + // Register again with the same email. + mockMvc.perform(post(URL + "/registration") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(baseTestUser))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(2)) + .andExpect(jsonPath("$.messages[0]").value("An account already exists for the email address")); } - if (argumentsHolder.getStatus() == DataStatus.INVALID) { - action.andExpect(status().is5xxServerError()); + + @Test + @DisplayName("Should return 400 Bad Request for an invalid (empty) registration") + void shouldRejectInvalidUser() throws Exception { + // An empty DTO fails bean validation (@NotBlank firstName/lastName/email/password) before + // reaching the controller body, so the framework returns a 400. + mockMvc.perform(post(URL + "/registration") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(new UserDto()))) + .andExpect(status().isBadRequest()); } + } + + @Nested + @DisplayName("Reset Password Request") + class ResetPasswordRequest { + + @Test + @DisplayName("Should accept a reset-password request and return the pending page") + void shouldAcceptResetPasswordRequest() throws Exception { + userService.registerNewUserAccount(baseTestUser); + + Map body = Map.of("email", testEmail); - MockHttpServletResponse actual = action.andReturn().getResponse(); - Response excepted = argumentsHolder.getResponse(); - AssertionsHelper.compareResponses(actual, excepted); + mockMvc.perform(post(URL + "/resetPassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.redirectUrl").value("/user/forgot-password-pending-verification.html")) + .andExpect(jsonPath("$.messages[0]").value("If account exists, password reset email has been sent!")); + } } - @Test - @Order(2) - public void resetPassword() throws Exception { - ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/resetPassword").contentType(MediaType.APPLICATION_FORM_URLENCODED) - .content(buildUrlEncodedFormEntity(baseTestUser))).andExpect(status().isOk()); + @Nested + @DisplayName("Update Password (authenticated)") + class UpdatePassword { + + @Test + @DisplayName("Should update the password with a valid old password") + void shouldUpdatePasswordWhenOldPasswordValid() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + + Map body = Map.of( + "oldPassword", VALID_PASSWORD, + "newPassword", NEW_VALID_PASSWORD); + + mockMvc.perform(post(URL + "/updatePassword") + .with(user(new DSUserDetails(user))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); - MockHttpServletResponse actual = action.andReturn().getResponse(); - Response excepted = ApiTestData.resetPassword(); - AssertionsHelper.compareResponses(actual, excepted); + // The stored hash must now verify against the new password. + User reloaded = userService.findUserByEmail(testEmail); + assertThat(userService.checkIfValidOldPassword(reloaded, NEW_VALID_PASSWORD)).isTrue(); + } + + @Test + @DisplayName("Should return 400 when the old password is incorrect") + void shouldRejectUpdateWhenOldPasswordInvalid() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + + Map body = Map.of( + "oldPassword", "WrongOldPass9!", + "newPassword", NEW_VALID_PASSWORD); + + mockMvc.perform(post(URL + "/updatePassword") + .with(user(new DSUserDetails(user))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } } - /** - * Tests the update password functionality with valid and invalid password combinations. - * - * @param argumentsHolder Contains test data for password updates (valid/invalid scenarios) - * @throws Exception if any error occurs during test execution - */ - @ParameterizedTest - @ArgumentsSource(ApiTestUpdatePasswordArgumentsProvider.class) - @Order(3) - public void updatePassword(ApiTestArgumentsHolder argumentsHolder) throws Exception { - // Register and login test user first - login(baseTestUser); - - PasswordDto passwordDto = argumentsHolder.getPasswordDto(); - - ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/updatePassword") - .with(oauth2Login().oauth2User(createTestOAuth2User())) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .content(buildUrlEncodedFormEntity(passwordDto))); - - if (argumentsHolder.getStatus() == DataStatus.VALID) { - action.andExpect(status().isOk()); + @Nested + @DisplayName("Save Password (password-reset completion)") + class SavePassword { + + /** + * Creates a password reset token for the user the way production code does (hashed at rest via + * {@link UserEmailService#createPasswordResetTokenForUser}) and returns the raw token, which is + * what the dual-read lookup supports. + */ + private String createResetTokenForUser(User user) { + String rawToken = "raw-reset-token-" + System.nanoTime(); + userEmailService.createPasswordResetTokenForUser(user, rawToken); + return rawToken; } - if (argumentsHolder.getStatus() == DataStatus.INVALID) { - action.andExpect(status().isBadRequest()); + + private Map savePasswordBody(String token, String newPassword, String confirmPassword) { + return Map.of( + "token", token, + "newPassword", newPassword, + "confirmPassword", confirmPassword); } - MockHttpServletResponse actual = action.andReturn().getResponse(); - Response expected = argumentsHolder.getResponse(); - AssertionsHelper.compareResponses(actual, expected); - } + @Test + @DisplayName("Should change the password and consume the token for a valid reset token") + void shouldChangePasswordWithValidToken() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = createResetTokenForUser(user); + + // Sanity: the token is stored hashed, not raw. + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))).isNotNull(); + + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, NEW_VALID_PASSWORD)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + // Password was actually changed (the new password now verifies / the stored hash changed). + User reloaded = userService.findUserByEmail(testEmail); + assertThat(userService.checkIfValidOldPassword(reloaded, NEW_VALID_PASSWORD)).isTrue(); - protected void login(UserDto userDto) { - User user; - if ((user = userService.findUserByEmail(userDto.getEmail())) == null) { - user = userService.registerNewUserAccount(userDto); + // Token was consumed/deleted (neither hashed nor raw form remains). + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))).isNull(); + assertThat(passwordResetTokenRepository.findByToken(rawToken)).isNull(); } - userService.authWithoutPassword(user); - } - /** - * Creates a test OAuth2 user for authentication in tests. - */ - private OAuth2User createTestOAuth2User() { - return TestFixtures.OAuth2.customUser(baseTestUser.getEmail(), "Test User", "test-user-123"); - } + @Test + @DisplayName("Should reject a reused token on the second attempt") + void shouldRejectReusedToken() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = createResetTokenForUser(user); + // First use succeeds. + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, NEW_VALID_PASSWORD)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + // Second use of the same (now consumed) token is rejected. + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, "AnotherPass3!", "AnotherPass3!")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + @Test + @DisplayName("Should reject an expired token") + void shouldRejectExpiredToken() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = "raw-expired-token-" + System.nanoTime(); + + // Persist a token whose stored value matches the dual-read hash lookup but is already + // expired. Wrapped in its own transaction since this test is not @Transactional. + txTemplate.executeWithoutResult(status -> { + PasswordResetToken expired = new PasswordResetToken(tokenHasher.hash(rawToken), user); + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DATE, -1); + expired.setExpiryDate(new Date(cal.getTimeInMillis())); + passwordResetTokenRepository.save(expired); + }); + + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, NEW_VALID_PASSWORD)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("Should return 400 when the new passwords do not match") + void shouldRejectMismatchedPasswords() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = createResetTokenForUser(user); + + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, "DifferentPass4!")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(1)); + + // The token must NOT have been consumed by a failed (mismatched) attempt. + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))).isNotNull(); + } + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java b/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java deleted file mode 100644 index 4f8e9ed..0000000 --- a/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.digitalsanctuary.spring.user.jdbc; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; - -public class ConnectionManager { - - private static final String driver = "org.mariadb.jdbc.Driver"; - - private static final String url = "jdbc:mariadb://127.0.0.1:3306/springuser"; - - private static final String username = "springuser"; - - private static final String password = "springuser"; - - static { - initDriver(); - } - - private static void initDriver() { - try { - Class.forName(driver); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } - - public static Connection open() { - try { - return DriverManager.getConnection(url, username, password); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java b/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java deleted file mode 100644 index 5773bc8..0000000 --- a/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.digitalsanctuary.spring.user.jdbc; - -import com.digitalsanctuary.spring.user.dto.UserDto; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; - - -/** - * Using for delete/save user test data - */ -public class Jdbc { - private static final String DELETE_VERIFICATION_TOKEN_QUERY = - "DELETE FROM verification_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_USER_ROLE = "DELETE FROM users_roles WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_PASSWORD_RESET_TOKEN = - "DELETE FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_USER_QUERY = "DELETE FROM user_account WHERE email = ?"; - private static final String GET_LAST_USER_ID_QUERY = "SELECT max(id) FROM user_account"; - private static final String GET_PASSWORD_RESET_TOKEN_BY_USER_EMAIL_QUERY = - "SELECT token FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - private static final String SAVE_TEST_USER_QUERY = "INSERT INTO user_account (id, first_name, last_name, email, " - + "password, enabled, failed_login_attempts, locked) VALUES (?,?,?,?,?,?,?,?)"; - - public static void deleteTestUser(UserDto userDto) { - try (Connection connection = ConnectionManager.open()) { - String[] params = new String[] {userDto.getEmail()}; - execute(connection, DELETE_VERIFICATION_TOKEN_QUERY, params); - execute(connection, DELETE_TEST_USER_ROLE, params); - execute(connection, DELETE_TEST_PASSWORD_RESET_TOKEN, params); - execute(connection, DELETE_TEST_USER_QUERY, params); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - - public static void saveTestUser(UserDto userDto) { - try (Connection connection = ConnectionManager.open()) { - ResultSet resultSet = connection.prepareStatement(GET_LAST_USER_ID_QUERY).executeQuery(); - int id = 0; - if (resultSet.next()) { - id = (resultSet.getInt(1) + 1); - } - Object[] params = new Object[] {id, userDto.getFirstName(), userDto.getLastName(), userDto.getEmail(), "TEST_USER_ENCODED_PASSWORD", true, - 0, false}; - execute(connection, SAVE_TEST_USER_QUERY, params); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - private static void execute(Connection connection, String query, Object[] params) throws SQLException { - PreparedStatement statement = connection.prepareStatement(query); - for (int i = 0; i < params.length; i++) { - Object param = params[i]; - if (param instanceof Integer) { - statement.setInt((i + 1), (Integer) param); - } - if (param instanceof String) { - statement.setString((i + 1), (String) param); - } - if (param instanceof Boolean) { - statement.setBoolean((i + 1), (Boolean) param); - } - } - statement.executeUpdate(); - } - - public static String getPasswordRestTokenByUserEmail(String email) { - String token = ""; - try (Connection connection = ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(GET_PASSWORD_RESET_TOKEN_BY_USER_EMAIL_QUERY); - statement.setString(1, email); - ResultSet set = statement.executeQuery(); - if (set.next()) { - token = set.getString(1); - } - } catch (SQLException e) { - throw new RuntimeException(e); - } - return token; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java index ad97c42..2802759 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java @@ -8,6 +8,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; /** @@ -87,7 +88,15 @@ void manyConsumerGuardsAllAllow() { }); } + // These nested guard configurations are registered explicitly via ApplicationContextRunner + // (withUserConfiguration). They are annotated @Profile("!test") so the library's broad + // @ComponentScan("com.digitalsanctuary.spring.user") in UserConfiguration does NOT pick them up + // as real beans inside the full @IntegrationTest context (which activates the "test" profile). + // Without this, their denying RegistrationGuard beans would leak into every integration context's + // CompositeRegistrationGuard and block all registration. The ApplicationContextRunner below + // activates no profile, so "!test" is satisfied and these configs still load for these tests. @Configuration + @Profile("!test") static class OneDenyingGuardConfig { @Bean RegistrationGuard consumerGuard() { @@ -96,6 +105,7 @@ RegistrationGuard consumerGuard() { } @Configuration + @Profile("!test") static class TwoOrderedGuardsConfig { @Bean @Order(1) @@ -111,6 +121,7 @@ RegistrationGuard secondGuard() { } @Configuration + @Profile("!test") static class TwoAllowingGuardsConfig { @Bean @Order(1) diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java index c36a68f..2b27194 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Calendar; @@ -58,13 +59,21 @@ void validateVerificationToken_returnsValidIfTokenValid() { Assertions.assertEquals(result, UserService.TokenValidationResult.VALID); } - // @Test - // void validateVerificationToken_returnsExpiredIfTokenExpired() { - // testToken.setExpiryDate(getExpirationDate(0)); - // when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); - // UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString()); - // Assertions.assertEquals(result, UserService.TokenValidationResult.EXPIRED); - // } + @Test + void validateVerificationToken_returnsExpiredIfTokenExpired() { + // Clearly-past expiry so the token is unambiguously expired. + testToken.setExpiryDate(getExpirationDate(-1)); + // Dual-read: validateVerificationToken first looks up by hash(raw), then by raw. With a null + // secret the hash is a deterministic SHA-256 of the raw value, so stub findByToken for any + // argument to resolve the token regardless of which lookup the service performs. + when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); + + UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); + + Assertions.assertEquals(UserService.TokenValidationResult.EXPIRED, result); + // Expired tokens are cleaned up (deleted) as part of validation. + Mockito.verify(verificationTokenRepository).delete(testToken); + } @Test void validateVerificationToken_returnInvalidTokenIfTokenNotFound() { From 4b5c06c4045732afc4e23e9005da9343b3c9c924 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 00:21:57 -0600 Subject: [PATCH 26/55] test: lockout enforced through the real authentication path + config-edge tests --- .../AccountLockoutIntegrationTest.java | 256 ++++++++++++++++++ .../user/service/DSUserDetailsTest.java | 25 ++ .../user/service/LoginAttemptServiceTest.java | 29 ++ 3 files changed, 310 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java new file mode 100644 index 0000000..c3087b6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java @@ -0,0 +1,256 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.service.DSUserDetailsService; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +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.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +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.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration test proving that account lockout (brute-force protection) is enforced through the + * real {@code formLogin} authentication path — not just at the unit level. + * + *

    What this exercises end-to-end

    + * + *

    + * A real {@code POST} to the form-login processing URL drives Spring Security, which fires + * {@code AuthenticationFailureBadCredentialsEvent} / {@code AuthenticationSuccessEvent}. The library's + * {@code AuthenticationEventListener} reacts and calls {@code LoginAttemptService.loginFailed(...)}, + * which atomically increments the persisted {@code failedLoginAttempts} counter and, at the threshold, + * sets {@code User.locked = true} in the database. On the next authentication attempt the DB-backed + * {@code DSUserDetailsService.loadUserByUsername} loads the now-locked user and + * {@code LoginHelperService.assertAccountUsable} throws {@code LockedException} — so the login is + * rejected even with the correct password. That correct-password-still-rejected assertion is + * the heart of this test: it proves lockout genuinely blocks authentication. + *

    + * + *

    Why this test does NOT use {@code @SecurityTest} and replaces the security beans deterministically

    + * + *

    + * The real lockout path requires authenticating against the library's DB-backed + * {@link DSUserDetailsService}, so that a lockout committed to the database actually blocks the next login. + * Two obstacles make that non-trivial in the test slice: + *

    + *
      + *
    1. {@code SecurityTestConfiguration} (imported by {@code @SecurityTest}) registers a + * {@code @Primary InMemoryUserDetailsManager} and a {@code @Primary TestingAuthenticationProvider} (which + * cannot authenticate username/password tokens). Even without {@code @SecurityTest}, those beans still leak + * in because {@code UserConfiguration}'s component scan ({@code basePackages = "com.digitalsanctuary.spring.user"}) + * does not install the Spring Boot {@code TypeExcludeFilter} and therefore sweeps up that + * {@code @TestConfiguration} from the {@code test.config} sub-package.
    2. + *
    3. That leaked {@code @Primary} provider also causes Spring Security to expose its own + * {@code @Primary AuthenticationManager}, so naively adding another {@code @Primary AuthenticationManager} + * collides ("found 2 primary beans").
    4. + *
    + *

    + * To remove both problems deterministically (independent of bean import/scan ordering), a + * {@link BeanDefinitionRegistryPostProcessor} removes the leaked {@code testUserDetailsService} and + * {@code testAuthenticationProvider} definitions and re-registers them as the real, DB-backed + * {@link DSUserDetailsService} and a {@code DaoAuthenticationProvider} bound to it. Spring Security then + * auto-builds a single authentication manager from that real provider, and form login authenticates against + * the database — so the persisted lock state gates login exactly as in production. + * {@code @AutoConfigureMockMvc(addFilters = true)} keeps the security filter chain active; + * {@link BaseTestConfiguration} supplies the fast BCrypt(4) {@link PasswordEncoder} and an in-memory + * {@code SessionRegistry}. + *

    + * + *

    Why this class uses an isolated in-memory database

    + * + *

    + * The {@code AuthenticationEventListener} delegates to {@code LoginAttemptService}, whose methods are + * {@code @Transactional} and therefore commit the failed-attempt / locked mutations to the + * user row in their own transactions. The standard {@code test} profile points every test at the shared + * {@code jdbc:h2:mem:testdb}; JUnit runs classes in parallel, and other integration tests call + * {@code userRepository.deleteAll()}. Those deletes would race this class's committed lock-state rows, + * producing intermittent failures. {@link TestPropertySource} therefore overrides {@code spring.datasource.url} + * to a dedicated database ({@code jdbc:h2:mem:lockouttest}). The URL options are copied verbatim from the + * shared {@code application-test.properties} URL ({@code DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE}); only the + * database name differs. The distinct datasource also gives this class its own Spring context, so its committed + * rows live in a database no other test's {@code deleteAll()} can see. The schema is created automatically + * because the {@code test} profile sets {@code ddl-auto=create-drop}. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc(addFilters = true) +@ActiveProfiles("test") +@Import({BaseTestConfiguration.class, AccountLockoutIntegrationTest.DbBackedSecurityBeanConfig.class}) +@TestPropertySource(properties = { + // Isolated in-memory DB so this class's COMMITTED lock-state rows are invisible to the shared-DB + // integration tests' deleteAll(). Options copied verbatim from the shared testdb URL + // (application-test.properties) — only the database name (lockouttest) differs. + "spring.datasource.url=jdbc:h2:mem:lockouttest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + // Small, known lockout threshold so N failed attempts deterministically locks the account. + "user.security.failedLoginAttempts=3", + // Admin-only unlock (negative duration) so the account never auto-unlocks during the test window, + // making the correct-password-still-rejected assertion deterministic. + "user.security.accountLockoutDuration=-1" +}) +@DisplayName("Account Lockout Integration Tests (real formLogin path)") +class AccountLockoutIntegrationTest { + + /** Must match user.security.failedLoginAttempts above. */ + private static final int MAX_FAILED_ATTEMPTS = 3; + + private static final String LOGIN_URL = "/user/login"; + private static final String FAILURE_URL = "/user/login.html?error"; + + private static final String TEST_EMAIL = "lockout-victim@test.com"; + private static final String CORRECT_PASSWORD = "CorrectPass1!"; + private static final String WRONG_PASSWORD = "WrongPass9!"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + void seedFreshUnlockedUser() { + // A dedicated user with a clean lock state guarantees repeated runs start from zero, regardless + // of any rows left committed by a previous run in this isolated database. + User existing = userRepository.findByEmail(TEST_EMAIL); + if (existing != null) { + userRepository.delete(existing); + } + + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setFirstName("Lockout"); + user.setLastName("Victim"); + user.setPassword(passwordEncoder.encode(CORRECT_PASSWORD)); + user.setEnabled(true); + user.setLocked(false); + user.setFailedLoginAttempts(0); + userRepository.save(user); + } + + @AfterEach + void cleanup() { + User user = userRepository.findByEmail(TEST_EMAIL); + if (user != null) { + userRepository.delete(user); + } + } + + @Test + @DisplayName("should reject a correct-password login once the account is locked by failed attempts") + void shouldRejectCorrectPasswordWhenAccountLockedByFailedAttempts() throws Exception { + // Sanity check: the correct password authenticates before any lockout, confirming the wiring. + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) + .andExpect(authenticated()); + // Reset the counter so the sanity-check success does not consume an attempt for the rest of the test. + seedFreshUnlockedUser(); + + // Perform N failed attempts with the WRONG password to trip the lockout threshold. + for (int attempt = 1; attempt <= MAX_FAILED_ATTEMPTS; attempt++) { + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(WRONG_PASSWORD)) + .andExpect(unauthenticated()) + .andExpect(redirectedUrl(FAILURE_URL)); + } + + // The account must now be locked in the database (the listener committed the change). + User locked = userRepository.findByEmail(TEST_EMAIL); + assertThat(locked).isNotNull(); + assertThat(locked.isLocked()).as("account should be locked after %d failed attempts", MAX_FAILED_ATTEMPTS).isTrue(); + assertThat(locked.getFailedLoginAttempts()).isGreaterThanOrEqualTo(MAX_FAILED_ATTEMPTS); + + // KEY ASSERTION: the (N+1)th attempt with the CORRECT password is still rejected, because the + // account is locked — the real DSUserDetailsService load path throws LockedException. + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) + .andExpect(unauthenticated()) + .andExpect(redirectedUrl(FAILURE_URL)); + } + + @Test + @DisplayName("should authenticate normally before the lockout threshold is reached") + void shouldAuthenticateWhenBelowLockoutThreshold() throws Exception { + // One short of the threshold still allows the correct password to succeed. + for (int attempt = 1; attempt < MAX_FAILED_ATTEMPTS; attempt++) { + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(WRONG_PASSWORD)) + .andExpect(unauthenticated()); + } + + User user = userRepository.findByEmail(TEST_EMAIL); + assertThat(user).isNotNull(); + assertThat(user.isLocked()).as("account should not be locked below the threshold").isFalse(); + + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) + .andExpect(authenticated()); + } + + /** + * Replaces the leaked {@code SecurityTestConfiguration} security beans with DB-backed equivalents via a + * {@link BeanDefinitionRegistryPostProcessor}, which runs deterministically before bean instantiation and + * is therefore immune to bean import/scan ordering (plain {@code @Bean} overrides proved order-dependent + * here). After this runs, the only {@code UserDetailsService} is the real {@link DSUserDetailsService} and + * the only {@code AuthenticationProvider} is a {@code DaoAuthenticationProvider} bound to it, so Spring + * Security builds a single, DB-backed authentication manager — no primary-bean collision, and form login + * sees the live, committed lock state. + */ + @TestConfiguration + static class DbBackedSecurityBeanConfig { + + @Bean + static BeanDefinitionRegistryPostProcessor replaceLeakedSecurityBeans() { + return new BeanDefinitionRegistryPostProcessor() { + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { + // Remove the in-memory user store and the TestingAuthenticationProvider that leak in from + // SecurityTestConfiguration. Once gone, the only UserDetailsService is the real + // DSUserDetailsService bean (which we mark primary) and the only AuthenticationProvider is + // the library's auto-configured DaoAuthenticationProvider (`authProvider`), already wired to + // the @Primary UserDetailsService and the @Primary BCrypt(4) PasswordEncoder. Spring Security + // then builds a single, DB-backed authentication manager — no encoder mismatch, no primary + // collision — and form login sees the live, committed lock state. + removeIfPresent(registry, "testUserDetailsService"); + removeIfPresent(registry, "testAuthenticationProvider"); + markPrimary(registry, "DSUserDetailsService"); + markPrimary(registry, "dsUserDetailsService"); + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { + // No-op: all work is done at registry time. + } + + private void removeIfPresent(BeanDefinitionRegistry registry, String name) { + if (registry.containsBeanDefinition(name)) { + registry.removeBeanDefinition(name); + } + } + + private void markPrimary(BeanDefinitionRegistry registry, String name) { + if (registry.containsBeanDefinition(name)) { + registry.getBeanDefinition(name).setPrimary(true); + } + } + }; + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java index df125df..192124b 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java @@ -251,6 +251,31 @@ void shouldPreserveOidcTokensAlongsideAttributes() { } } + @Nested + @DisplayName("Account Locked Status") + class AccountLockedStatusTests { + + @Test + @DisplayName("isAccountNonLocked should return false when the wrapped User is locked") + void shouldReturnNotAccountNonLockedWhenUserLocked() { + testUser.setLocked(true); + + DSUserDetails details = new DSUserDetails(testUser); + + assertThat(details.isAccountNonLocked()).isFalse(); + } + + @Test + @DisplayName("isAccountNonLocked should return true when the wrapped User is not locked") + void shouldReturnAccountNonLockedWhenUserNotLocked() { + testUser.setLocked(false); + + DSUserDetails details = new DSUserDetails(testUser); + + assertThat(details.isAccountNonLocked()).isTrue(); + } + } + @Nested @DisplayName("Builder") class BuilderTests { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java index a6618c5..e66750b 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java @@ -146,5 +146,34 @@ void isLocked_unlocksUserAfterLockoutDuration() { verify(userRepository).save(testUser); } + @Test + void checkIfUserShouldBeUnlocked_adminOnlyUnlockKeepsLockedDespitePastLockedDate() { + // A negative accountLockoutDuration means the account can ONLY be unlocked by an administrator, + // never automatically by elapsed time — even with a lockedDate far in the past. + loginAttemptService.setAccountLockoutDuration(-1); + testUser.setLocked(true); + testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); // locked an hour ago + + User result = loginAttemptService.checkIfUserShouldBeUnlocked(testUser); + + assertTrue(result.isLocked()); + assertNotNull(result.getLockedDate()); + // No auto-unlock occurred, so nothing should have been persisted. + verify(userRepository, never()).save(testUser); + } + + @Test + void isLocked_adminOnlyUnlockKeepsUserLockedDespitePastLockedDate() { + // End-to-end through isLocked(): with admin-only unlock, a long-locked user stays locked. + loginAttemptService.setAccountLockoutDuration(-1); + testUser.setLocked(true); + testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); + when(userRepository.findByEmail(anyString())).thenReturn(testUser); + + assertTrue(loginAttemptService.isLocked(testUser.getEmail())); + assertTrue(testUser.isLocked()); + verify(userRepository, never()).save(testUser); + } + // Additional tests can be written for edge cases and exception handling } From 419ebee47471d3aefab993617e67324805bbd1b6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 00:32:29 -0600 Subject: [PATCH 27/55] fix(test-infra): exclude @TestConfiguration/auto-config from library component scan; stabilize real-form-login integration tests --- .../spring/user/UserConfiguration.java | 7 +- .../AccountLockoutIntegrationTest.java | 92 ++++--------------- 2 files changed, 22 insertions(+), 77 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java index fd55bc0..ef65a8b 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java @@ -1,7 +1,10 @@ package com.digitalsanctuary.spring.user; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.context.TypeExcludeFilter; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.Import; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableAsync; @@ -23,7 +26,9 @@ @EnableRetry @EnableScheduling @EnableMethodSecurity -@ComponentScan(basePackages = "com.digitalsanctuary.spring.user") +@ComponentScan(basePackages = "com.digitalsanctuary.spring.user", + excludeFilters = {@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)}) @Import(UserAutoConfigurationRegistrar.class) public class UserConfiguration { diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java index c3087b6..783f8df 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java @@ -15,14 +15,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.beans.factory.support.BeanDefinitionRegistry; -import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; 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.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; @@ -47,36 +42,31 @@ * the heart of this test: it proves lockout genuinely blocks authentication. *

    * - *

    Why this test does NOT use {@code @SecurityTest} and replaces the security beans deterministically

    + *

    Why this test does NOT use {@code @SecurityTest}

    * *

    * The real lockout path requires authenticating against the library's DB-backed * {@link DSUserDetailsService}, so that a lockout committed to the database actually blocks the next login. - * Two obstacles make that non-trivial in the test slice: - *

    - *
      - *
    1. {@code SecurityTestConfiguration} (imported by {@code @SecurityTest}) registers a + * {@code @SecurityTest} imports {@code SecurityTestConfiguration}, which registers a * {@code @Primary InMemoryUserDetailsManager} and a {@code @Primary TestingAuthenticationProvider} (which - * cannot authenticate username/password tokens). Even without {@code @SecurityTest}, those beans still leak - * in because {@code UserConfiguration}'s component scan ({@code basePackages = "com.digitalsanctuary.spring.user"}) - * does not install the Spring Boot {@code TypeExcludeFilter} and therefore sweeps up that - * {@code @TestConfiguration} from the {@code test.config} sub-package.
    2. - *
    3. That leaked {@code @Primary} provider also causes Spring Security to expose its own - * {@code @Primary AuthenticationManager}, so naively adding another {@code @Primary AuthenticationManager} - * collides ("found 2 primary beans").
    4. - *
    - *

    - * To remove both problems deterministically (independent of bean import/scan ordering), a - * {@link BeanDefinitionRegistryPostProcessor} removes the leaked {@code testUserDetailsService} and - * {@code testAuthenticationProvider} definitions and re-registers them as the real, DB-backed - * {@link DSUserDetailsService} and a {@code DaoAuthenticationProvider} bound to it. Spring Security then - * auto-builds a single authentication manager from that real provider, and form login authenticates against - * the database — so the persisted lock state gates login exactly as in production. + * cannot authenticate username/password tokens) — both intended for mock-security tests, not the real + * DB-backed form login this test needs. This class therefore uses a plain {@code @SpringBootTest} that imports + * only {@link BaseTestConfiguration}, leaving the library's auto-configured DB-backed + * {@link DSUserDetailsService} + {@code DaoAuthenticationProvider} as the sole authentication path. Spring + * Security builds a single authentication manager from that real provider, and form login authenticates + * against the database — so the persisted lock state gates login exactly as in production. * {@code @AutoConfigureMockMvc(addFilters = true)} keeps the security filter chain active; * {@link BaseTestConfiguration} supplies the fast BCrypt(4) {@link PasswordEncoder} and an in-memory * {@code SessionRegistry}. *

    * + *

    + * Note: {@code SecurityTestConfiguration} no longer leaks into this non-{@code @SecurityTest} context via the + * library's component scan, because {@code UserConfiguration} now installs the Spring Boot + * {@code TypeExcludeFilter} / {@code AutoConfigurationExcludeFilter} in its {@code @ComponentScan}, which + * excludes {@code @TestConfiguration} classes from the scan. + *

    + * *

    Why this class uses an isolated in-memory database

    * *

    @@ -96,7 +86,7 @@ @SpringBootTest(classes = TestApplication.class) @AutoConfigureMockMvc(addFilters = true) @ActiveProfiles("test") -@Import({BaseTestConfiguration.class, AccountLockoutIntegrationTest.DbBackedSecurityBeanConfig.class}) +@Import(BaseTestConfiguration.class) @TestPropertySource(properties = { // Isolated in-memory DB so this class's COMMITTED lock-state rows are invisible to the shared-DB // integration tests' deleteAll(). Options copied verbatim from the shared testdb URL @@ -203,54 +193,4 @@ void shouldAuthenticateWhenBelowLockoutThreshold() throws Exception { mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) .andExpect(authenticated()); } - - /** - * Replaces the leaked {@code SecurityTestConfiguration} security beans with DB-backed equivalents via a - * {@link BeanDefinitionRegistryPostProcessor}, which runs deterministically before bean instantiation and - * is therefore immune to bean import/scan ordering (plain {@code @Bean} overrides proved order-dependent - * here). After this runs, the only {@code UserDetailsService} is the real {@link DSUserDetailsService} and - * the only {@code AuthenticationProvider} is a {@code DaoAuthenticationProvider} bound to it, so Spring - * Security builds a single, DB-backed authentication manager — no primary-bean collision, and form login - * sees the live, committed lock state. - */ - @TestConfiguration - static class DbBackedSecurityBeanConfig { - - @Bean - static BeanDefinitionRegistryPostProcessor replaceLeakedSecurityBeans() { - return new BeanDefinitionRegistryPostProcessor() { - @Override - public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { - // Remove the in-memory user store and the TestingAuthenticationProvider that leak in from - // SecurityTestConfiguration. Once gone, the only UserDetailsService is the real - // DSUserDetailsService bean (which we mark primary) and the only AuthenticationProvider is - // the library's auto-configured DaoAuthenticationProvider (`authProvider`), already wired to - // the @Primary UserDetailsService and the @Primary BCrypt(4) PasswordEncoder. Spring Security - // then builds a single, DB-backed authentication manager — no encoder mismatch, no primary - // collision — and form login sees the live, committed lock state. - removeIfPresent(registry, "testUserDetailsService"); - removeIfPresent(registry, "testAuthenticationProvider"); - markPrimary(registry, "DSUserDetailsService"); - markPrimary(registry, "dsUserDetailsService"); - } - - @Override - public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { - // No-op: all work is done at registry time. - } - - private void removeIfPresent(BeanDefinitionRegistry registry, String name) { - if (registry.containsBeanDefinition(name)) { - registry.removeBeanDefinition(name); - } - } - - private void markPrimary(BeanDefinitionRegistry registry, String name) { - if (registry.containsBeanDefinition(name)) { - registry.getBeanDefinition(name).setPrimary(true); - } - } - }; - } - } } From 872274fac6616898855f170b9982687e350b5bcb Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 00:40:41 -0600 Subject: [PATCH 28/55] test: WebAuthn credential ownership IDOR negative test at the repository level --- .../WebAuthnCredentialOwnershipTest.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java new file mode 100644 index 0000000..9bc6d7a --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java @@ -0,0 +1,132 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; +import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Repository-slice IDOR (Insecure Direct Object Reference) negative tests for WebAuthn credential + * ownership enforcement. + * + *

    + * The ownership guard lives in {@link WebAuthnCredentialQueryRepository#deleteCredential(String, Long)} + * and {@link WebAuthnCredentialQueryRepository#renameCredential(String, String, Long)}: each loads the + * credential by its id, then verifies the credential's owning {@code WebAuthnUserEntity.user.id} matches + * the supplied {@code userId} before acting. These tests prove that guard holds against a real (H2) + * database — not just a mock — so that user A cannot delete or rename user B's passkey by guessing its + * credential id. + *

    + * + *

    + * This runs as a {@code @DataJpaTest} slice (transactional, rolled back per test), so it does not pollute + * the shared integration database. {@link WebAuthnCredentialQueryRepository} is conditional on + * {@code user.webauthn.enabled} and is not part of the repository slice scan, so it is constructed + * manually from the autowired {@link WebAuthnCredentialRepository}. + *

    + */ +@DatabaseTest +class WebAuthnCredentialOwnershipTest { + + @Autowired + private WebAuthnCredentialRepository credentialRepository; + + @Autowired + private TestEntityManager entityManager; + + private WebAuthnCredentialQueryRepository queryRepository; + + private User userA; + private User userB; + private WebAuthnCredential credA; + private WebAuthnCredential credB; + + private static final String CRED_B_ORIGINAL_LABEL = "User B's iPhone"; + + @BeforeEach + void setUp() { + // The query repository is @ConditionalOnProperty(user.webauthn.enabled) and is not picked up by + // the @DataJpaTest slice scan, so wire it manually from the slice-managed Spring Data repository. + queryRepository = new WebAuthnCredentialQueryRepository(credentialRepository); + + userA = persistUser("idor-owner-a@test.com"); + userB = persistUser("idor-owner-b@test.com"); + + credA = persistCredential("cred-a-id", "User A's YubiKey", userA, "handle-a"); + credB = persistCredential("cred-b-id", CRED_B_ORIGINAL_LABEL, userB, "handle-b"); + } + + private User persistUser(String email) { + User user = UserTestDataBuilder.aUser().withId(null).withEmail(email).build(); + return entityManager.persistAndFlush(user); + } + + private WebAuthnCredential persistCredential(String credentialId, String label, User owner, String userHandle) { + WebAuthnUserEntity userEntity = new WebAuthnUserEntity(); + userEntity.setId(userHandle); + userEntity.setName(owner.getEmail()); + userEntity.setDisplayName(owner.getFirstName() + " " + owner.getLastName()); + userEntity.setUser(owner); + entityManager.persist(userEntity); + + WebAuthnCredential credential = new WebAuthnCredential(); + credential.setCredentialId(credentialId); + credential.setUserEntity(userEntity); + credential.setPublicKey(new byte[] {1, 2, 3, 4}); + credential.setSignatureCount(0L); + credential.setLabel(label); + credential.setCreated(Instant.now()); + entityManager.persist(credential); + entityManager.flush(); + return credential; + } + + @Test + void shouldReturnZeroAndPreserveCredentialWhenDeletingAnotherUsersCredential() { + int deleted = queryRepository.deleteCredential(credB.getCredentialId(), userA.getId()); + + assertThat(deleted).as("user A must not be able to delete user B's credential").isZero(); + + // Reload from the database to confirm the credential still exists. + entityManager.clear(); + assertThat(credentialRepository.findById(credB.getCredentialId())) + .as("user B's credential must still exist after the rejected cross-user delete").isPresent(); + } + + @Test + void shouldReturnZeroAndPreserveLabelWhenRenamingAnotherUsersCredential() { + int renamed = queryRepository.renameCredential(credB.getCredentialId(), "hacked", userA.getId()); + + assertThat(renamed).as("user A must not be able to rename user B's credential").isZero(); + + // Reload from the database to confirm the label is unchanged. + entityManager.clear(); + assertThat(credentialRepository.findById(credB.getCredentialId())) + .as("user B's credential must still exist after the rejected cross-user rename").isPresent() + .get().extracting(WebAuthnCredential::getLabel) + .as("user B's credential label must be unchanged").isEqualTo(CRED_B_ORIGINAL_LABEL); + } + + @Test + void shouldDeleteOwnCredentialWhenOwnerRequestsDeletion() { + // Positive control: the owner CAN delete their own credential. Proves the guard rejects based on + // ownership rather than rejecting everything, so the negative assertions above are meaningful. + int deleted = queryRepository.deleteCredential(credA.getCredentialId(), userA.getId()); + + assertThat(deleted).as("user A must be able to delete their own credential").isEqualTo(1); + + // Flush the pending delete to the DB, then clear so the reload hits the database rather than the + // first-level cache. (clear() alone would discard the un-flushed delete and falsely show the row.) + entityManager.flush(); + entityManager.clear(); + assertThat(credentialRepository.findById(credA.getCredentialId())) + .as("user A's credential must be gone after the owner deletes it").isEmpty(); + } +} From 9a9520a6a1eddd37d3b738e8dd3c46fc4df83284 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 00:52:53 -0600 Subject: [PATCH 29/55] test: cover WebSecurityConfig authorization branches and CSRF behavior --- ...enticationEntryPointConfigurationTest.java | 6 +- .../WebSecurityAuthorizationAllowTest.java | 68 ++++++++++++++ .../WebSecurityAuthorizationDenyTest.java | 93 +++++++++++++++++++ ...ebSecurityAuthorizationFailClosedTest.java | 60 ++++++++++++ .../user/security/WebSecurityCsrfTest.java | 74 +++++++++++++++ 5 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java index 87e62e1..cce45d9 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java @@ -8,7 +8,6 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; @@ -84,7 +83,10 @@ void shouldNotRegisterLibraryBeanWhenConsumerProvidesEntryPoint() { } } - @Configuration + // Not annotated with @Configuration so the integration tests' component scan does not pick it up + // (it is registered explicitly via ApplicationContextRunner.withUserConfiguration above). A scanned + // @Configuration here would leak a plain LoginUrlAuthenticationEntryPoint("/custom/login") into every + // @SpringBootTest context, overriding the framework's HtmxAwareAuthenticationEntryPoint. static class ConsumerEntryPointConfiguration { @Bean public AuthenticationEntryPoint consumerEntryPoint() { diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java new file mode 100644 index 0000000..83722f7 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java @@ -0,0 +1,68 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Authorization-model tests for {@link WebSecurityConfig} under {@code user.security.defaultAction=allow}. + * + *

    + * The {@code allow} model inverts the default: everything is anonymously accessible except the explicitly + * listed {@code protectedURIs}. These tests drive the real security filter chain via {@code @SecurityTest}'s + * {@code MockMvc(addFilters = true)}. + *

    + * + *

    + * As in the deny tests, the test app has no MVC handler for these paths, so a request that passes authorization + * returns 404 (reached the dispatcher, no handler), while a rejected anonymous request to a protected URI + * returns a 302 redirect to the login page. {@code protectedURIs} is {@code /protected.html} (from + * {@code application-test.yml}); any other path is unprotected under {@code allow}. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=allow", "user.security.protectedURIs=/protected.html"}) +@DisplayName("WebSecurityConfig Authorization - defaultAction=allow") +class WebSecurityAuthorizationAllowTest { + + /** Listed in protectedURIs, so it requires authentication even under allow. */ + private static final String PROTECTED_URI = "/protected.html"; + + /** Not listed in protectedURIs, so it is anonymously accessible under allow. */ + private static final String UNLISTED_URI = "/some/random/unlisted/path"; + + private static final String LOGIN_PAGE = "/user/login.html"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should redirect anonymous request for a protected URI to login when defaultAction is allow") + void shouldRejectAnonymousProtectedUriWhenAllow() throws Exception { + mockMvc.perform(get(PROTECTED_URI)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(LOGIN_PAGE)); + } + + @Test + @DisplayName("should allow anonymous access to an unlisted URI when defaultAction is allow") + void shouldAllowAnonymousUnlistedUriWhenAllow() throws Exception { + // 404 (no handler) proves the request passed authorization; it must NOT redirect to login. + mockMvc.perform(get(UNLISTED_URI)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should allow an authenticated user to reach a protected URI when defaultAction is allow") + void shouldAllowAuthenticatedUserOnProtectedUriWhenAllow() throws Exception { + mockMvc.perform(get(PROTECTED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java new file mode 100644 index 0000000..85b21e1 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java @@ -0,0 +1,93 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Authorization-model tests for {@link WebSecurityConfig} under {@code user.security.defaultAction=deny}. + * + *

    + * The {@code deny} model is the secure default: every request requires authentication except the explicitly + * listed {@code unprotectedURIs} (plus the framework's own login/registration/forgot-password pages, which + * {@code getUnprotectedURIsList()} always adds). These tests drive the real security filter chain via + * {@code @SecurityTest}'s {@code MockMvc(addFilters = true)} so the authorization decisions are made by Spring Security + * itself, not by mocks. + *

    + * + *

    How "rejected" vs "allowed" is asserted

    + * + *

    + * The test application defines no MVC handler for these paths, so a request that passes authorization falls + * through to a 404 (no handler) rather than a 200. A request that is rejected by the anonymous-user + * authentication entry point ({@link HtmxAwareAuthenticationEntryPoint} wrapping + * {@code LoginUrlAuthenticationEntryPoint}) produces a 302 redirect to the login page for ordinary browser requests, or + * a 401 for HTMX requests. So: 302/401 == rejected; 404 == authorization passed (the request reached the dispatcher). + * This makes the authorization decision unambiguous without needing a stub controller. + *

    + * + *

    + * The URI lists come from {@code application-test.yml}: {@code unprotectedURIs} includes {@code /index.html}; + * {@code /protected.html} is NOT in that list, so under {@code deny} it requires authentication. {@code loginPageURI} is + * {@code /user/login.html}. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=deny"}) +@DisplayName("WebSecurityConfig Authorization - defaultAction=deny") +class WebSecurityAuthorizationDenyTest { + + /** Not present in unprotectedURIs, so it requires authentication under deny. */ + private static final String UNLISTED_URI = "/protected.html"; + + /** Present in unprotectedURIs (application-test.yml), so it is anonymously accessible under deny. */ + private static final String UNPROTECTED_URI = "/index.html"; + + private static final String LOGIN_PAGE = "/user/login.html"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should redirect anonymous request for an unlisted URI to login when defaultAction is deny") + void shouldRejectAnonymousUnlistedUriWhenDeny() throws Exception { + mockMvc.perform(get(UNLISTED_URI)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(LOGIN_PAGE)); + } + + @Test + @DisplayName("should return 401 for an HTMX anonymous request to an unlisted URI when defaultAction is deny") + void shouldReturn401ForHtmxAnonymousUnlistedUriWhenDeny() throws Exception { + mockMvc.perform(get(UNLISTED_URI).header("HX-Request", "true")) + .andExpect(status().isUnauthorized()) + .andExpect(header().exists("HX-Redirect")); + } + + @Test + @DisplayName("should allow anonymous access to a listed unprotected URI when defaultAction is deny") + void shouldAllowAnonymousUnprotectedUriWhenDeny() throws Exception { + // 404 (no handler) proves the request passed authorization and reached the dispatcher; it must NOT be a + // 302 redirect to login or a 401/403 rejection. + mockMvc.perform(get(UNPROTECTED_URI)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should allow an authenticated user to reach an otherwise-protected URI when defaultAction is deny") + void shouldAllowAuthenticatedUserOnProtectedUriWhenDeny() throws Exception { + // Authenticated => authorization passes => falls through to 404 (no handler). The key assertion is that it is + // NOT redirected to login and NOT a 401/403. The user(...) post-processor attaches the authentication to the + // request so it flows through the real security filter chain. + mockMvc.perform(get(UNLISTED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java new file mode 100644 index 0000000..47eeaac --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java @@ -0,0 +1,60 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Fail-closed test for {@link WebSecurityConfig} when {@code user.security.defaultAction} is set to an + * unrecognized/typo'd value. + * + *

    + * {@code buildSecurityFilterChain} only treats the literal strings {@code "deny"} and {@code "allow"} as valid. Any + * other value (a typo, an empty/garbage string) falls into the {@code else} branch, which logs an error and configures + * {@code anyRequest().denyAll()}. This is the secure, fail-closed behavior: a misconfiguration denies + * everything rather than silently allowing access. These tests lock that behavior in. + *

    + * + *

    + * The strongest assertion is that even an authenticated user is denied (403) for a path that, under any valid + * configuration, they would be allowed to reach (it would fall through to a 404 no-handler). Under {@code denyAll()}, + * authentication is irrelevant — access is refused outright. For an anonymous user, {@code denyAll()} raises an + * {@code AuthenticationException}, so the authentication entry point redirects to the login page (a 3xx) rather than + * serving the resource. Either way, the resource is never served — confirming fail-closed. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=bogus-typo-value"}) +@DisplayName("WebSecurityConfig Authorization - unrecognized defaultAction fails closed") +class WebSecurityAuthorizationFailClosedTest { + + /** A path that is in unprotectedURIs under the test profile; under a VALID config it would be reachable. */ + private static final String NORMALLY_ALLOWED_URI = "/index.html"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should deny an authenticated user even on a normally-allowed URI when defaultAction is unrecognized") + void shouldDenyAuthenticatedUserWhenDefaultActionIsUnrecognized() throws Exception { + // Under deny/allow this same authenticated request would pass authorization and fall through to a 404. + // Fail-closed denyAll() refuses it with 403 regardless of authentication. + mockMvc.perform(get(NORMALLY_ALLOWED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("should not serve the resource to an anonymous user when defaultAction is unrecognized") + void shouldNotServeAnonymousRequestWhenDefaultActionIsUnrecognized() throws Exception { + // Anonymous + denyAll() => AuthenticationException => entry point redirects to login (3xx). The resource is + // never served (no 2xx, no 404 fall-through). + mockMvc.perform(get(NORMALLY_ALLOWED_URI)) + .andExpect(status().is3xxRedirection()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java new file mode 100644 index 0000000..b0ff0b3 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java @@ -0,0 +1,74 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * CSRF-enforcement tests for {@link WebSecurityConfig}. + * + *

    + * Spring Security enables CSRF protection by default; {@link WebSecurityConfig} additionally exempts the paths listed in + * {@code user.security.disableCSRFURIs} via {@code csrf().ignoringRequestMatchers(...)}. Under the test profile + * ({@code application-test.yml}) {@code disableCSRFURIs=/no-csrf-test}. These tests drive the real filter chain via + * {@code @SecurityTest}'s {@code MockMvc(addFilters = true)}. + *

    + * + *

    Isolating CSRF from authorization

    + * + *

    + * A CSRF rejection is a 403 produced by the {@code CsrfFilter}, which runs before authorization. To make CSRF + * the only gate under test, the authenticated cases use {@code @WithMockUser} so the authorization layer would otherwise + * let the request through (the test app has no handler for these paths, so a fully-passing request yields a 404). Thus: + * a 403 here means CSRF rejected the request; a 404 means it passed CSRF (and authorization) and reached the dispatcher. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=deny", "user.security.disableCSRFURIs=/no-csrf-test"}) +@DisplayName("WebSecurityConfig CSRF Enforcement") +class WebSecurityCsrfTest { + + /** A CSRF-protected path (not in disableCSRFURIs). */ + private static final String CSRF_PROTECTED_URI = "/protected.html"; + + /** A path listed in disableCSRFURIs, so the CsrfFilter ignores it. */ + private static final String CSRF_EXEMPT_URI = "/no-csrf-test"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should reject a POST without a CSRF token with 403 on a CSRF-protected endpoint") + void shouldRejectPostWithoutCsrfTokenOnProtectedEndpoint() throws Exception { + // Authenticated, so authorization would pass; the only thing that can reject this is the missing CSRF token. + mockMvc.perform(post(CSRF_PROTECTED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("should pass the CSRF filter when a POST carries a valid CSRF token") + void shouldPassCsrfFilterWhenValidTokenProvided() throws Exception { + // With a valid token the request clears CSRF; authorization also passes (authenticated), so it falls through + // to a 404 (no handler). The load-bearing assertion is that it is NOT a 403 CSRF rejection. + mockMvc.perform(post(CSRF_PROTECTED_URI).with(user("user@test.com").roles("USER")).with(csrf())) + .andExpect(status().is(Matchers.not(403))); + } + + @Test + @DisplayName("should not reject a POST without a CSRF token on a disableCSRFURIs path") + void shouldNotEnforceCsrfOnExemptPath() throws Exception { + // The path is in disableCSRFURIs, so the CsrfFilter ignores it; with an authenticated user the request passes + // authorization too and falls through to a 404. The key assertion: NOT a 403 CSRF rejection despite no token. + mockMvc.perform(post(CSRF_EXEMPT_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().is(Matchers.not(403))) + .andExpect(status().isNotFound()); + } +} From 8214eea177544794c41a11f7539b87ee0fe3da87 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 00:58:41 -0600 Subject: [PATCH 30/55] test: de-scope JpaAuditingConfigTest stand-in to @TestConfiguration to avoid component-scan leak --- .../spring/user/util/JpaAuditingConfigTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java b/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java index b8b2921..c3dde4a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java @@ -5,9 +5,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.data.domain.AuditorAware; @@ -48,7 +48,9 @@ class JpaAuditingConfigTest { * Mirrors {@link JpaAuditingConfig}'s class-level gate exactly, exposing a marker bean that reflects whether the * gated configuration is active. Deliberately omits {@code @EnableJpaAuditing} so no JPA metamodel is initialized. */ - @Configuration + // @TestConfiguration (not @Configuration) so the library's @ComponentScan TypeExcludeFilter keeps this + // stand-in out of integration contexts; it is still applied where used via withUserConfiguration(...). + @TestConfiguration @ConditionalOnProperty(name = "user.jpa.auditing.enabled", havingValue = "true", matchIfMissing = true) static class GatedConfiguration { @Bean From e727e8cf78e2e7ecd77f3d8debb8ee071447f527 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 01:03:13 -0600 Subject: [PATCH 31/55] test: concurrent duplicate-registration is serialized on Postgres and MariaDB --- .../AbstractConcurrentRegistrationTest.java | 189 ++++++++++++++++++ .../MariaDBConcurrentRegistrationTest.java | 36 ++++ .../PostgreSQLConcurrentRegistrationTest.java | 35 ++++ 3 files changed, 260 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java new file mode 100644 index 0000000..4d65f03 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java @@ -0,0 +1,189 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Validates that the SERIALIZABLE duplicate-registration race protection (UserService.registerNewUserAccount -> + * persistNewUserAccount, isolation = SERIALIZABLE, with DataIntegrityViolationException / ConcurrencyFailureException + * translated to UserAlreadyExistException) actually holds on a real, production-grade database — not just on H2. + * + *

    + * Two threads race to register the SAME email at the same instant (released together via a CountDownLatch). On a real + * Postgres (SSI) or MariaDB/InnoDB (next-key locks) under SERIALIZABLE plus the unique email constraint, exactly one + * thread must win (one persisted User) and the other must fail with the handled, translated + * {@link UserAlreadyExistException} — never a raw serialization/constraint exception bubbling up as a 500, and never a + * second user row. + *

    + * + *

    + * Subclasses provide a real database via Testcontainers and point {@code spring.datasource.*} at it via + * {@code @DynamicPropertySource}. The {@code test} profile is active so {@code RolePrivilegeSetupService} seeds the + * {@code ROLE_USER} role on context refresh (the registration path requires it). This class deliberately is NOT + * {@code @Transactional}: each registration must run in its own service-managed transaction on its own thread, and a + * test-managed transaction would defeat that. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +abstract class AbstractConcurrentRegistrationTest { + + /** Password that satisfies upper/lower/digit/special policy requirements. */ + private static final String VALID_PASSWORD = "Str0ng!Passw0rd#2024"; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @AfterEach + void cleanUp() { + // No @Transactional rollback here (the threads commit their own transactions), so clean up explicitly. + userRepository.deleteAll(); + } + + @RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]") + @DisplayName("should serialize concurrent duplicate registration into exactly one user and one UserAlreadyExistException") + void shouldSerializeConcurrentDuplicateRegistrationWhenTwoThreadsRaceSameEmail() throws InterruptedException { + final String email = "race-" + System.nanoTime() + "@test.com"; + + final int threadCount = 2; + final CountDownLatch readyLatch = new CountDownLatch(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + try { + final List> futures = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(registrationTask(email, readyLatch, startLatch))); + } + + // Wait until both threads are parked at the start gate, then release them simultaneously + // to maximize the registration race. + assertThat(readyLatch.await(30, TimeUnit.SECONDS)) + .as("both registration threads should reach the start gate") + .isTrue(); + startLatch.countDown(); + + final AtomicInteger successCount = new AtomicInteger(); + final AtomicInteger alreadyExistCount = new AtomicInteger(); + final List unexpectedFailures = new ArrayList<>(); + final List persistedUsers = new ArrayList<>(); + + for (Future future : futures) { + final RegistrationOutcome outcome = collect(future); + if (outcome.user != null) { + successCount.incrementAndGet(); + persistedUsers.add(outcome.user); + } else if (outcome.error instanceof UserAlreadyExistException) { + alreadyExistCount.incrementAndGet(); + } else { + unexpectedFailures.add(outcome.error); + } + } + + assertThat(unexpectedFailures) + .as("neither thread should fail with a raw serialization/constraint exception (it must be " + + "translated to UserAlreadyExistException)") + .isEmpty(); + assertThat(successCount.get()) + .as("exactly one thread should successfully register the user") + .isEqualTo(1); + assertThat(alreadyExistCount.get()) + .as("the losing thread should fail with the handled UserAlreadyExistException") + .isEqualTo(1); + + final User found = userRepository.findByEmail(email.toLowerCase()); + assertThat(found) + .as("exactly one user row should exist for the raced email") + .isNotNull(); + + final long rowCount = userRepository.findAll().stream() + .filter(u -> email.toLowerCase().equals(u.getEmail())) + .count(); + assertThat(rowCount) + .as("the database must contain EXACTLY ONE user row for the raced email — two rows would mean " + + "SERIALIZABLE failed to prevent the duplicate") + .isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private Callable registrationTask(final String email, final CountDownLatch readyLatch, + final CountDownLatch startLatch) { + return () -> { + final UserDto dto = new UserDto(); + dto.setFirstName("Race"); + dto.setLastName("Condition"); + dto.setEmail(email); + dto.setPassword(VALID_PASSWORD); + dto.setMatchingPassword(VALID_PASSWORD); + + readyLatch.countDown(); + if (!startLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("start gate was never opened"); + } + + try { + return RegistrationOutcome.success(userService.registerNewUserAccount(dto)); + } catch (Throwable t) { + return RegistrationOutcome.failure(t); + } + }; + } + + private RegistrationOutcome collect(final Future future) { + try { + return future.get(60, TimeUnit.SECONDS); + } catch (ExecutionException e) { + // The task catches everything and returns an outcome, so a raw ExecutionException is itself unexpected. + return RegistrationOutcome.failure(e.getCause() != null ? e.getCause() : e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while collecting registration outcome", e); + } catch (Exception e) { + return RegistrationOutcome.failure(e); + } + } + + /** Captures the result of a single registration attempt: either the persisted user or the thrown error. */ + private static final class RegistrationOutcome { + private final User user; + private final Throwable error; + + private RegistrationOutcome(final User user, final Throwable error) { + this.user = user; + this.error = error; + } + + static RegistrationOutcome success(final User user) { + return new RegistrationOutcome(user, null); + } + + static RegistrationOutcome failure(final Throwable error) { + return new RegistrationOutcome(null, error); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java new file mode 100644 index 0000000..83c7223 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java @@ -0,0 +1,36 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent duplicate-registration race against a real MariaDB container. MariaDB/InnoDB implements + * SERIALIZABLE using next-key locks; a losing concurrent transaction sees either the unique-email constraint violation + * or a lock/serialization failure, both of which UserService.persistNewUserAccount translates to + * UserAlreadyExistException. + */ +@Testcontainers +@DisplayName("MariaDB Concurrent Registration Tests") +class MariaDBConcurrentRegistrationTest extends AbstractConcurrentRegistrationTest { + + @Container + static final MariaDBContainer MARIADB = new MariaDBContainer<>("mariadb:11.4") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MARIADB::getJdbcUrl); + registry.add("spring.datasource.username", MARIADB::getUsername); + registry.add("spring.datasource.password", MARIADB::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java new file mode 100644 index 0000000..4fd33e3 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java @@ -0,0 +1,35 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent duplicate-registration race against a real PostgreSQL container. PostgreSQL implements + * SERIALIZABLE via Serializable Snapshot Isolation (SSI), so a losing concurrent transaction is aborted with a + * serialization failure that UserService.persistNewUserAccount translates to UserAlreadyExistException. + */ +@Testcontainers +@DisplayName("PostgreSQL Concurrent Registration Tests") +class PostgreSQLConcurrentRegistrationTest extends AbstractConcurrentRegistrationTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } +} From d589c9167d1ba53cbe84520ec5d478bd7ea11fee Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 01:06:52 -0600 Subject: [PATCH 32/55] build: drop now-unused awaitility test dep (Task 9.5 used CountDownLatch) --- build.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/build.gradle b/build.gradle index b3e18c2..3ebae6b 100644 --- a/build.gradle +++ b/build.gradle @@ -94,8 +94,6 @@ dependencies { testImplementation 'org.testcontainers:testcontainers-postgresql:2.0.5' testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' testImplementation 'org.assertj:assertj-core:3.27.7' - // Awaitility is used by the Testcontainers concurrent-registration test - testImplementation 'org.awaitility:awaitility:4.3.0' // Legacy Jackson 2 (com.fasterxml.jackson) for test JSON utilities. Spring Boot 4 ships Jackson 3 // (tools.jackson), so the com.fasterxml.jackson APIs these tests use must be declared explicitly // rather than relied upon transitively. Version is managed by the Spring Boot BOM. From daafb8be58f1e88fe1c57e9a71df42aa7201f194 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:31 -0600 Subject: [PATCH 33/55] fix(security): make internal persist* methods package-private to close RegistrationGuard bypass persistNewUserAccount, persistChangedPassword, and persistInitialPassword were public, letting a consumer call them directly and skip the centralized RegistrationGuard enforced in registerNewUserAccount. They are now package-private; CGLIB self-invocation still applies the @Transactional boundary because Spring's proxy subclass is generated in the same package. Addresses review finding CRITICAL-2. --- .../spring/user/service/UserService.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 6863e07..84de757 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -371,8 +371,11 @@ public User registerNewUserAccount(final UserDto newUserDto) { * *

    * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST - * be invoked through the Spring proxy (via {@link #self}) so the transaction applies, and is not - * intended to be called directly by consumers. + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is + * deliberately package-private so consumers cannot call it directly and bypass the + * centralized RegistrationGuard enforced by {@link #registerNewUserAccount(UserDto)}; CGLIB + * self-invocation still applies the transaction because Spring's proxy subclass is generated in + * this same package. *

    * * @param user the fully built user entity (password already encoded) @@ -380,7 +383,7 @@ public User registerNewUserAccount(final UserDto newUserDto) { * @throws UserAlreadyExistException if an account with the same email already exists */ @Transactional(isolation = Isolation.SERIALIZABLE) - public User persistNewUserAccount(final User user) { + User persistNewUserAccount(final User user) { if (emailExists(user.getEmail())) { log.debug("UserService.persistNewUserAccount: email already exists: {}", user.getEmail()); throw new UserAlreadyExistException( @@ -733,15 +736,16 @@ public void changeUserPassword(final User user, final String password) { * *

    * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST - * be invoked through the Spring proxy (via {@link #self}) so the transaction applies, and is not - * intended to be called directly by consumers. + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is + * deliberately package-private so it is not part of the public API; CGLIB self-invocation + * still applies the transaction because Spring's proxy subclass is generated in this same package. *

    * * @param user the user whose password changed (password field already set/encoded) * @param encodedPassword the already-encoded password to record in history */ @Transactional - public void persistChangedPassword(final User user, final String encodedPassword) { + void persistChangedPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); // Terminate all existing sessions so a reset/change forces re-auth everywhere (OWASP). @@ -828,15 +832,16 @@ public void setInitialPassword(User user, String rawPassword) { * *

    * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST - * be invoked through the Spring proxy (via {@link #self}) so the transaction applies, and is not - * intended to be called directly by consumers. + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is + * deliberately package-private so it is not part of the public API; CGLIB self-invocation + * still applies the transaction because Spring's proxy subclass is generated in this same package. *

    * * @param user the user whose initial password is being set (password field already set) * @param encodedPassword the already-encoded password to record in history */ @Transactional - public void persistInitialPassword(final User user, final String encodedPassword) { + void persistInitialPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); } From ce7db08c62378e546e0f391125ca4a4398e89130 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:31 -0600 Subject: [PATCH 34/55] fix(security): atomically consume verification token on the valid path validateVerificationToken now runs in a single transaction that enables the user AND deletes the token, making the token strictly single-use and non-replayable (previously the controller deleted it in a separate, non-atomic step). The controller resolves the user before consuming (the token no longer resolves afterward) and drops its separate delete call. Javadoc corrected to match the now-accurate 'atomically consumes' claim. Addresses review finding CRITICAL-3. --- .../user/controller/UserActionController.java | 6 ++++-- .../user/service/UserVerificationService.java | 17 ++++++++++++++--- .../controller/UserActionControllerTest.java | 7 ++++--- .../service/UserVerificationServiceTest.java | 4 ++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java index 9ac6e92..6a97909 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java +++ b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java @@ -114,13 +114,15 @@ public ModelAndView confirmRegistration(final HttpServletRequest request, final log.debug("UserAPI.confirmRegistration: called with token: {}", tokenFingerprint(token)); Locale locale = request.getLocale(); model.addAttribute("lang", locale.getLanguage()); + // Resolve the user BEFORE validating: validateVerificationToken atomically consumes (deletes) the token, + // so a lookup by the same raw token afterward would no longer resolve. + final User user = userVerificationService.getUserByVerificationToken(token); final TokenValidationResult result = userVerificationService.validateVerificationToken(token); if (result == TokenValidationResult.VALID) { - final User user = userVerificationService.getUserByVerificationToken(token); if (user != null) { + // The token was already consumed (deleted) atomically inside validateVerificationToken. userService.authWithoutPassword(user); - userVerificationService.deleteVerificationToken(token); AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(user) .sessionId(request.getSession().getId()) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index 297d03c..e8e2192 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -145,12 +145,21 @@ public void createVerificationTokenForUser(final User user, final String token) } /** - * Validates a user verification token and, when valid, atomically consumes it by enabling the - * user. Uses dual-read so both hashed (post-upgrade) and plaintext (pre-upgrade) tokens resolve. + * Validates a user verification token and, when valid, atomically consumes it: the user is enabled and the + * token is deleted within a single transaction so the token is strictly single-use and cannot be replayed. + * Expired tokens are likewise deleted (as cleanup) and rejected. Uses dual-read so both hashed (post-upgrade) + * and plaintext (pre-upgrade) tokens resolve. * - * @param token the raw token to validate + *

    + * Because this method consumes the token, callers must obtain any needed {@link User} reference (e.g. via + * {@link #getUserByVerificationToken(String)}) before invoking it; a subsequent lookup by the same + * raw token will no longer resolve. + *

    + * + * @param token the raw token to validate and consume * @return the token validation result (VALID, INVALID_TOKEN, or EXPIRED) */ + @Transactional public UserService.TokenValidationResult validateVerificationToken(String token) { final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken == null) { @@ -166,6 +175,8 @@ public UserService.TokenValidationResult validateVerificationToken(String token) user.setEnabled(true); userRepository.save(user); + // Consume the token in the same transaction so it is single-use and cannot be replayed. + tokenRepository.delete(verificationToken); return UserService.TokenValidationResult.VALID; } diff --git a/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java b/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java index 5bc680d..091a6fc 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java @@ -163,10 +163,11 @@ void confirmRegistration_validToken_confirmsAndAuthenticatesUser() throws Except .andExpect(redirectedUrl("/user/registration-complete.html?lang=en&message=Account+verified+successfully")) .andExpect(model().attribute("message", "Account verified successfully")); - // Verify interactions + // Verify interactions. The token is consumed atomically inside validateVerificationToken, so the + // controller no longer issues a separate deleteVerificationToken call. verify(userService).authWithoutPassword(testUser); - verify(userVerificationService).deleteVerificationToken(token); - + verify(userVerificationService, never()).deleteVerificationToken(anyString()); + // Verify audit event ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditEvent.class); verify(eventPublisher).publishEvent(auditCaptor.capture()); diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java index 2b27194..2b0ef4c 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java @@ -57,6 +57,10 @@ void validateVerificationToken_returnsValidIfTokenValid() { when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString()); Assertions.assertEquals(result, UserService.TokenValidationResult.VALID); + // The user is enabled and the token is consumed (deleted) atomically so it is strictly single-use. + Assertions.assertTrue(testUser.isEnabled()); + Mockito.verify(userRepository).save(testUser); + Mockito.verify(verificationTokenRepository).delete(testToken); } @Test From a5d8a92519b9a60b6a2b312649fb905b8cf64bf8 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:31 -0600 Subject: [PATCH 35/55] fix(security): reject Facebook OAuth2 login when email is explicitly unverified getUserFromFacebookOAuth2User now applies the same explicit-unverified check used on the Google path, covering both Facebook's 'email_verified' (newer Graph API) and 'verified' (older) claims. Absent claims remain trusted; only an explicit false is rejected. Addresses review finding HIGH-1. --- .../spring/user/service/DSOAuth2UserService.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index 0c9c905..3cd10f5 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -231,6 +231,15 @@ public User getUserFromFacebookOAuth2User(OAuth2User principal) { return null; } log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); + // Reject the login if Facebook explicitly reports the email as NOT verified. Facebook exposes this + // under "email_verified" on newer Graph API versions and "verified" on older ones; either explicit + // false rejects. Providers that do not expose the claim at all are trusted (only an explicit false + // is rejected), matching the Google path's policy in getUserFromGoogleOAuth2User. + if (isExplicitlyUnverified(principal.getAttribute("email_verified")) || isExplicitlyUnverified(principal.getAttribute("verified"))) { + log.warn("getUserFromFacebookOAuth2User: rejecting login because Facebook reports the email as not verified"); + throw new OAuth2AuthenticationException(new OAuth2Error("email_not_verified"), + "Your email address is not verified with your login provider."); + } User user = new User(); String email = principal.getAttribute("email"); user.setEmail(email != null ? email.toLowerCase() : null); From 5eecb0a6f9422c9c17bbbaf4ce1f1daa50307c83 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:43 -0600 Subject: [PATCH 36/55] fix(security): move unconditional security beans to auto-config; relocate @EnableWebSecurity methodSecurityExpressionHandler, httpSessionEventPublisher, and authenticationEventPublisher were @Bean methods on the component-scanned WebSecurityConfig with no @ConditionalOnMissingBean, so a consumer-defined bean of the same type caused an override conflict. They move to UserSecurityBeansAutoConfiguration (each now guarded by @ConditionalOnMissingBean), consistent with the codebase principle that @ConditionalOnMissingBean is only reliable on auto-configuration classes. @EnableWebSecurity moves from WebSecurityConfig to WebSecurityFilterChainAutoConfiguration, where the filter chain is actually defined. The auto-config Javadoc now warns prominently that its type-based @ConditionalOnMissingBean back-off is all-or-nothing: ANY consumer SecurityFilterChain bean (even a narrow one) suppresses the library chain entirely. Addresses review findings HIGH-3, HIGH-4, and MEDIUM-1. --- .../UserSecurityBeansAutoConfiguration.java | 50 +++++++++++++++++++ .../user/security/WebSecurityConfig.java | 43 ---------------- ...bSecurityFilterChainAutoConfiguration.java | 16 +++++- 3 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java index 0a72906..34c506a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -3,15 +3,21 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.AuthenticationEventPublisher; +import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.session.HttpSessionEventPublisher; import com.digitalsanctuary.spring.user.UserConfiguration; import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; import lombok.RequiredArgsConstructor; @@ -116,4 +122,48 @@ public DaoAuthenticationProvider authProvider(PasswordEncoder passwordEncoder) { authProvider.setPasswordEncoder(passwordEncoder); return authProvider; } + + /** + * Creates a {@link MethodSecurityExpressionHandler} wired with the configured {@link RoleHierarchy} so method + * security annotations (e.g. {@code @PreAuthorize}) honor the role hierarchy. Declared {@code static} so it is + * available to the method-security infrastructure during early initialization. Backs off entirely if the + * consuming application defines its own {@link MethodSecurityExpressionHandler}. + * + * @param roleHierarchy the effective {@link RoleHierarchy} (may be {@code null} when none is configured) + * @return the configured {@link MethodSecurityExpressionHandler} + */ + @Bean + @ConditionalOnMissingBean(MethodSecurityExpressionHandler.class) + static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); + expressionHandler.setRoleHierarchy(roleHierarchy); + return expressionHandler; + } + + /** + * Creates the {@link HttpSessionEventPublisher} that bridges servlet {@code HttpSession} lifecycle events into + * the Spring event system (required for {@link SessionRegistry}-based concurrent-session tracking). Backs off + * entirely if the consuming application defines its own {@link HttpSessionEventPublisher}. + * + * @return the {@link HttpSessionEventPublisher} + */ + @Bean + @ConditionalOnMissingBean(HttpSessionEventPublisher.class) + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + + /** + * Publishes Spring Security authentication events to the application event system so listeners can react to + * successful/failed authentication. Backs off entirely if the consuming application defines its own + * {@link AuthenticationEventPublisher}. + * + * @param applicationEventPublisher the Spring {@link ApplicationEventPublisher} + * @return the default {@link AuthenticationEventPublisher} + */ + @Bean + @ConditionalOnMissingBean(AuthenticationEventPublisher.class) + public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + return new DefaultAuthenticationEventPublisher(applicationEventPublisher); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 10793db..ee7fa4b 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -12,21 +12,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; -import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; -import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; -import org.springframework.security.access.hierarchicalroles.RoleHierarchy; -import org.springframework.security.authentication.AuthenticationEventPublisher; -import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; -import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; import com.digitalsanctuary.spring.user.service.DSOAuth2UserService; import com.digitalsanctuary.spring.user.service.DSOidcUserService; @@ -47,7 +40,6 @@ @EqualsAndHashCode(callSuper = false) @Configuration @RequiredArgsConstructor -@EnableWebSecurity public class WebSecurityConfig { @@ -331,41 +323,6 @@ private List getUnprotectedURIsList() { return unprotectedURIs; } - /** - * The methodSecurityExpressionHandler method creates a MethodSecurityExpressionHandler object and sets the roleHierarchy for the handler. This - * ensures that method security annotations like @PreAuthorize use the configured role hierarchy. - * - * @return the MethodSecurityExpressionHandler object - */ - @Bean - static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { - DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); - expressionHandler.setRoleHierarchy(roleHierarchy); - return expressionHandler; - } - - /** - * The httpSessionEventPublisher method creates an HttpSessionEventPublisher object. - * - * @return the HttpSessionEventPublisher object - */ - @Bean - public HttpSessionEventPublisher httpSessionEventPublisher() { - return new HttpSessionEventPublisher(); - } - - /** - * This is required to publish authentication events to the Spring event system. This allows us to listen for authentication events and perform - * actions based on successful or failed authentication. - * - * @param applicationEventPublisher the Spring ApplicationEventPublisher - * @return the Spring Security default AuthenticationEventPublisher - */ - @Bean - public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { - return new DefaultAuthenticationEventPublisher(applicationEventPublisher); - } - /** * Helper method to split comma-separated property values and filter out empty strings. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java index 3238cb3..409a45e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.web.SecurityFilterChain; import com.digitalsanctuary.spring.user.UserConfiguration; @@ -24,9 +25,19 @@ *
  • Fully replace it by defining their own {@link SecurityFilterChain} bean — in which case the library's chain is suppressed entirely * and the consumer owns all security rules, including the library's protected URIs.
  • *
+ * + *

+ * WARNING — this back-off is all-or-nothing. The {@link ConditionalOnMissingBean} is keyed on the {@link SecurityFilterChain} + * type, so defining any {@link SecurityFilterChain} bean — even a narrow, single-purpose one (e.g. an actuator-only or + * API-only chain) — suppresses the library's entire chain (form login, logout, CSRF, session management, WebAuthn, OAuth2). There is no partial + * coexistence: a consumer who defines their own chain owns all security configuration, including every URI the library would otherwise + * protect. If you intend only to add rules for a subset of requests, you must reproduce the library's protections in your own chain rather than rely + * on both being active. + *

*

- * A consumer that wants to layer additional rules in front of the library's chain can define their own chain with a higher-precedence (lower) - * {@code @Order} value; that chain is then consulted first by Spring Security's {@code FilterChainProxy}. + * The intended pattern for layering is to not define a {@link SecurityFilterChain} at all, and instead customize via the library's + * {@code user.security.*} properties and the documented extension beans. (Spring Security's multi-chain {@code @Order} layering does not apply here, + * because the library backs off entirely as soon as a second chain bean exists.) *

* *

@@ -37,6 +48,7 @@ *

*/ @Slf4j +@EnableWebSecurity @AutoConfiguration(after = UserConfiguration.class) @RequiredArgsConstructor public class WebSecurityFilterChainAutoConfiguration { From 1a9da5e99c1c76315f2d2245a9eb660baeafc4df Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:43 -0600 Subject: [PATCH 37/55] refactor(security): isolate MFA filter-merging post-processor; document scope; add tests The static BeanPostProcessor that calls setMfaEnabled(true) moves out of the dependency-rich, event-listening MfaConfiguration into a new dependency-free MfaFilterMergingConfiguration, so that declaring a BeanPostProcessor no longer forces MfaConfiguration to be instantiated early. Its Javadoc now warns prominently that, when MFA is enabled, the flag is applied to ALL AbstractAuthenticationProcessingFilter beans including consumer-defined filters. Adds previously missing coverage: the post-processor sets the flag on processing filters, leaves other beans untouched, and is gated correctly on user.mfa.enabled. Addresses review findings HIGH-2, HIGH-6, and MEDIUM-2. --- .../user/security/MfaConfiguration.java | 46 ++---------- .../MfaFilterMergingConfiguration.java | 74 +++++++++++++++++++ .../MfaFilterMergingConfigurationTest.java | 57 ++++++++++++++ 3 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java index e226770..94487b5 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java @@ -3,7 +3,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -57,12 +56,15 @@ *

*

* Therefore we keep our single property-driven enforcement factory and activate ONLY the merging side using public - * SS7 API: {@link #mfaFilterMergingPostProcessor()} replicates {@code EnableMfaFiltersPostProcessor} by invoking the - * public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on the form-login and WebAuthn - * processing filters. This is gated on {@code user.mfa.enabled=true}, leaving the default (no-MFA) path untouched. + * SS7 API. The merging post-processor lives in {@link MfaFilterMergingConfiguration} (a separate, dependency-free + * configuration) so that declaring a {@code BeanPostProcessor} does not force this dependency-rich, event-listening + * configuration to be instantiated early. It replicates {@code EnableMfaFiltersPostProcessor} by invoking the public + * {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on the authentication processing filters, gated + * on {@code user.mfa.enabled=true}, leaving the default (no-MFA) path untouched. *

* * @see MfaConfigProperties + * @see MfaFilterMergingConfiguration * @see WebSecurityConfig */ @Slf4j @@ -115,42 +117,6 @@ public DefaultAuthorizationManagerFactory mfaAuthorizationManagerFactory return factory; } - /** - * Activates Spring Security 7 factor merging by enabling MFA mode on every authentication processing filter. - *

- * This replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal - * {@code EnableMfaFiltersPostProcessor} using only public API. When MFA mode is enabled, - * {@link AbstractAuthenticationProcessingFilter} merges the factor authorities of the existing authentication onto - * the authentication produced by a subsequent login step (for the same principal) instead of replacing it. Without - * this, completing a second factor would drop the first factor's authority and the user could never satisfy all - * required factors (H4 lockout). - *

- *

- * The bean is only created when MFA is enabled, so the default (no-MFA) login path is completely unaffected. - *

- * - * @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters - */ - @Bean - @ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false) - public static BeanPostProcessor mfaFilterMergingPostProcessor() { - return new BeanPostProcessor() { - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) { - // Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every - // authentication mechanism this framework configures: formLogin, webAuthn, and oauth2Login - // all extend it. SS's internal EnableMfaFiltersPostProcessor additionally flips the flag on - // AuthenticationFilter, BasicAuthenticationFilter, and pre-authentication filters; this - // framework does not configure those mechanisms, so they are deliberately not targeted here. - if (bean instanceof AbstractAuthenticationProcessingFilter filter) { - filter.setMfaEnabled(true); - log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName()); - } - return bean; - } - }; - } - /** * Validates MFA configuration on application startup. Performs validation only when MFA is enabled; returns * immediately otherwise. diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java new file mode 100644 index 0000000..9601224 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java @@ -0,0 +1,74 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import lombok.extern.slf4j.Slf4j; + +/** + * Activates Spring Security 7 factor merging by enabling MFA mode on authentication processing filters. This is the + * "merging" half of multi-factor login described in {@link MfaConfiguration}; it is isolated here, in a small + * configuration with no constructor-injected dependencies and no {@code @EventListener} methods, on purpose. + * + *

+ * The merging behaviour is provided by a {@code static} {@link BeanPostProcessor} {@code @Bean}. A {@code BeanPostProcessor}- + * declaring class is instantiated very early in the context lifecycle (before the regular bean-instantiation phase). Keeping + * that declaration on the dependency-rich {@link MfaConfiguration} would force that class to be instantiated early + * too — which can emit the "not eligible for getting processed by all BeanPostProcessors" warning and interfere with + * its {@code @EventListener} registration. Housing the post-processor in this dependency-free class avoids that entirely. + *

+ * + *

+ * WARNING — scope of {@code setMfaEnabled(true)}. When {@code user.mfa.enabled=true}, the post-processor flips + * MFA mode on every {@link AbstractAuthenticationProcessingFilter} bean in the application context. That includes + * any filter a consuming application defines that extends this base class (e.g. a custom JWT or API-key + * authentication filter). Such a filter will then also perform SS7 factor merging: on a subsequent authentication for an + * already-authenticated principal it rebuilds the result via {@code authenticationResult.toBuilder()...}. If that filter's + * {@link org.springframework.security.core.Authentication} implementation does not support {@code toBuilder()}, the merge can + * throw at runtime. Consumers enabling MFA who also register custom processing filters should be aware their filters are + * affected. (This mirrors the framework default: it is only active when MFA is explicitly enabled.) + *

+ * + * @see MfaConfiguration + * @see AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean) + */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false) +public class MfaFilterMergingConfiguration { + + /** + * Replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal {@code EnableMfaFiltersPostProcessor} + * using only public API, by invoking the public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on + * every authentication processing filter. Without this, completing a second factor would REPLACE the first factor's + * authentication (dropping its authority) and the user could never satisfy all required factors (the H4 lockout). + * + *

+ * Declared {@code static} so the post-processor can be registered without eagerly instantiating this configuration + * class. The bean exists only when {@code user.mfa.enabled=true}, so the default (no-MFA) login path is unaffected. + *

+ * + * @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters + */ + @Bean + public static BeanPostProcessor mfaFilterMergingPostProcessor() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + // Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every authentication + // mechanism this framework configures: formLogin, webAuthn, and oauth2Login all extend it. SS's internal + // EnableMfaFiltersPostProcessor additionally flips the flag on AuthenticationFilter, + // BasicAuthenticationFilter, and pre-authentication filters; this framework does not configure those + // mechanisms, so they are deliberately not targeted here. See the class-level WARNING regarding + // consumer-defined filters that extend this base class. + if (bean instanceof AbstractAuthenticationProcessingFilter filter) { + filter.setMfaEnabled(true); + log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName()); + } + return bean; + } + }; + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java new file mode 100644 index 0000000..0993575 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java @@ -0,0 +1,57 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; + +/** + * Tests for {@link MfaFilterMergingConfiguration}. + *

+ * Guards the most safety-critical MFA runtime behaviour: that {@code setMfaEnabled(true)} is applied to authentication + * processing filters (without which a second factor REPLACES the first and the user can never satisfy all required + * factors — the H4 lockout), and that this behaviour is correctly gated behind {@code user.mfa.enabled=true}. A + * Spring Boot upgrade that changed {@code BeanPostProcessor} ordering, or a regression in the gating, would surface here. + *

+ */ +@DisplayName("MfaFilterMergingConfiguration Tests") +class MfaFilterMergingConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(MfaFilterMergingConfiguration.class); + + @Test + @DisplayName("Post-processor enables MFA mode on authentication processing filters") + void shouldEnableMfaOnProcessingFilter() { + BeanPostProcessor postProcessor = MfaFilterMergingConfiguration.mfaFilterMergingPostProcessor(); + AbstractAuthenticationProcessingFilter filter = mock(AbstractAuthenticationProcessingFilter.class); + + Object result = postProcessor.postProcessAfterInitialization(filter, "someAuthenticationFilter"); + + assertThat(result).isSameAs(filter); + verify(filter).setMfaEnabled(true); + } + + @Test + @DisplayName("Post-processor leaves non-filter beans untouched") + void shouldLeaveNonFilterBeansUntouched() { + BeanPostProcessor postProcessor = MfaFilterMergingConfiguration.mfaFilterMergingPostProcessor(); + Object other = new Object(); + + assertThat(postProcessor.postProcessAfterInitialization(other, "someOtherBean")).isSameAs(other); + } + + @Test + @DisplayName("Post-processor bean is registered ONLY when user.mfa.enabled=true") + void shouldGatePostProcessorOnProperty() { + runner.withPropertyValues("user.mfa.enabled=true") + .run(context -> assertThat(context).hasNotFailed().hasBean("mfaFilterMergingPostProcessor")); + runner.withPropertyValues("user.mfa.enabled=false") + .run(context -> assertThat(context).hasNotFailed().doesNotHaveBean("mfaFilterMergingPostProcessor")); + runner.run(context -> assertThat(context).hasNotFailed().doesNotHaveBean("mfaFilterMergingPostProcessor")); + } +} From 10b7cdae1d4765336e35f7d8592ebc948f7b0dda Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:54 -0600 Subject: [PATCH 38/55] fix(security): avoid logging PII in OAuth2 failure handler; expose attribute constant onAuthenticationFailure no longer logs the raw exception message (which can embed an account email for Locked/Disabled exceptions, or a guard-specific reason for registration_denied) into the ERROR stream. Expected failures now log at WARN with only a non-sensitive summary (exception type plus the OAuth2 error code); unexpected failures log at ERROR without the message. Full detail remains at DEBUG. ERROR_MESSAGE_SESSION_ATTRIBUTE is now public so the test references the constant instead of duplicating the literal. Addresses review findings HIGH-5 and MEDIUM-5. --- ...ingOAuth2AuthenticationFailureHandler.java | 43 +++++++++++++++++-- ...Auth2AuthenticationFailureHandlerTest.java | 2 +- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java index 3d6449f..e75dee1 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.security; import java.io.IOException; +import org.springframework.security.authentication.AccountStatusException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -29,7 +30,7 @@ public class SanitizingOAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { /** Session attribute key the login page reads to display an error message. */ - static final String ERROR_MESSAGE_SESSION_ATTRIBUTE = "error.message"; + public static final String ERROR_MESSAGE_SESSION_ATTRIBUTE = "error.message"; /** OAuth2 error code raised when a provider explicitly reports the email is not verified. */ static final String EMAIL_NOT_VERIFIED_ERROR_CODE = "email_not_verified"; @@ -56,8 +57,17 @@ public SanitizingOAuth2AuthenticationFailureHandler(String loginPageURI) { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - // Log the real detail server-side only. Server logs are an acceptable place for sensitive detail. - log.error("OAuth2 login failure: {}", exception.getMessage()); + // Avoid placing potentially-sensitive raw exception messages into the ERROR/WARN log streams that + // commonly feed SIEM/centralized logging. LockedException/DisabledException messages can embed the + // account email, and a registration_denied error can carry a guard-specific reason. So we log only a + // NON-sensitive summary (exception type plus, for OAuth2 failures, the error code) at WARN for the + // expected failure categories, reserving ERROR for genuinely unexpected failures. The full exception + // (including its message and stack trace) remains available at DEBUG for operators who opt in. + if (isExpectedFailure(exception)) { + log.warn("OAuth2 login failure ({}): {}", exception.getClass().getSimpleName(), nonSensitiveDetail(exception)); + } else { + log.error("Unexpected OAuth2 login failure ({})", exception.getClass().getSimpleName()); + } log.debug("OAuth2 login failure detail", exception); // Store ONLY a generic, non-sensitive message for the UI. Never the raw exception message. @@ -65,6 +75,33 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo response.sendRedirect(loginPageURI); } + /** + * Identifies authentication failures that are expected during normal operation (a locked/disabled account, a + * provider-conflict, an unverified email, or a denied registration) and therefore warrant only a WARN-level, + * non-sensitive log line rather than an ERROR. + * + * @param exception the authentication failure + * @return {@code true} if this is an expected failure category + */ + private boolean isExpectedFailure(AuthenticationException exception) { + return exception instanceof AccountStatusException || exception instanceof OAuth2AuthenticationException; + } + + /** + * Produces a non-sensitive description of the failure for logging. For OAuth2 failures this is the error code + * (a fixed identifier such as {@code email_not_verified} or {@code registration_denied}); the error description + * and the raw exception message are intentionally excluded because they can contain PII (e.g. an account email). + * + * @param exception the authentication failure + * @return a non-sensitive, log-safe description + */ + private String nonSensitiveDetail(AuthenticationException exception) { + if (exception instanceof OAuth2AuthenticationException oauth2Exception && oauth2Exception.getError() != null) { + return "error=" + oauth2Exception.getError().getErrorCode(); + } + return "see DEBUG log for detail"; + } + /** * Maps an authentication failure to a safe, user-facing message. A small number of failure categories map to * a slightly more helpful (but still non-sensitive) message; everything else falls back to a fixed generic diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java index 1ff15f2..312de22 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java @@ -19,7 +19,7 @@ class SanitizingOAuth2AuthenticationFailureHandlerTest { private static final String LOGIN_PAGE_URI = "/user/login.html"; - private static final String SESSION_ATTRIBUTE = "error.message"; + private static final String SESSION_ATTRIBUTE = SanitizingOAuth2AuthenticationFailureHandler.ERROR_MESSAGE_SESSION_ATTRIBUTE; private SanitizingOAuth2AuthenticationFailureHandler handler; From 9845cbc5fae71d276f74bdc8732d26e52ce82025 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:54 -0600 Subject: [PATCH 39/55] test: convert LoginAttemptServiceTest to AssertJ per project convention Addresses review finding MEDIUM-3. --- .../user/service/LoginAttemptServiceTest.java | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java index e66750b..fb7fa75 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java @@ -1,10 +1,6 @@ package com.digitalsanctuary.spring.user.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -52,9 +48,9 @@ void loginSucceeded_resetsFailedAttempts() { loginAttemptService.loginSucceeded(testUser.getEmail()); - assertEquals(0, testUser.getFailedLoginAttempts()); - assertFalse(testUser.isLocked()); - assertNull(testUser.getLockedDate()); + assertThat(testUser.getFailedLoginAttempts()).isZero(); + assertThat(testUser.isLocked()).isFalse(); + assertThat(testUser.getLockedDate()).isNull(); verify(userRepository).save(testUser); } @@ -70,8 +66,8 @@ void loginFailed_callsAtomicIncrementAndLocksAtThreshold() { verify(userRepository).incrementFailedAttempts(testUser.getEmail()); verify(userRepository).findByEmail(testUser.getEmail()); - assertTrue(testUser.isLocked()); - assertNotNull(testUser.getLockedDate()); + assertThat(testUser.isLocked()).isTrue(); + assertThat(testUser.getLockedDate()).isNotNull(); verify(userRepository).save(testUser); } @@ -85,8 +81,8 @@ void loginFailed_doesNotLockBelowThreshold() { loginAttemptService.loginFailed(testUser.getEmail()); verify(userRepository).incrementFailedAttempts(testUser.getEmail()); - assertFalse(testUser.isLocked()); - assertNull(testUser.getLockedDate()); + assertThat(testUser.isLocked()).isFalse(); + assertThat(testUser.getLockedDate()).isNull(); verify(userRepository, never()).save(testUser); } @@ -121,14 +117,14 @@ void isLocked_returnsTrueWhenUserIsLocked() { when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertTrue(loginAttemptService.isLocked(testUser.getEmail())); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isTrue(); } @Test void isLocked_returnsFalseWhenUserIsNotLocked() { when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertFalse(loginAttemptService.isLocked(testUser.getEmail())); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isFalse(); } @Test @@ -139,10 +135,10 @@ void isLocked_unlocksUserAfterLockoutDuration() { when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertFalse(loginAttemptService.isLocked(testUser.getEmail())); - assertFalse(testUser.isLocked()); - assertNull(testUser.getLockedDate()); - assertEquals(0, testUser.getFailedLoginAttempts()); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isFalse(); + assertThat(testUser.isLocked()).isFalse(); + assertThat(testUser.getLockedDate()).isNull(); + assertThat(testUser.getFailedLoginAttempts()).isZero(); verify(userRepository).save(testUser); } @@ -156,8 +152,8 @@ void checkIfUserShouldBeUnlocked_adminOnlyUnlockKeepsLockedDespitePastLockedDate User result = loginAttemptService.checkIfUserShouldBeUnlocked(testUser); - assertTrue(result.isLocked()); - assertNotNull(result.getLockedDate()); + assertThat(result.isLocked()).isTrue(); + assertThat(result.getLockedDate()).isNotNull(); // No auto-unlock occurred, so nothing should have been persisted. verify(userRepository, never()).save(testUser); } @@ -170,8 +166,8 @@ void isLocked_adminOnlyUnlockKeepsUserLockedDespitePastLockedDate() { testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertTrue(loginAttemptService.isLocked(testUser.getEmail())); - assertTrue(testUser.isLocked()); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isTrue(); + assertThat(testUser.isLocked()).isTrue(); verify(userRepository, never()).save(testUser); } From b49b3f5de8023d26d523ad4202db264e5af58d62 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 10:26:54 -0600 Subject: [PATCH 40/55] test: cover expired-token cleanup path of validateAndConsumePasswordResetToken Asserts an expired token returns null AND is deleted as cleanup. Addresses review finding MEDIUM-4. --- .../user/service/TokenHashingSecurityTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java index 44762a3..3dd4859 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -207,6 +207,24 @@ void shouldFailWhenReusingConsumedToken() { User second = userService.validateAndConsumePasswordResetToken(rawToken); assertThat(second).isNull(); } + + @Test + @DisplayName("(h) consuming an EXPIRED token returns null AND deletes it (cleanup)") + void shouldRejectAndCleanUpExpiredTokenOnConsume() { + String rawToken = "expired-consume-me"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken expired = new PasswordResetToken(); + expired.setToken(hashed); + expired.setUser(testUser); + expired.setExpiryDate(past(60)); + when(passwordTokenRepository.findByToken(hashed)).thenReturn(expired); + + User result = userService.validateAndConsumePasswordResetToken(rawToken); + + assertThat(result).isNull(); + // The expired token is cleaned up (deleted) even though consumption is rejected. + verify(passwordTokenRepository).delete(expired); + } } // --------------------------------------------------------------------------------------------- From b9ce58eb6246c9350ef4b05fc1178030e9387d5f Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 11:30:15 -0600 Subject: [PATCH 41/55] fix(docs): make the Javadoc jar build (release blocker) Two pre-existing issues on this branch broke './gradlew javadoc' (and thus publishLocal and the Maven Central release, both of which build the Javadoc jar): - @apiNote/@implSpec/@implNote were reported as 'unknown tag'. Registered them as standard doc tags in the Javadoc options. - {@link VerificationToken#getPlainToken()} could not resolve because getPlainToken() is Lombok- generated (@Data) and invisible to the source-based Javadoc tool. Switched to {@code ...}. --- build.gradle | 7 +++++++ .../spring/user/service/UserVerificationService.java | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 3ebae6b..12c5047 100644 --- a/build.gradle +++ b/build.gradle @@ -139,6 +139,13 @@ tasks.named('jar') { // This is necessary because Lombok generates constructors that cannot be documented tasks.withType(Javadoc).configureEach { options.addStringOption('Xdoclint:all,-missing', '-quiet') + // Register the standard JDK documentation tags so @apiNote/@implSpec/@implNote are recognized + // by the doclet (otherwise they are reported as "unknown tag" errors and fail the Javadoc jar). + options.tags = [ + 'apiNote:a:API Note:', + 'implSpec:a:Implementation Requirements:', + 'implNote:a:Implementation Note:' + ] } def registerJdkTestTask(name, jdkVersion) { diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index e8e2192..625046a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -102,7 +102,7 @@ public VerificationToken getVerificationToken(final String verificationToken) { *

* A fresh high-entropy raw token is generated. Only its hash is persisted in the * {@code token} column (consistent with {@link #createVerificationTokenForUser}); the raw value - * is returned to the caller via {@link VerificationToken#getPlainToken()} so a verification email + * is returned to the caller via {@code VerificationToken.getPlainToken()} so a verification email * link can be built. The expiry is set from the configurable * {@code user.registration.verificationTokenValidityMinutes} (not a hardcoded 24h). The existing * row is updated in place, preserving the single-active-token invariant. @@ -110,7 +110,7 @@ public VerificationToken getVerificationToken(final String verificationToken) { * * @param existingVerificationToken the existing verification token string to replace * @return the updated verification token entity. Its persisted {@code token} is the hash; the raw - * value is available via {@link VerificationToken#getPlainToken()}. + * value is available via {@code VerificationToken.getPlainToken()}. */ @Transactional public VerificationToken generateNewVerificationToken(final String existingVerificationToken) { From 209ee863b64ed18c306fcb3c83cccb9e9efe962b Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 12:12:04 -0600 Subject: [PATCH 42/55] fix(security): make library SecurityFilterChain coexist with additional consumer chains (HIGH-3) The library's SecurityFilterChain backed off via @ConditionalOnMissingBean(SecurityFilterChain.class) - a TYPE-based condition. That suppressed the ENTIRE library chain whenever a consumer defined any SecurityFilterChain, even a narrow one (e.g. an actuator- or test-API-only chain at a higher @Order). The result: the library's form login, CSRF, session management, WebAuthn and OAuth2 config silently vanished and the library's URIs were left unprotected. This is a regression surfaced by running the demo app under its 'playwright-test' profile, which defines a narrow /api/test/** chain: the library chain was suppressed, /user/** became unprotected (500s on protected pages) and form login broke, failing ~70 Playwright tests. Switch to @ConditionalOnMissingBean(name = "securityFilterChain"). The library chain now coexists with additional, differently-named consumer chains (standard Spring Security multi-chain @Order layering), while a consumer can still fully replace it by naming their bean 'securityFilterChain'. Rewrites WebSecurityConfigCompositionTest to assert coexistence (the regression guard) and the named-replacement back-off, replacing the prior test that wrongly encoded the type-based behavior. Validated end-to-end: demo app now builds both chains, protected pages redirect to login, and the Playwright suite goes from ~70 failures to 102/103 passing. --- ...bSecurityFilterChainAutoConfiguration.java | 31 +++-- .../WebSecurityConfigCompositionTest.java | 114 ++++++++++++------ 2 files changed, 89 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java index 409a45e..de6e62f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java @@ -17,27 +17,24 @@ * Auto-configuration that contributes the library's {@link SecurityFilterChain}. * *

- * The chain is contributed at a low precedence ({@link #SECURITY_FILTER_CHAIN_ORDER}) and backs off entirely via {@link ConditionalOnMissingBean} - * when the consuming application defines its own {@link SecurityFilterChain}. This lets consumers either: + * The chain is contributed at a low precedence ({@link #SECURITY_FILTER_CHAIN_ORDER}) so it acts as the catch-all chain. The back-off is keyed on the + * bean name {@code securityFilterChain} (via {@link ConditionalOnMissingBean}), which supports two distinct consumer scenarios: *

*
    - *
  • Rely on the library's chain (the default), or
  • - *
  • Fully replace it by defining their own {@link SecurityFilterChain} bean — in which case the library's chain is suppressed entirely - * and the consumer owns all security rules, including the library's protected URIs.
  • + *
  • Add additional, narrower chains alongside the library's (the common case). A consumer can define one or more extra + * {@link SecurityFilterChain} beans with their own {@code @Order} and {@code securityMatcher} (e.g. an actuator-only or API-only chain). Because the + * conditional is name-based, those differently-named chains do not suppress the library chain — both coexist, and Spring Security's + * {@code FilterChainProxy} consults them in {@code @Order}. The narrower chain (higher precedence / lower order) handles its matched requests; the + * library chain remains the catch-all for everything else (form login, logout, CSRF, session management, WebAuthn, OAuth2).
  • + *
  • Fully replace the library's chain by defining a {@link SecurityFilterChain} bean named exactly {@code securityFilterChain}. That single + * named bean suppresses the library's chain, and the consumer then owns all security rules, including the library's protected URIs.
  • *
* *

- * WARNING — this back-off is all-or-nothing. The {@link ConditionalOnMissingBean} is keyed on the {@link SecurityFilterChain} - * type, so defining any {@link SecurityFilterChain} bean — even a narrow, single-purpose one (e.g. an actuator-only or - * API-only chain) — suppresses the library's entire chain (form login, logout, CSRF, session management, WebAuthn, OAuth2). There is no partial - * coexistence: a consumer who defines their own chain owns all security configuration, including every URI the library would otherwise - * protect. If you intend only to add rules for a subset of requests, you must reproduce the library's protections in your own chain rather than rely - * on both being active. - *

- *

- * The intended pattern for layering is to not define a {@link SecurityFilterChain} at all, and instead customize via the library's - * {@code user.security.*} properties and the documented extension beans. (Spring Security's multi-chain {@code @Order} layering does not apply here, - * because the library backs off entirely as soon as a second chain bean exists.) + * Why name-based rather than type-based: a type-based {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} would back off as soon as the + * consumer defined any chain — even a narrow one — silently suppressing the entire library chain and leaving the library's URIs + * unprotected. Keying on the {@code securityFilterChain} bean name preserves the standard Spring Security multi-chain {@code @Order} layering pattern, + * while still giving consumers a clear, explicit way to opt into a full replacement (name your replacement bean {@code securityFilterChain}). *

* *

@@ -75,7 +72,7 @@ public class WebSecurityFilterChainAutoConfiguration { */ @Bean @Order(SECURITY_FILTER_CHAIN_ORDER) - @ConditionalOnMissingBean(SecurityFilterChain.class) + @ConditionalOnMissingBean(name = "securityFilterChain") public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { log.debug("WebSecurityFilterChainAutoConfiguration: contributing library SecurityFilterChain at order {}", SECURITY_FILTER_CHAIN_ORDER); return webSecurityConfig.buildSecurityFilterChain(http, sessionRegistry); diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java index 9aa64d2..96b06bc 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java @@ -18,25 +18,24 @@ /** * Tests that the library's {@link SecurityFilterChain} bean is composable: it is contributed at a low precedence - * ({@link SecurityFilterProperties#BASIC_AUTH_ORDER}) and backs off entirely (via {@link ConditionalOnMissingBean}) when a consuming application defines its own - * {@link SecurityFilterChain}. + * ({@link SecurityFilterProperties#BASIC_AUTH_ORDER}) as the catch-all chain, and its back-off is keyed on the bean + * name {@code securityFilterChain} (via {@link ConditionalOnMissingBean}). *

- * The chain is contributed by {@link WebSecurityFilterChainAutoConfiguration} (an auto-configuration), which delegates construction to - * {@link WebSecurityConfig#buildSecurityFilterChain}. It lives on an auto-configuration class — rather than directly as a {@code @Bean} on the - * component-scanned {@link WebSecurityConfig} — precisely because {@code @ConditionalOnMissingBean} is only reliable on auto-configuration - * classes (which load after user-defined bean definitions). + * This is deliberately name-based, not type-based. A type-based + * {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} would suppress the entire library chain the moment a + * consumer defined any {@link SecurityFilterChain} — even a narrow, single-purpose one (e.g. a test-API or + * actuator chain). That breaks the standard Spring Security multi-chain {@code @Order} layering pattern and silently + * leaves the library's URIs unprotected. Keying on the bean name lets additional, differently-named consumer chains + * coexist with the library chain, while still letting a consumer fully replace it by naming their bean + * {@code securityFilterChain}. *

*

- * The real bean method requires the full security context and many collaborators to invoke. Rather than boot that heavyweight context (which would - * also risk polluting the shared JPA metamodel across parallel integration contexts), this test verifies the composition contract in two - * complementary, isolated ways: + * The real bean method requires the full security context and many collaborators to invoke. Rather than boot that + * heavyweight context (which would also risk polluting the shared JPA metamodel across parallel integration contexts), + * this test verifies the composition contract in two complementary, isolated ways: a reflection assertion on the real + * annotation, and an {@link ApplicationContextRunner} test against a lightweight stand-in that mirrors the exact same + * name-based conditional/order semantics. *

- *
    - *
  1. A reflection assertion that the auto-configuration's {@code securityFilterChain} method carries - * {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} and a low-precedence {@code @Order}.
  2. - *
  3. An {@link ApplicationContextRunner} test against a lightweight stand-in auto-configuration that mirrors the exact same conditional/order - * semantics, proving the back-off behaviour when a consumer supplies their own chain.
  4. - *
*/ @DisplayName("WebSecurityConfig SecurityFilterChain Composition Tests") class WebSecurityConfigCompositionTest { @@ -46,16 +45,22 @@ class WebSecurityConfigCompositionTest { class AnnotationContract { @Test - @DisplayName("securityFilterChain is annotated @ConditionalOnMissingBean(SecurityFilterChain.class)") - void securityFilterChainIsConditionalOnMissingBean() throws Exception { + @DisplayName("securityFilterChain backs off by bean NAME (not by type), so additional consumer chains coexist") + void securityFilterChainIsConditionalOnMissingBeanByName() throws Exception { Method method = WebSecurityFilterChainAutoConfiguration.class.getMethod("securityFilterChain", HttpSecurity.class, SessionRegistry.class); ConditionalOnMissingBean conditional = method.getAnnotation(ConditionalOnMissingBean.class); assertThat(conditional).as("@ConditionalOnMissingBean must be present").isNotNull(); - assertThat(conditional.value()).as("must back off when any SecurityFilterChain is present").contains(SecurityFilterChain.class); + assertThat(conditional.name()) + .as("must back off only when a bean NAMED securityFilterChain is present (an explicit full replacement), " + + "so narrower consumer chains coexist instead of suppressing the library chain") + .contains("securityFilterChain"); + assertThat(conditional.value()) + .as("must NOT be type-based: a type match would suppress the library chain whenever ANY SecurityFilterChain exists") + .isEmpty(); } @Test - @DisplayName("securityFilterChain is annotated with a low-precedence @Order so consumer chains win") + @DisplayName("securityFilterChain is annotated with a low-precedence @Order so it is the catch-all and consumer chains win their matched paths") void securityFilterChainIsOrderedAtLowPrecedence() throws Exception { Method method = WebSecurityFilterChainAutoConfiguration.class.getMethod("securityFilterChain", HttpSecurity.class, SessionRegistry.class); Order order = method.getAnnotation(Order.class); @@ -69,8 +74,8 @@ void securityFilterChainIsOrderedAtLowPrecedence() throws Exception { } @Nested - @DisplayName("Back-off semantics via ApplicationContextRunner") - class BackOffSemantics { + @DisplayName("Composition semantics via ApplicationContextRunner") + class CompositionSemantics { // Register the library stand-in as an auto-configuration so it is processed AFTER user-defined beans, // which is required for @ConditionalOnMissingBean to evaluate correctly. @@ -82,49 +87,80 @@ class BackOffSemantics { void libraryChainPresentByDefault() { contextRunner.run(context -> { assertThat(context).hasSingleBean(SecurityFilterChain.class); - assertThat(context).hasBean("librarySecurityFilterChain"); + assertThat(context).hasBean("securityFilterChain"); }); } @Test - @DisplayName("Library SecurityFilterChain backs off when a consumer defines their own") - void libraryChainBacksOffWhenConsumerSuppliesChain() { + @DisplayName("Library chain COEXISTS with an additional, differently-named consumer chain (the standard multi-chain pattern)") + void libraryChainCoexistsWithAdditionalConsumerChain() { contextRunner - .withUserConfiguration(ConsumerChainConfiguration.class) + .withUserConfiguration(AdditionalConsumerChainConfiguration.class) + .run(context -> { + // Both chains are present: a narrower, differently-named consumer chain must NOT suppress the + // library's catch-all chain. This is the regression guard for the test-API / actuator-chain case. + assertThat(context.getBeansOfType(SecurityFilterChain.class)).hasSize(2); + assertThat(context).hasBean("securityFilterChain"); + assertThat(context).hasBean("apiSecurityFilterChain"); + }); + } + + @Test + @DisplayName("Library chain backs off ONLY when the consumer defines a bean NAMED securityFilterChain (explicit full replacement)") + void libraryChainBacksOffOnNamedReplacement() { + contextRunner + .withUserConfiguration(NamedReplacementChainConfiguration.class) .run(context -> { assertThat(context).hasSingleBean(SecurityFilterChain.class); - assertThat(context).hasBean("consumerSecurityFilterChain"); - assertThat(context).doesNotHaveBean("librarySecurityFilterChain"); + // The consumer's bean (named securityFilterChain) wins; the library's stand-in backs off. + assertThat(context).hasBean("securityFilterChain"); }); } } /** - * Lightweight stand-in mirroring the auto-configuration's composition contract (same annotations as - * {@link WebSecurityFilterChainAutoConfiguration#securityFilterChain}). Returns a Mockito mock so no servlet/security context is required. + * Lightweight stand-in mirroring the auto-configuration's composition contract (same name-based conditional and + * order as {@link WebSecurityFilterChainAutoConfiguration#securityFilterChain}). The bean is named + * {@code securityFilterChain} to match the real method's bean name. Returns a Mockito mock so no servlet/security + * context is required. *

- * Intentionally NOT annotated with {@code @Configuration}: that would make this nested class eligible for the integration tests' component scan - * ({@code com.digitalsanctuary.spring.user}) and pollute those contexts with stray {@link SecurityFilterChain} beans. The - * {@link ApplicationContextRunner} registers these classes explicitly via {@code withConfiguration}/{@code withUserConfiguration}, so they are - * still processed as (lite) configuration sources without being scannable. + * Intentionally NOT annotated with {@code @Configuration}: that would make this nested class eligible for the + * integration tests' component scan ({@code com.digitalsanctuary.spring.user}) and pollute those contexts with stray + * {@link SecurityFilterChain} beans. The {@link ApplicationContextRunner} registers these classes explicitly via + * {@code withConfiguration}/{@code withUserConfiguration}, so they are still processed as (lite) configuration + * sources without being scannable. *

*/ static class LibraryChainConfiguration { @Bean @Order(SecurityFilterProperties.BASIC_AUTH_ORDER) - @ConditionalOnMissingBean(SecurityFilterChain.class) - public SecurityFilterChain librarySecurityFilterChain() { + @ConditionalOnMissingBean(name = "securityFilterChain") + public SecurityFilterChain securityFilterChain() { + return org.mockito.Mockito.mock(SecurityFilterChain.class); + } + } + + /** + * An ADDITIONAL, narrower consumer chain with a different bean name and higher precedence (lower {@code @Order}), + * exactly like a test-API or actuator chain. It must coexist with the library's catch-all chain. Intentionally NOT + * annotated with {@code @Configuration} (see {@link LibraryChainConfiguration}). + */ + static class AdditionalConsumerChainConfiguration { + @Bean + @Order(1) + public SecurityFilterChain apiSecurityFilterChain() { return org.mockito.Mockito.mock(SecurityFilterChain.class); } } /** - * A trivial consumer-supplied chain that should win and suppress the library's chain. Intentionally NOT annotated with {@code @Configuration} so it - * is not component-scanned by the integration tests (see {@link LibraryChainConfiguration}). + * A FULL-replacement consumer chain named exactly {@code securityFilterChain}; this is the explicit opt-in that + * suppresses the library's chain. Intentionally NOT annotated with {@code @Configuration} (see + * {@link LibraryChainConfiguration}). */ - static class ConsumerChainConfiguration { + static class NamedReplacementChainConfiguration { @Bean - public SecurityFilterChain consumerSecurityFilterChain() { + public SecurityFilterChain securityFilterChain() { return org.mockito.Mockito.mock(SecurityFilterChain.class); } } From f0ee8f0a5c8ed10a62eec953b9c6020efd523f47 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 18:38:04 -0600 Subject: [PATCH 43/55] feat(security): preserve and regenerate current session on password change (default) A self-service password change (and removing a password) previously invalidated ALL of the user's sessions including the current one, logging the user out of the very device they used to change it. This adds invalidateSessionsAfterPasswordChange(): by default it preserves the current session and regenerates its id (session-fixation protection per OWASP) while invalidating only the user's OTHER sessions, so the user stays logged in. Token-based password resets are unaffected (no authenticated current session to preserve -> all sessions invalidated as before). Controlled by user.session.invalidation.keep-current-session-on-password-change (default true); set false to restore the prior invalidate-everything behavior. --- .../service/SessionInvalidationService.java | 157 +++++++++++++++++- .../spring/user/service/UserService.java | 11 +- .../SessionInvalidationServiceTest.java | 98 +++++++++++ .../spring/user/service/UserServiceTest.java | 6 +- 4 files changed, 260 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java index a1ff794..a3ae6c3 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java @@ -5,16 +5,23 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.persistence.model.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * Service for invalidating user sessions. * - *

Provides functionality to invalidate all active sessions for a given user, useful for + *

Provides functionality to invalidate active sessions for a given user, useful for * admin-initiated password resets and other security operations that require forcing users - * to re-authenticate.

+ * to re-authenticate. {@link #invalidateUserSessions(User)} terminates every session for the user; + * {@link #invalidateSessionsAfterPasswordChange(User)} applies the self-service password-change policy, which by + * default preserves and regenerates the user's current session while invalidating their other sessions.

* *

Race Condition Note: This service uses Spring's SessionRegistry to track * and invalidate sessions. Due to the nature of the SessionRegistry API, there is an inherent @@ -37,6 +44,15 @@ public class SessionInvalidationService { @Value("${user.session.invalidation.warn-threshold:1000}") private int warnThreshold; + /** + * When {@code true} (the default), a self-service password change preserves the user's current session + * (regenerating its id to mitigate session fixation) and invalidates only the user's other sessions, so + * the user stays logged in after changing their own password. When {@code false}, every session for the user is + * invalidated, including the current one (the pre-4.x behavior), forcing an immediate re-login. + */ + @Value("${user.session.invalidation.keep-current-session-on-password-change:true}") + private boolean keepCurrentSessionOnPasswordChange; + /** * Invalidates all active sessions for the given user. * This forces the user to re-authenticate on their next request. @@ -76,11 +92,8 @@ public int invalidateUserSessions(User user) { for (SessionInformation session : sessions) { session.expireNow(); invalidatedCount++; - // Log truncated session ID to avoid exposing full session identifiers - String sessionId = session.getSessionId(); - String safeSessionId = sessionId.length() > 8 ? sessionId.substring(0, 8) + "..." : sessionId; log.debug("SessionInvalidationService.invalidateUserSessions: expired session {} for user {}", - safeSessionId, user.getEmail()); + truncateSessionId(session.getSessionId()), user.getEmail()); } } } @@ -90,6 +103,138 @@ public int invalidateUserSessions(User user) { return invalidatedCount; } + /** + * Invalidates sessions after a self-service password change, applying the configured policy + * ({@code user.session.invalidation.keep-current-session-on-password-change}, default {@code true}). + * + *

+ * With the default policy, the user's current session is preserved and its id is regenerated (mitigating + * session fixation), while every other session for the user is invalidated — so the user remains + * logged in on the device they just used to change their password, but any other active sessions are terminated. + * This follows the OWASP guidance to regenerate the current session and invalidate the rest on a credential change. + *

+ * + *

+ * When the policy is disabled, this delegates to {@link #invalidateUserSessions(User)} and terminates all + * sessions including the current one. If there is no current servlet request/session (e.g. the password is being + * changed through a flow where the user is not authenticated in a session, such as a token-based password reset), + * there is no current session to preserve, so all of the user's registered sessions are invalidated. + *

+ * + * @param user the user whose sessions should be invalidated + * @return the number of other sessions that were invalidated (the preserved current session is not counted) + */ + public int invalidateSessionsAfterPasswordChange(User user) { + if (!keepCurrentSessionOnPasswordChange) { + return invalidateUserSessions(user); + } + if (user == null) { + log.warn("SessionInvalidationService.invalidateSessionsAfterPasswordChange: user is null"); + return 0; + } + + final HttpServletRequest request = currentRequest(); + final String currentSessionId = currentSessionId(request); + + int invalidatedCount = 0; + Object currentPrincipal = null; + final List principals = sessionRegistry.getAllPrincipals(); + if (principals.size() > warnThreshold) { + log.warn("SessionInvalidationService.invalidateSessionsAfterPasswordChange: high principal count ({}) may impact performance", + principals.size()); + } + + for (Object principal : principals) { + User principalUser = extractUser(principal); + if (principalUser != null && principalUser.getId().equals(user.getId())) { + for (SessionInformation session : sessionRegistry.getAllSessions(principal, false)) { + if (currentSessionId != null && currentSessionId.equals(session.getSessionId())) { + // Preserve the current session; it is regenerated below rather than expired. + currentPrincipal = principal; + continue; + } + session.expireNow(); + invalidatedCount++; + log.debug("SessionInvalidationService.invalidateSessionsAfterPasswordChange: expired other session {} for user {}", + truncateSessionId(session.getSessionId()), user.getEmail()); + } + } + } + + if (currentPrincipal != null) { + regenerateCurrentSession(request, currentSessionId, currentPrincipal, user); + } + + log.info("SessionInvalidationService.invalidateSessionsAfterPasswordChange: invalidated {} other session(s) for user {}; " + + "current session preserved and regenerated: {}", invalidatedCount, user.getEmail(), currentPrincipal != null); + return invalidatedCount; + } + + /** + * Regenerates the current HTTP session id (preserving the session and its {@code SecurityContext}) and keeps the + * {@link SessionRegistry} consistent so the concurrent-session machinery recognizes the new id on the next request. + * Best-effort: if there is no active session to regenerate (e.g. it was already invalidated or the response is + * committed), the user simply keeps their existing session id. + * + * @param request the current servlet request (non-null) + * @param oldSessionId the current session id prior to regeneration + * @param principal the security principal the session is registered under + * @param user the user (for logging) + */ + private void regenerateCurrentSession(HttpServletRequest request, String oldSessionId, Object principal, User user) { + try { + final String newSessionId = request.changeSessionId(); + if (!newSessionId.equals(oldSessionId)) { + sessionRegistry.removeSessionInformation(oldSessionId); + sessionRegistry.registerNewSession(newSessionId, principal); + log.debug("SessionInvalidationService.regenerateCurrentSession: regenerated current session {} -> {} for user {}", + truncateSessionId(oldSessionId), truncateSessionId(newSessionId), user.getEmail()); + } + } catch (IllegalStateException ex) { + log.debug("SessionInvalidationService.regenerateCurrentSession: could not regenerate current session for user {}: {}", + user.getEmail(), ex.getMessage()); + } + } + + /** + * Returns the current servlet request bound to this thread, or {@code null} if the call is not happening on a + * request-bound thread (e.g. a background job). + * + * @return the current {@link HttpServletRequest}, or {@code null} + */ + private HttpServletRequest currentRequest() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes servletRequestAttributes) { + return servletRequestAttributes.getRequest(); + } + return null; + } + + /** + * Returns the id of the existing session on the given request, or {@code null} if the request is {@code null} or + * has no session. + * + * @param request the current request (may be {@code null}) + * @return the current session id, or {@code null} + */ + private String currentSessionId(HttpServletRequest request) { + if (request == null) { + return null; + } + HttpSession session = request.getSession(false); + return session != null ? session.getId() : null; + } + + /** + * Truncates a session id for safe logging, never exposing the full identifier. + * + * @param sessionId the full session id + * @return the first 8 characters followed by an ellipsis, or the id unchanged if it is short + */ + private String truncateSessionId(String sessionId) { + return sessionId != null && sessionId.length() > 8 ? sessionId.substring(0, 8) + "..." : sessionId; + } + /** * Extracts the User object from a principal. * Handles both User and DSUserDetails principal types. diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 84de757..14ba32a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -748,8 +748,11 @@ public void changeUserPassword(final User user, final String password) { void persistChangedPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); - // Terminate all existing sessions so a reset/change forces re-auth everywhere (OWASP). - sessionInvalidationService.invalidateUserSessions(user); + // Force re-auth on a password change (OWASP). By default the current session is preserved and + // regenerated and only the user's OTHER sessions are invalidated, so the user is not logged out + // of the device they just used; set user.session.invalidation.keep-current-session-on-password-change=false + // to terminate every session including the current one. + sessionInvalidationService.invalidateSessionsAfterPasswordChange(user); } /** @@ -788,7 +791,9 @@ public void removeUserPassword(User user) { user.setPassword(null); userRepository.save(user); passwordHistoryRepository.deleteByUser(user); - sessionInvalidationService.invalidateUserSessions(user); + // Same policy as a password change: by default preserve+regenerate the current session and invalidate + // only the user's other sessions (see user.session.invalidation.keep-current-session-on-password-change). + sessionInvalidationService.invalidateSessionsAfterPasswordChange(user); log.info("Password removed for user: {}", user.getEmail()); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java index f392aa6..e244180 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java @@ -8,6 +8,7 @@ import java.util.Collections; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -16,9 +17,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; @@ -274,4 +279,97 @@ void includesPrincipalCountInInfoLog() { verify(session).expireNow(); } } + + @Nested + @DisplayName("invalidateSessionsAfterPasswordChange Tests") + class InvalidateSessionsAfterPasswordChangeTests { + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + private void bindRequestWithSession(String sessionId) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(new MockHttpSession(null, sessionId)); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @Test + @DisplayName("by default preserves and regenerates the current session, invalidating only the user's OTHER sessions") + void preservesAndRegeneratesCurrentSession() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + bindRequestWithSession("current-session"); + + SessionInformation currentSession = mock(SessionInformation.class); + SessionInformation otherSession = mock(SessionInformation.class); + when(currentSession.getSessionId()).thenReturn("current-session"); + when(otherSession.getSessionId()).thenReturn("other-session"); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(Arrays.asList(currentSession, otherSession)); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + // Only the OTHER session is invalidated; the current one is preserved (kept logged in). + assertThat(invalidated).isEqualTo(1); + verify(otherSession).expireNow(); + verify(currentSession, never()).expireNow(); + // The current session's id is regenerated (fixation protection) and the registry kept consistent. + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + String newSessionId = attrs.getRequest().getSession(false).getId(); + assertThat(newSessionId).isNotEqualTo("current-session"); + verify(sessionRegistry).removeSessionInformation("current-session"); + verify(sessionRegistry).registerNewSession(newSessionId, testUser); + } + + @Test + @DisplayName("when policy is disabled, invalidates ALL sessions including the current one") + void invalidatesAllWhenPolicyDisabled() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", false); + bindRequestWithSession("current-session"); + + SessionInformation currentSession = mock(SessionInformation.class); + when(currentSession.getSessionId()).thenReturn("current-session"); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(List.of(currentSession)); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + assertThat(invalidated).isEqualTo(1); + verify(currentSession).expireNow(); + verify(sessionRegistry, never()).registerNewSession(anyString(), any()); + } + + @Test + @DisplayName("with no current request (e.g. token-based reset), invalidates all of the user's sessions") + void invalidatesAllWhenNoCurrentRequest() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + // No RequestContextHolder bound: there is no current session to preserve. + + SessionInformation session1 = mock(SessionInformation.class); + SessionInformation session2 = mock(SessionInformation.class); + when(session1.getSessionId()).thenReturn("s1"); + when(session2.getSessionId()).thenReturn("s2"); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(Arrays.asList(session1, session2)); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + assertThat(invalidated).isEqualTo(2); + verify(session1).expireNow(); + verify(session2).expireNow(); + verify(sessionRegistry, never()).registerNewSession(anyString(), any()); + } + + @Test + @DisplayName("returns 0 and does nothing when user is null") + void returnsZeroWhenUserIsNull() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(null); + + assertThat(invalidated).isEqualTo(0); + verify(sessionRegistry, never()).getAllPrincipals(); + } + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index ddca985..8f29e56 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -279,7 +279,7 @@ void changeUserPassword_invalidatesExistingSessions() { userService.changeUserPassword(testUser, newPassword); // Then - verify(sessionInvalidationService).invalidateUserSessions(testUser); + verify(sessionInvalidationService).invalidateSessionsAfterPasswordChange(testUser); } // Additional tests for comprehensive coverage @@ -918,7 +918,7 @@ void shouldRemovePasswordAndClearHistory() { assertThat(testUser.getPassword()).isNull(); verify(userRepository).save(testUser); verify(passwordHistoryRepository).deleteByUser(testUser); - verify(sessionInvalidationService).invalidateUserSessions(testUser); + verify(sessionInvalidationService).invalidateSessionsAfterPasswordChange(testUser); } } @@ -1081,7 +1081,7 @@ void changeUserPassword_encodesBeforeSave() { InOrder inOrder = inOrder(passwordEncoder, userRepository, sessionInvalidationService); inOrder.verify(passwordEncoder).encode(newPassword); inOrder.verify(userRepository).save(testUser); - inOrder.verify(sessionInvalidationService).invalidateUserSessions(testUser); + inOrder.verify(sessionInvalidationService).invalidateSessionsAfterPasswordChange(testUser); } @Test From 55b5d8aaf584692d15f899b65f528bb94e75ce16 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 13 Jun 2026 18:38:04 -0600 Subject: [PATCH 44/55] docs(migration): correct chain override model; document breaking changes - Rewrite the SecurityFilterChain override-model section for the now name-based @ConditionalOnMissingBean (additional consumer chains coexist; full replacement requires a bean named securityFilterChain). - Document the UserEmailService constructor's new TokenHasher parameter (breaking for subclasses). - Document the Passay 1.x -> 2.0.0 upgrade and package relocations. - Document the new preserve-current-session-on-password-change default and its toggle. --- MIGRATION.md | 114 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 23d43a0..2d39dec 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -91,6 +91,21 @@ plugins { } ``` +#### Passay upgraded to 2.0.0 + +The framework's transitive Passay dependency was upgraded from 1.x to **2.0.0**, which **relocated +several packages** (e.g. `org.passay.CharacterData` → `org.passay.data.CharacterData`, +`org.passay.CharacterRule` → `org.passay.rule.CharacterRule`). This only affects you if your +application **uses Passay directly** (e.g. for custom password rules): + +- If you declared your own `org.passay:passay` dependency at a 1.x version, **remove the explicit + pin** (let it inherit 2.0.0 transitively) or bump it to `2.0.0`. Pinning an older version forces a + conflicting downgrade that breaks the framework's `PasswordPolicyService` at runtime + (`ClassNotFoundException: org.passay.data.CharacterData`). +- Update your own Passay imports to the new 2.0.0 package names. + +Applications that do not use Passay directly need no changes. + ### Step 3: Spring Security 7 Changes Spring Boot 4.0 includes Spring Security 7, which has breaking changes from Spring Security 6.x. @@ -268,50 +283,64 @@ If you have a custom `WebSecurityConfig` or extend the framework's security conf #### SecurityFilterChain override model (4.x) -The library now contributes its `SecurityFilterChain` through a dedicated auto-configuration with two important properties: +The library contributes its `SecurityFilterChain` through a dedicated auto-configuration with two important properties: + +- **Ordered at low precedence.** The library's chain is registered with `@Order(Ordered.LOWEST_PRECEDENCE - 5)` — the same low precedence Spring Boot uses for its own default servlet security chain. This value is sourced from `SecurityFilterProperties.BASIC_AUTH_ORDER`; the constant was `SecurityProperties.BASIC_AUTH_ORDER` in Spring Boot 3.x and was relocated to `SecurityFilterProperties.BASIC_AUTH_ORDER` in Spring Boot 4.0 (still `Ordered.LOWEST_PRECEDENCE - 5`). The library's chain has **no `securityMatcher`**, so it is the catch-all: any consumer-supplied chain with a `securityMatcher` and a lower (higher-precedence) `@Order` is consulted first by Spring Security's `FilterChainProxy`, and unmatched requests fall through to the library's chain. +- **Backs off only on a same-named replacement.** The library's chain bean is named `securityFilterChain` and is annotated `@ConditionalOnMissingBean(name = "securityFilterChain")`. It backs off **only** when you define a `SecurityFilterChain` bean **named `securityFilterChain`** (an explicit full replacement). Defining additional, differently-named chains does **not** suppress it. -- **Ordered at low precedence.** The library's chain is registered with `@Order(Ordered.LOWEST_PRECEDENCE - 5)` — the same low precedence Spring Boot uses for its own default servlet security chain. This value is sourced from `SecurityFilterProperties.BASIC_AUTH_ORDER`; the constant was `SecurityProperties.BASIC_AUTH_ORDER` in Spring Boot 3.x and was relocated to `SecurityFilterProperties.BASIC_AUTH_ORDER` in Spring Boot 4.0 (still `Ordered.LOWEST_PRECEDENCE - 5`). This means any consumer-supplied chain with a lower (higher-precedence) `@Order` is consulted first by Spring Security's `FilterChainProxy`. -- **Backs off entirely if you define your own.** The library's chain is annotated `@ConditionalOnMissingBean(SecurityFilterChain.class)`. If your application defines **any** `SecurityFilterChain` bean, the library's chain is suppressed completely. +> **Behavior change vs. earlier 4.x pre-releases:** an earlier iteration used `@ConditionalOnMissingBean(SecurityFilterChain.class)` (type-based), which suppressed the entire library chain as soon as you defined *any* `SecurityFilterChain` — even a narrow one (e.g. a test-API or actuator chain). That silently left the library's URIs unprotected. The conditional is now **name-based** so the standard Spring Security multi-chain `@Order` layering pattern works as expected. This gives you two ways to customize security: -**Option A — Replace the library's chain (you own all the rules).** +**Option A — Add additional, narrower chains alongside the library's (recommended for most layering).** -Define your own `SecurityFilterChain`. Because of `@ConditionalOnMissingBean`, the library's chain backs off entirely and **does not** apply any of its rules. You are now responsible for protecting *all* URIs, including the framework's endpoints (login, registration, password reset, profile, etc.). Use this when you want full control. +Define your own `SecurityFilterChain` with a `securityMatcher` scoping it to a subset of requests and a higher-precedence (lower) `@Order`. Give it **any name other than `securityFilterChain`**. Both chains coexist: your chain handles its matched requests, and the library's catch-all chain keeps protecting everything else (login, registration, password reset, profile, etc.). ```java @Configuration @EnableWebSecurity -public class CustomSecurityConfig { +public class ApiSecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + @Order(1) // higher precedence than the library's catch-all chain + public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/**") // scopes this chain to /api/** + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/public/**").permitAll() + .anyRequest().hasRole("ADMIN")) + .csrf(csrf -> csrf.disable()); + return http.build(); // bean name is "apiSecurityFilterChain" -> library chain stays active + } +} +``` + +**Option B — Fully replace the library's chain (you own all the rules).** + +Define your own `SecurityFilterChain` bean **named `securityFilterChain`**. The library's chain backs off entirely and does **not** apply any of its rules; you are now responsible for protecting *all* URIs, including the framework's endpoints. + +```java +@Configuration +@EnableWebSecurity +public class CustomSecurityConfig { + + @Bean // bean name MUST be "securityFilterChain" to replace the library's chain + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz - // All patterns must start with / .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") // You must also permit/secure the framework's own URIs here, // since the library chain no longer applies: .requestMatchers("/user/registration", "/user/login", "/user/resetPassword").permitAll() - .anyRequest().authenticated() - ) - .formLogin(form -> form - .loginPage("/user/login.html") - .permitAll() - ); + .anyRequest().authenticated()) + .formLogin(form -> form.loginPage("/user/login.html").permitAll()); return http.build(); } } ``` -**Option B — Layer your own higher-precedence chain in front of the library's.** - -Define your own `SecurityFilterChain` with a `securityMatcher` scoping it to a subset of requests and a higher-precedence (lower) `@Order`. Spring Security evaluates chains in order and uses the **first** chain whose matcher matches. Requests that don't match your chain fall through to the library's chain. - -> Note: As soon as you define *any* `SecurityFilterChain` bean, the library's `@ConditionalOnMissingBean` causes its chain to back off. So Option B does **not** keep the library's chain active automatically — if you want both your chain and the library's behavior, you currently need to reproduce the rules you care about in your own chain(s). The `@Order` mechanism is what lets multiple consumer-defined chains coexist with predictable precedence. - -For most applications that only need to *add* a few rules, the simplest path is to rely on the library's chain and the `user.security.*` properties (`protectedURIs`, `unprotectedURIs`, `defaultAction`, etc.) rather than defining your own `SecurityFilterChain`. +For most applications that only need to *add* a few rules, the simplest path is to rely on the library's chain and the `user.security.*` properties (`protectedURIs`, `unprotectedURIs`, `defaultAction`, etc.) rather than defining your own `SecurityFilterChain` at all. ### Custom User Services @@ -337,6 +366,49 @@ Most consumers call these methods from controllers (which are not transactional) unaffected. If you depend on enlisting these operations in a surrounding transaction, you will need to restructure that flow. +#### `UserEmailService` constructor gained a `TokenHasher` parameter (breaking for subclasses) + +Verification and password-reset tokens are now hashed at rest. `UserEmailService` therefore takes +an additional `TokenHasher` constructor parameter. **If you subclass `UserEmailService`**, your +subclass constructor must accept and pass through the new parameter: + +```java +public CustomUserEmailService( + MailService mailService, + UserVerificationService userVerificationService, + PasswordResetTokenRepository passwordTokenRepository, + ApplicationEventPublisher eventPublisher, + SessionInvalidationService sessionInvalidationService, + TokenHasher tokenHasher) { // <-- new parameter + super(mailService, userVerificationService, passwordTokenRepository, + eventPublisher, sessionInvalidationService, tokenHasher); +} +``` + +`TokenHasher` is a framework `@Component`, so it is available for injection. Consumers that do not +subclass `UserEmailService` are unaffected. The hashing is backward compatible at runtime: tokens +issued before the upgrade (stored in plaintext) are still resolved via a dual-read lookup and remain +usable until they expire. + +#### Sessions on password change: current session is now preserved (OWASP) + +A self-service password change (and removing a password to go passwordless) invalidates the user's +**other** sessions but, by default, now **preserves and regenerates the current session** rather than +logging the user out of the device they just used. This follows OWASP guidance (regenerate the +current session id, invalidate the rest) and is a friendlier default. + +To restore the previous "invalidate every session, including the current one" behavior, set: + +```yaml +user: + session: + invalidation: + keep-current-session-on-password-change: false +``` + +Token-based password **resets** (the forgot-password flow) are unaffected: there is no authenticated +current session to preserve, so all of the user's sessions are invalidated as before. + ### Custom Controllers If you have controllers that extend or work alongside framework controllers: From f3ca56ce378a17ee3c5b71f5bfcdd15d4b65cdec Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 08:45:30 -0600 Subject: [PATCH 45/55] fix(mfa): auto-unprotect configured factor entry-point URIs to prevent redirect loop (#313) When MFA is enabled the framework already auto-unprotects /user/mfa/status, but not the configured passwordEntryPointUri / webauthnEntryPointUri. A partially-authenticated user (one factor satisfied) is redirected to a factor entry-point page to complete the remaining factor(s); if that page is itself protected, the redirect target is denied and the user loops between entry points (ERR_TOO_MANY_REDIRECTS). getUnprotectedURIsList() now also adds the configured entry-point URIs when MFA is enabled, so consuming apps no longer have to list them manually. This is the 'related observation' from #313. The issue's primary bug (second-factor login replacing rather than merging factor authorities) was already fixed on this branch by the MFA filter-merging post-processor (MfaFilterMergingConfiguration sets mfaEnabled=true on the auth processing filters). --- .../user/security/WebSecurityConfig.java | 19 +++++++++++++++++++ .../api/MfaFeatureEnabledIntegrationTest.java | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index ee7fa4b..3fcc790 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -318,11 +318,30 @@ private List getUnprotectedURIsList() { } if (mfaConfigProperties.isEnabled()) { unprotectedURIs.add("/user/mfa/status"); + // A partially-authenticated user (one factor satisfied) is redirected to the configured factor + // entry-point page(s) to complete the remaining factor(s). Those pages MUST be reachable without + // full authentication; otherwise the redirect target is itself denied and the user loops between + // entry points (ERR_TOO_MANY_REDIRECTS). Auto-unprotect the configured entry-point URIs so a + // consuming app does not have to remember to list them manually. + addIfHasText(unprotectedURIs, mfaConfigProperties.getPasswordEntryPointUri()); + addIfHasText(unprotectedURIs, mfaConfigProperties.getWebauthnEntryPointUri()); } unprotectedURIs.removeAll(Collections.emptyList()); return unprotectedURIs; } + /** + * Adds the given URI to the list only when it is non-null and not blank. + * + * @param uris the list to add to + * @param uri the candidate URI (may be {@code null} or blank) + */ + private void addIfHasText(List uris, String uri) { + if (uri != null && !uri.isBlank()) { + uris.add(uri); + } + } + /** * Helper method to split comma-separated property values and filter out empty strings. * diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java index 4961e4e..d3e9105 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java @@ -95,6 +95,19 @@ void shouldRedirectToPasswordEntryPointWhenMissingFactorAuthority() throws Excep .andExpect(redirectedUrlPattern(mfaConfigProperties.getPasswordEntryPointUri() + "**")); } + @Test + @DisplayName("auto-unprotects the configured WebAuthn factor entry-point URI (prevents the partial-auth redirect loop)") + void shouldUnprotectConfiguredWebauthnEntryPointUri() throws Exception { + // A partially-authenticated user (missing the WEBAUTHN factor) is redirected to the WebAuthn entry-point + // page to complete it. If that page is itself protected, the redirect target is denied and the user loops + // between entry points (ERR_TOO_MANY_REDIRECTS). The framework auto-unprotects the configured entry-point + // URIs, so an unauthenticated request must NOT be redirected to the login page (a protected path would be). + String webauthnEntryPoint = mfaConfigProperties.getWebauthnEntryPointUri(); + mockMvc.perform(get(webauthnEntryPoint)).andExpect(result -> assertThat(result.getResponse().getStatus()) + .as("MFA WebAuthn entry point %s must be unprotected (not redirected to login)", webauthnEntryPoint) + .isNotEqualTo(org.springframework.http.HttpStatus.FOUND.value())); + } + @Test @DisplayName("should report fully authenticated when user has all required factor authorities") void shouldReportFullyAuthenticatedWhenUserHasAllRequiredFactorAuthorities() throws Exception { From 7d05a8c0d3a3d22813afdbb9c6fc43576680369d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 10:28:41 -0600 Subject: [PATCH 46/55] ci: target Java 21 + 25 for the Spring Boot 4.x build (drop Java 17) Spring Boot 4.0 requires Java 21+, so the library compiles to Java 21 bytecode via the Gradle toolchain. Replace the testJdk17 task (which never worked against Java 21 bytecode) with testJdk25, and run runtime-compatibility tests on Java 21 (minimum) and Java 25 (current LTS). CI now installs JDK 21 (toolchain + Gradle JVM) and 25, runs './gradlew check' (compile + test on 21) and './gradlew testJdk25' (runtime on 25). The previous [17, 21] matrix's Java-17 leg could not resolve the required Java 21 toolchain and would have failed. Validated locally: testJdk25 is green. --- .github/workflows/build.yml | 19 ++++++++++++------- build.gradle | 8 +++++--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a8d858..0fe21a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,25 +12,30 @@ permissions: jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - jdk: [ '17', '21' ] steps: - uses: actions/checkout@v4 - - name: Set up JDK ${{ matrix.jdk }} + # Spring Boot 4.x requires Java 21+. The library compiles to Java 21 bytecode via the Gradle + # toolchain; we additionally verify runtime compatibility on Java 25 (current LTS). Both JDKs are + # installed; listing 21 last makes it the default JAVA_HOME, so Gradle runs on (and compiles with) + # Java 21 and discovers Java 25 for the runtime-only testJdk25 task. + - name: Set up JDK 21 (toolchain) and 25 (runtime) uses: actions/setup-java@v4 with: distribution: temurin - java-version: ${{ matrix.jdk }} + java-version: | + 25 + 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 - - name: Build and test + - name: Build and test (compile + test on Java 21) run: ./gradlew check --no-daemon + - name: Test on Java 25 (runtime compatibility) + run: ./gradlew testJdk25 --no-daemon - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: - name: test-reports-jdk${{ matrix.jdk }} + name: test-reports path: build/reports/tests/ codeql: diff --git a/build.gradle b/build.gradle index 12c5047..8c6a9bb 100644 --- a/build.gradle +++ b/build.gradle @@ -182,15 +182,17 @@ def registerJdkTestTask(name, jdkVersion) { } } -registerJdkTestTask('testJdk17', 17) +// Spring Boot 4.x requires Java 21+, so the library compiles to Java 21 bytecode (see the toolchain +// above) and is tested for runtime compatibility on Java 21 (the minimum) and Java 25 (the current LTS). registerJdkTestTask('testJdk21', 21) +registerJdkTestTask('testJdk25', 25) // Optional task that runs tests with multiple JDKs tasks.register('testAll') { - dependsOn(tasks.named('testJdk17'), tasks.named('testJdk21')) + dependsOn(tasks.named('testJdk21'), tasks.named('testJdk25')) doFirst { - println("Running tests with both JDK 17 and JDK 21") + println("Running tests with JDK 21 and JDK 25") } } From 2a8c7075cae9b46b24b7af4dd01daf4a1b4273eb Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 10:52:23 -0600 Subject: [PATCH 47/55] test: stop global spring.profiles.active pollution that flaked RegistrationGuard wiring tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseTestConfiguration set spring.profiles.active=test via System.setProperty, a JVM-global mutation that leaked across the parallel test suite. When it raced ahead of RegistrationGuardConfigurationTest — which runs an ApplicationContextRunner with no profile and gates its consumer-guard configs on @Profile("!test") — the test profile suppressed those guards, so DefaultRegistrationGuard was registered and two wiring assertions failed (intermittently; surfaced on the Java 25 run). The property was redundant: every test annotation and direct consumer already declares @ActiveProfiles("test"), and ddl-auto/datasource are set in the test properties files. Removed the TestPropertySourcesConfigurer entirely. Also pinned RegistrationGuardConfigurationTest's runner to a non-test profile via withPropertyValues so it stays deterministic against any future ambient leak. --- .../RegistrationGuardConfigurationTest.java | 7 +++++++ .../test/config/BaseTestConfiguration.java | 20 ------------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java index 2802759..6762023 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java @@ -32,7 +32,14 @@ class RegistrationGuardConfigurationTest { // Register RegistrationGuardConfiguration as an auto-configuration so its @ConditionalOnMissingBean // evaluates AFTER any consumer-supplied guard beans — mirroring production, where this configuration // is component-scanned by the UserConfiguration auto-configuration (loaded after consumer beans). + // + // The inlined "spring.profiles.active" pins this context to a non-"test" profile so the @Profile("!test") + // guard configs below always load. Without it, the test depends on no "test" profile being active — a + // fragile assumption, because anything that sets the global "spring.profiles.active=test" system property + // (e.g. another test running concurrently) would suppress the guard configs and flake these assertions. + // The inlined property source outranks any leaked system property, making this deterministic. private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withPropertyValues("spring.profiles.active=registrationguardtest") .withConfiguration(AutoConfigurations.of(RegistrationGuardConfiguration.class)); @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java b/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java index 2e3d4a4..732ee5a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java @@ -83,24 +83,4 @@ public Locale testLocale() { return Locale.US; } - /** - * Test-specific property overrides. - */ - @Bean - public TestPropertySourcesConfigurer testPropertySourcesConfigurer() { - return new TestPropertySourcesConfigurer(); - } - - /** - * Helper class to configure test properties programmatically. - */ - public static class TestPropertySourcesConfigurer { - - public TestPropertySourcesConfigurer() { - // Set system properties for tests - System.setProperty("spring.profiles.active", "test"); - System.setProperty("spring.jpa.hibernate.ddl-auto", "create-drop"); - System.setProperty("spring.datasource.initialization-mode", "always"); - } - } } \ No newline at end of file From 26f5f10ad1a0172664404ffa44d53818a4ecb4cc Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 10:59:15 -0600 Subject: [PATCH 48/55] test: restore load-bearing global test profile; keep RegistrationGuard hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit removed BaseTestConfiguration's global spring.profiles.active=test, assuming it redundant. It was not: integration tests using a bare @SpringBootTest (e.g. UserUpdateIntegrationTest) rely on the ambient test profile to resolve the test datasource, and dropping it spun up default-profile contexts that contended on H2 locks — producing 11 failures (SQL grammar errors + H2 LockTimeoutException) on the Java 21 run. Restore the global property (with a comment explaining why it stays) and keep the real fix for the original flake: RegistrationGuardConfigurationTest pins its own non-test profile via a higher-precedence inlined property source, so it no longer depends on the ambient profile. Full test + testJdk25 suites: 855 tests, 0 failures. --- .../test/config/BaseTestConfiguration.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java b/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java index 732ee5a..508f1c1 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java @@ -83,4 +83,32 @@ public Locale testLocale() { return Locale.US; } + /** + * Test-specific property overrides. + */ + @Bean + public TestPropertySourcesConfigurer testPropertySourcesConfigurer() { + return new TestPropertySourcesConfigurer(); + } + + /** + * Helper class to configure test properties programmatically. + * + *

Note: {@code spring.profiles.active=test} is set globally here so that integration tests using a + * bare {@code @SpringBootTest} (no {@code @ActiveProfiles}) still resolve the test datasource and share + * a single H2 configuration across the JVM. Without it, default-profile contexts spin up a second + * datasource and contend on H2 locks under parallel execution. Tests that must run with a different + * profile (e.g. {@code RegistrationGuardConfigurationTest}) override this locally via a + * higher-precedence inlined property source rather than relying on this ambient value.

+ */ + public static class TestPropertySourcesConfigurer { + + public TestPropertySourcesConfigurer() { + // Set system properties for tests + System.setProperty("spring.profiles.active", "test"); + System.setProperty("spring.jpa.hibernate.ddl-auto", "create-drop"); + System.setProperty("spring.datasource.initialization-mode", "always"); + } + } + } \ No newline at end of file From 6c97fbd34a5e95a811061e6aae1e7f5b7bb6ff4f Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 15:07:17 -0600 Subject: [PATCH 49/55] fix(security): address PR #314 review feedback - UserEmailService: route createPasswordResetTokenForUser through a @Lazy self proxy so its @Transactional applies (self-invocation was a no-op, reopening the single-active-token race between deleteByUser and save) - LoginHelperService: remove PII (email) from Locked/Disabled exception messages; email retained only in DEBUG logs - CompositeRegistrationGuard: throw IllegalStateException on a null delegate decision instead of failing open; add fail-fast tests - AuditMailAutoConfiguration: fix flushOnWrite SpEL default (:true -> :false) to match AuditConfig field default so the flush scheduler is created - TokenHasher: extract shared static fingerprint(); drop duplicated private copies in UserVerificationService and UserActionController - UserService: correct cleanUpPasswordHistory comment to reflect the real proxied call chain --- .../audit/AuditMailAutoConfiguration.java | 2 +- .../user/controller/UserActionController.java | 23 ++------------ .../CompositeRegistrationGuard.java | 10 +++++- .../user/service/LoginHelperService.java | 6 ++-- .../spring/user/service/TokenHasher.java | 20 ++++++++++++ .../spring/user/service/UserEmailService.java | 25 +++++++++++++-- .../spring/user/service/UserService.java | 12 ++++--- .../user/service/UserVerificationService.java | 22 ++----------- .../CompositeRegistrationGuardTest.java | 31 +++++++++++++++++++ .../user/service/UserEmailServiceTest.java | 4 +++ 10 files changed, 104 insertions(+), 51 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java index eca6efe..a8002c4 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java @@ -74,7 +74,7 @@ public FileAuditLogWriter fileAuditLogWriter(AuditConfig auditConfig) { */ @Bean @ConditionalOnBean(FileAuditLogWriter.class) - @ConditionalOnExpression("${user.audit.logEvents:true} && !${user.audit.flushOnWrite:true}") + @ConditionalOnExpression("${user.audit.logEvents:true} && !${user.audit.flushOnWrite:false}") public FileAuditLogFlushScheduler fileAuditLogFlushScheduler(FileAuditLogWriter fileAuditLogWriter) { return new FileAuditLogFlushScheduler(fileAuditLogWriter); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java index 6a97909..bf69bf5 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java +++ b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java @@ -12,6 +12,7 @@ import org.springframework.web.servlet.ModelAndView; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.service.TokenHasher; import com.digitalsanctuary.spring.user.service.UserService; import com.digitalsanctuary.spring.user.service.UserService.TokenValidationResult; import com.digitalsanctuary.spring.user.service.UserVerificationService; @@ -78,7 +79,7 @@ public class UserActionController { @GetMapping("${user.security.changePasswordURI:/user/changePassword}") public ModelAndView showChangePasswordPage(final HttpServletRequest request, final ModelMap model, @RequestParam("token") final String token) { - log.debug("UserAPI.showChangePasswordPage: called with token: {}", tokenFingerprint(token)); + log.debug("UserAPI.showChangePasswordPage: called with token: {}", TokenHasher.fingerprint(token)); final TokenValidationResult result = userService.validatePasswordResetToken(token); log.debug("UserAPI.showChangePasswordPage: result: {}", result); AuditEvent changePasswordAuditEvent = AuditEvent.builder().source(this).sessionId(request.getSession().getId()) @@ -111,7 +112,7 @@ public ModelAndView showChangePasswordPage(final HttpServletRequest request, fin @GetMapping("${user.security.registrationConfirmURI:/user/registrationConfirm}") public ModelAndView confirmRegistration(final HttpServletRequest request, final ModelMap model, @RequestParam("token") final String token) throws UnsupportedEncodingException { - log.debug("UserAPI.confirmRegistration: called with token: {}", tokenFingerprint(token)); + log.debug("UserAPI.confirmRegistration: called with token: {}", TokenHasher.fingerprint(token)); Locale locale = request.getLocale(); model.addAttribute("lang", locale.getLanguage()); // Resolve the user BEFORE validating: validateVerificationToken atomically consumes (deletes) the token, @@ -146,22 +147,4 @@ public ModelAndView confirmRegistration(final HttpServletRequest request, final String redirectString = "redirect:" + registrationNewVerificationURI; return new ModelAndView(redirectString, model); } - - /** - * Produces a non-reversible fingerprint of a token for safe logging. Never logs the full token - * value (which is sensitive authentication material). - * - * @param token the raw token value - * @return "null" if the token is null, "****" for short tokens, otherwise the first 6 characters - * followed by an ellipsis - */ - private String tokenFingerprint(final String token) { - if (token == null) { - return "null"; - } - if (token.length() <= 8) { - return "****"; - } - return token.substring(0, 6) + "…"; - } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java index 08f75fa..a7be960 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java @@ -47,12 +47,20 @@ public CompositeRegistrationGuard(final List delegates) { * @param context the registration context describing the attempt * @return the first denying {@link RegistrationDecision}, or {@link RegistrationDecision#allow()} if * all delegates allow + * @throws IllegalStateException if any delegate returns {@code null}; the SPI contract requires a + * non-null decision, and silently treating {@code null} as "allow" would let a buggy guard + * fail open. Failing fast surfaces the bug at test/runtime instead. */ @Override public RegistrationDecision evaluate(final RegistrationContext context) { for (RegistrationGuard delegate : delegates) { RegistrationDecision decision = delegate.evaluate(context); - if (decision != null && !decision.allowed()) { + if (decision == null) { + throw new IllegalStateException( + "RegistrationGuard " + delegate.getClass().getName() + " returned a null decision; " + + "guards must return a non-null RegistrationDecision (allow or deny)."); + } + if (!decision.allowed()) { return decision; } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java index bf939cf..823c086 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java @@ -119,13 +119,15 @@ public DSUserDetails userLoginHelper(User dbUser, OidcUserInfo oidcUserInfo, Oid * @throws DisabledException if the account is disabled */ private void assertAccountUsable(User user) { + // Exception messages are intentionally generic (no PII): they can surface to WARN/ERROR logs and + // user-facing error flows via handlers we do not control. The email is captured only in DEBUG logs. if (user.isLocked()) { log.debug("Rejecting authentication for locked account: {}", user.getEmail()); - throw new LockedException("Account is locked: " + user.getEmail()); + throw new LockedException("Account is locked"); } if (!user.isEnabled()) { log.debug("Rejecting authentication for disabled account: {}", user.getEmail()); - throw new DisabledException("Account is disabled: " + user.getEmail()); + throw new DisabledException("Account is disabled"); } } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java index 989ea74..ac6b890 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java @@ -93,6 +93,26 @@ public String hash(final String rawToken) { } } + /** + * Produces a short, non-reversible fingerprint of a raw token for safe logging. Never logs the full + * token: returns a fixed placeholder for {@code null}/short values and only the first 6 characters + * (followed by an ellipsis) for longer tokens. Intended purely for correlating log lines, not for + * any security decision. + * + * @param token the raw token (may be {@code null}) + * @return {@code "null"} if the token is {@code null}, {@code "****"} if it is 8 characters or fewer, + * otherwise the first 6 characters followed by an ellipsis + */ + public static String fingerprint(final String token) { + if (token == null) { + return "null"; + } + if (token.length() <= 8) { + return "****"; + } + return token.substring(0, 6) + "…"; + } + /** * Converts a byte array to a lowercase hex string. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java index 3c8f5e9..cfe4e48 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -8,8 +8,10 @@ import java.util.Map; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -61,6 +63,23 @@ public class UserEmailService { /** Hashes tokens before they are stored at rest. */ private final TokenHasher tokenHasher; + /** + * Self-reference, resolved through the Spring proxy, used to invoke {@link #createPasswordResetTokenForUser} + * so its {@code @Transactional} boundary actually applies. + * + *

+ * Calling {@code createPasswordResetTokenForUser(...)} directly from another method in this class + * ({@code this.createPasswordResetTokenForUser(...)}) is a self-invocation that bypasses the Spring proxy, + * so the {@code @Transactional} would never start and the {@code deleteByUser} + {@code save} could run in + * separate transactions — reopening the single-active-token race. Invoking through this proxied reference + * ensures the delete and save commit atomically. It is injected {@link Lazy} to break the construction-time + * circular dependency on itself. + *

+ */ + @Lazy + @Autowired + private UserEmailService self; + /** The configured app URL for admin-initiated password resets. */ @Value("${user.admin.appUrl:#{null}}") private String configuredAppUrl; @@ -85,7 +104,8 @@ public class UserEmailService { public void sendForgotPasswordVerificationEmail(final User user, final String appUrl) { log.debug("UserEmailService.sendForgotPasswordVerificationEmail: called for user: {}", user != null ? user.getEmail() : null); final String token = generateToken(); - createPasswordResetTokenForUser(user, token); + // Invoke through the proxy so the @Transactional boundary on createPasswordResetTokenForUser applies. + self.createPasswordResetTokenForUser(user, token); AuditEvent sendForgotPasswordEmailAuditEvent = AuditEvent.builder().source(this).user(user).action("sendForgotPasswordVerificationEmail") .actionStatus("Success").message("Forgot password email to be sent.").build(); @@ -256,7 +276,8 @@ public int initiateAdminPasswordReset(final User user, final String appUrl, fina // Step 1: Generate token and create password reset token (must succeed before invalidating sessions) final String token = generateToken(); - createPasswordResetTokenForUser(user, token); + // Invoke through the proxy so the @Transactional boundary on createPasswordResetTokenForUser applies. + self.createPasswordResetTokenForUser(user, token); // Step 2: Send password reset email (must succeed before invalidating sessions) sendPasswordResetEmail(user, appUrl, token); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 14ba32a..569fe45 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -437,11 +437,13 @@ private void savePasswordHistory(User user, String encodedPassword) { * Cleans up old password history entries for a user, keeping only the most recent entries. * *

- * This method runs within the caller's class-level transaction (it is invoked via - * self-invocation, so any method-level {@code @Transactional} would be bypassed by the proxy - * and never apply). Rather than load every history row and {@code deleteAll} the overflow - * (a read-then-delete window that races with concurrent inserts), it issues a single - * set-based, bounded delete: + * This is a private helper reached via {@code savePasswordHistory} on the call chain + * {@code changeUserPassword} → {@code self.persistChangedPassword} (proxied, {@code @Transactional}) + * → {@code savePasswordHistory} → {@code cleanUpPasswordHistory}. It therefore runs inside the + * transaction opened at {@code persistChangedPassword}; it carries no {@code @Transactional} of its + * own (one on a private/self-invoked method would be ignored by the proxy anyway). Rather than load + * every history row and {@code deleteAll} the overflow (a read-then-delete window that races with + * concurrent inserts), it issues a single set-based, bounded delete: *

*
    *
  1. Locate the id of the oldest entry to keep (the {@code maxEntries}-th most recent entry, diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index 625046a..bf23c66 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -74,7 +74,7 @@ private VerificationToken resolveByRawToken(final String rawToken) { * @return the user by verification token */ public User getUserByVerificationToken(final String verificationToken) { - log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", tokenFingerprint(verificationToken)); + log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", TokenHasher.fingerprint(verificationToken)); final VerificationToken token = resolveByRawToken(verificationToken); if (token != null) { log.debug("UserVerificationService.getUserByVerificationToken: user found: {}", @@ -186,7 +186,7 @@ public UserService.TokenValidationResult validateVerificationToken(String token) * @param token the raw token */ public void deleteVerificationToken(final String token) { - log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", tokenFingerprint(token)); + log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", TokenHasher.fingerprint(token)); final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken != null) { tokenRepository.delete(verificationToken); @@ -196,22 +196,4 @@ public void deleteVerificationToken(final String token) { } } - /** - * Produces a non-reversible fingerprint of a token for safe logging. Never logs the full token - * value (which is sensitive authentication material). - * - * @param token the raw token value - * @return "null" if the token is null, "****" for short tokens, otherwise the first 6 characters - * followed by an ellipsis - */ - private String tokenFingerprint(final String token) { - if (token == null) { - return "null"; - } - if (token.length() <= 8) { - return "****"; - } - return token.substring(0, 6) + "…"; - } - } diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java index 71bd0ba..a753a5f 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.registration; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -119,6 +120,36 @@ void secondDenyAfterFirstAllow() { } } + @Nested + @DisplayName("Null decisions (fail-fast)") + class NullDecisions { + + @Test + @DisplayName("A delegate returning null throws IllegalStateException instead of failing open") + void nullDecisionThrows() { + RegistrationGuard nullReturning = ctx -> null; + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(nullReturning)); + + assertThatThrownBy(() -> composite.evaluate(CONTEXT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("null decision"); + } + + @Test + @DisplayName("A later guard returning null still throws even after earlier guards allow") + void nullDecisionAfterAllowThrows() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + when(second.evaluate(CONTEXT)).thenReturn(null); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + assertThatThrownBy(() -> composite.evaluate(CONTEXT)) + .isInstanceOf(IllegalStateException.class); + } + } + @Test @DisplayName("getDelegates returns the composed guards in order") void getDelegatesReturnsGuardsInOrder() { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java index e21fbac..987d1e7 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java @@ -26,6 +26,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Method; import java.util.Map; @@ -62,6 +63,9 @@ class UserEmailServiceTest { void setUp() { userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, eventPublisher, sessionInvalidationService, tokenHasher); + // In production 'self' is the Spring proxy used to apply @Transactional on createPasswordResetTokenForUser. + // There is no proxy in a unit test, so point it at the instance itself to exercise the real call path. + ReflectionTestUtils.setField(userEmailService, "self", userEmailService); testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") From 589c3f221a87a0eb60a554a1d739958476dfa5f7 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 15:20:20 -0600 Subject: [PATCH 50/55] test: isolate each Spring context to a unique in-memory H2 database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests shared a single named DB (jdbc:h2:mem:testdb). Under JUnit parallel execution, concurrently-booting Spring contexts collided on schema DDL and role seeding against that one DB — intermittently producing 'Table "ROLE" already exists' and H2 LockTimeoutException during context load. Two tests already worked around this with dedicated DB names (lockouttest, userapitest); this fixes it for all of them at the source. Append ${random.uuid} to the H2 URL so each context resolves its own DB name once at startup: connections within a context still share one DB, while distinct contexts stay isolated. The TestContext cache key is unaffected (it keys on config, not resolved properties), so identically-configured tests still reuse one context. Verified: 3x testJdk25 + default test suite, 855 tests, 0 failures, no lock contention. --- src/test/resources/application-test.properties | 7 ++++++- src/test/resources/application.properties | 3 ++- src/test/resources/application.yml | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 7ef1b59..b6a79f8 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,7 +1,12 @@ # Test Profile Configuration # Database Configuration for H2 -spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# Per-context unique DB name (${random.uuid}) so each Spring context gets its own isolated in-memory +# database. JUnit runs test classes in parallel; a single shared name (jdbc:h2:mem:testdb) lets +# concurrently-booting contexts collide on schema DDL + role seeding (Table "ROLE" already exists / +# LockTimeoutException). The placeholder resolves once per context, so all connections within a context +# share one DB while distinct contexts stay isolated. +spring.datasource.url=jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 9c6e6f7..d8ca720 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -25,7 +25,8 @@ user.webauthn.enabled=false spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL +# Per-context unique DB name so parallel test contexts don't collide on a shared in-memory DB. +spring.datasource.url=jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;MODE=MySQL spring.datasource.username=sa spring.datasource.password=sa diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e0c60de..6a0edab 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,7 +1,8 @@ # Test configuration to ensure H2 uses proper dialect spring: datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + # Per-context unique DB name so parallel test contexts don't collide on a shared in-memory DB. + url: jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: From 93ec181f78c45e3ebd53cc80facd6fe16e505a60 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 15:26:55 -0600 Subject: [PATCH 51/55] ci: bump actions to latest majors (Node 24) Clears the Node 20 deprecation warnings and the CodeQL Action v3 deprecation: - actions/checkout v4 -> v6 (all workflows) - actions/setup-java v4 -> v5 - gradle/actions/setup-gradle v4 -> v6 - actions/upload-artifact v4 -> v7 - github/codeql-action init+analyze v3 -> v4 anthropics/claude-code-action stays at v1 (current major). --- .github/workflows/build.yml | 16 ++++++++-------- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0fe21a0..699c44a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,27 +13,27 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # Spring Boot 4.x requires Java 21+. The library compiles to Java 21 bytecode via the Gradle # toolchain; we additionally verify runtime compatibility on Java 25 (current LTS). Both JDKs are # installed; listing 21 last makes it the default JAVA_HOME, so Gradle runs on (and compiles with) # Java 21 and discovers Java 25 for the runtime-only testJdk25 task. - name: Set up JDK 21 (toolchain) and 25 (runtime) - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: | 25 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Build and test (compile + test on Java 21) run: ./gradlew check --no-daemon - name: Test on Java 25 (runtime compatibility) run: ./gradlew testJdk25 --no-daemon - name: Upload test reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-reports path: build/reports/tests/ @@ -43,15 +43,15 @@ jobs: permissions: security-events: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 with: distribution: temurin java-version: '21' - - uses: github/codeql-action/init@v3 + - uses: github/codeql-action/init@v4 with: languages: java-kotlin queries: security-extended - name: Build run: ./gradlew compileJava --no-daemon - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8452b0f..fd65da1 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..94dcce5 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 From 5a7578b794e4e00d4c74aee3ed7a6249e3e23ca2 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 15:45:21 -0600 Subject: [PATCH 52/55] fix(security): address second-pass review (session DoS, NPE, log injection) - SanitizingOAuth2AuthenticationFailureHandler: use getSession(false) so a failed OAuth2 callback does not allocate a session for sessionless callers (prevents unauthenticated scanners from forcing session creation). A real OAuth2 flow already has a session, so the user-facing message is preserved. - UserVerificationService.generateNewVerificationToken: guard against a null resolved token (unknown/consumed/malformed) and throw IllegalArgumentException instead of NPEing on updateToken. - TokenHasher.fingerprint: strip control chars (CR/LF) from the logged prefix to close CodeQL log-injection on token-derived log lines. - MfaFilterMergingConfiguration: convert tab indentation to 4 spaces per CLAUDE.md style. - Tests: model the real (session-present) OAuth2 callback and add a test asserting no session is created for a sessionless request. --- .../MfaFilterMergingConfiguration.java | 64 +++++++++---------- ...ingOAuth2AuthenticationFailureHandler.java | 10 ++- .../spring/user/service/TokenHasher.java | 11 +++- .../user/service/UserVerificationService.java | 7 ++ ...Auth2AuthenticationFailureHandlerTest.java | 21 ++++++ 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java index 9601224..1bdb8ed 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java @@ -39,36 +39,36 @@ @ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false) public class MfaFilterMergingConfiguration { - /** - * Replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal {@code EnableMfaFiltersPostProcessor} - * using only public API, by invoking the public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on - * every authentication processing filter. Without this, completing a second factor would REPLACE the first factor's - * authentication (dropping its authority) and the user could never satisfy all required factors (the H4 lockout). - * - *

    - * Declared {@code static} so the post-processor can be registered without eagerly instantiating this configuration - * class. The bean exists only when {@code user.mfa.enabled=true}, so the default (no-MFA) login path is unaffected. - *

    - * - * @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters - */ - @Bean - public static BeanPostProcessor mfaFilterMergingPostProcessor() { - return new BeanPostProcessor() { - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) { - // Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every authentication - // mechanism this framework configures: formLogin, webAuthn, and oauth2Login all extend it. SS's internal - // EnableMfaFiltersPostProcessor additionally flips the flag on AuthenticationFilter, - // BasicAuthenticationFilter, and pre-authentication filters; this framework does not configure those - // mechanisms, so they are deliberately not targeted here. See the class-level WARNING regarding - // consumer-defined filters that extend this base class. - if (bean instanceof AbstractAuthenticationProcessingFilter filter) { - filter.setMfaEnabled(true); - log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName()); - } - return bean; - } - }; - } + /** + * Replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal {@code EnableMfaFiltersPostProcessor} + * using only public API, by invoking the public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on + * every authentication processing filter. Without this, completing a second factor would REPLACE the first factor's + * authentication (dropping its authority) and the user could never satisfy all required factors (the H4 lockout). + * + *

    + * Declared {@code static} so the post-processor can be registered without eagerly instantiating this configuration + * class. The bean exists only when {@code user.mfa.enabled=true}, so the default (no-MFA) login path is unaffected. + *

    + * + * @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters + */ + @Bean + public static BeanPostProcessor mfaFilterMergingPostProcessor() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + // Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every authentication + // mechanism this framework configures: formLogin, webAuthn, and oauth2Login all extend it. SS's internal + // EnableMfaFiltersPostProcessor additionally flips the flag on AuthenticationFilter, + // BasicAuthenticationFilter, and pre-authentication filters; this framework does not configure those + // mechanisms, so they are deliberately not targeted here. See the class-level WARNING regarding + // consumer-defined filters that extend this base class. + if (bean instanceof AbstractAuthenticationProcessingFilter filter) { + filter.setMfaEnabled(true); + log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName()); + } + return bean; + } + }; + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java index e75dee1..1d9d292 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java @@ -8,6 +8,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; /** @@ -71,7 +72,14 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo log.debug("OAuth2 login failure detail", exception); // Store ONLY a generic, non-sensitive message for the UI. Never the raw exception message. - request.getSession().setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception)); + // Use getSession(false) so we do not allocate a session for callers that do not already have one: + // a legitimate OAuth2 attempt establishes a session during the authorization-request phase, while an + // unauthenticated scanner hitting the callback cold has none and should not be able to force session + // creation. When there is no session the login page simply shows its default message. + HttpSession session = request.getSession(false); + if (session != null) { + session.setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception)); + } response.sendRedirect(loginPageURI); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java index ac6b890..c88e5f4 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java @@ -99,9 +99,14 @@ public String hash(final String rawToken) { * (followed by an ellipsis) for longer tokens. Intended purely for correlating log lines, not for * any security decision. * + *

    + * The returned prefix is stripped of CR/LF and other control characters so that an attacker-supplied + * token (these values arrive as request parameters) cannot forge or split log lines (log injection). + *

    + * * @param token the raw token (may be {@code null}) * @return {@code "null"} if the token is {@code null}, {@code "****"} if it is 8 characters or fewer, - * otherwise the first 6 characters followed by an ellipsis + * otherwise the first 6 (control-character-stripped) characters followed by an ellipsis */ public static String fingerprint(final String token) { if (token == null) { @@ -110,7 +115,9 @@ public static String fingerprint(final String token) { if (token.length() <= 8) { return "****"; } - return token.substring(0, 6) + "…"; + // Strip control characters (incl. CR/LF) from the logged prefix to prevent log injection/forging. + final String prefix = token.substring(0, 6).replaceAll("\\p{Cntrl}", ""); + return prefix + "…"; } /** diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index bf23c66..75714c2 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -115,6 +115,13 @@ public VerificationToken getVerificationToken(final String verificationToken) { @Transactional public VerificationToken generateNewVerificationToken(final String existingVerificationToken) { VerificationToken vToken = resolveByRawToken(existingVerificationToken); + if (vToken == null) { + // The supplied token does not resolve to a stored row (unknown, already consumed, or malformed). + // Fail explicitly rather than NPE on the update below. + log.warn("UserVerificationService.generateNewVerificationToken: no token found for {}", + TokenHasher.fingerprint(existingVerificationToken)); + throw new IllegalArgumentException("No verification token found for the supplied value"); + } final String rawToken = UUID.randomUUID().toString(); // Store the hash of the new raw token; the raw value is what gets emailed to the user. vToken.updateToken(tokenHasher.hash(rawToken), verificationTokenValidityMinutes); diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java index 312de22..3cdf6ff 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java @@ -33,6 +33,9 @@ void setUp() { void shouldNotLeakRawMessageForLockedException() throws Exception { // Given - a LockedException whose message contains the account email (per Task 1.4) MockHttpServletRequest request = new MockHttpServletRequest(); + // A real OAuth2 callback already has a session (the authorization request is stored there during the + // redirect phase), so the handler stores the user-facing message on the existing session. + request.getSession(true); MockHttpServletResponse response = new MockHttpServletResponse(); LockedException raw = new LockedException("Account is locked for user secret.email@example.com"); @@ -55,6 +58,7 @@ void shouldNotLeakRawMessageForLockedException() throws Exception { void shouldNotLeakRawMessageForOAuth2Exception() throws Exception { // Given MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); MockHttpServletResponse response = new MockHttpServletResponse(); OAuth2AuthenticationException raw = new OAuth2AuthenticationException( new OAuth2Error("User Registered With Alternate Provider"), @@ -75,6 +79,7 @@ void shouldNotLeakRawMessageForOAuth2Exception() throws Exception { void shouldMapEmailNotVerifiedToSpecificGenericMessage() throws Exception { // Given MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); MockHttpServletResponse response = new MockHttpServletResponse(); OAuth2AuthenticationException raw = new OAuth2AuthenticationException( new OAuth2Error("email_not_verified"), "Email verified=false for victim@example.com"); @@ -88,4 +93,20 @@ void shouldMapEmailNotVerifiedToSpecificGenericMessage() throws Exception { assertThat(stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.EMAIL_NOT_VERIFIED_MESSAGE); assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); } + + @Test + @DisplayName("Should NOT allocate a session for a sessionless request (no session forced by scanners)") + void shouldNotCreateSessionWhenNonePresent() throws Exception { + // Given - a cold request with no existing session (e.g. an unauthenticated scanner hitting the callback) + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + LockedException raw = new LockedException("Account is locked for user secret.email@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then - no session is created (the handler uses getSession(false)) and the redirect still happens. + assertThat(request.getSession(false)).isNull(); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } } From 564943609553a2905b216d3f3442e83a79125611 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 16:47:52 -0600 Subject: [PATCH 53/55] =?UTF-8?q?fix(security):=20third-pass=20review=20?= =?UTF-8?q?=E2=80=94=20token=20replay,=20GDPR=20atomicity,=20audit=20integ?= =?UTF-8?q?rity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [P1] Token consume was replayable under concurrency: validateVerificationToken and validateAndConsumePasswordResetToken did read-check-delete, so two requests could both read a token before either delete committed. Make the conditional DELETE the atomic guard (deleteByToken returns a row count; apply the effect only when count==1). Added VerificationTokenRepository.deleteByToken. Added loser-path unit tests. [P2] GDPR deletion ran non-transactionally: deleteUser -> executeUserDeletion was a self-invocation that bypassed the @Transactional proxy, so the whole delete was non-atomic and the after-commit event fired immediately. Route through a @Lazy self proxy. [P2/P3] Audit records could be forged/corrupted via raw field text (CR/LF/pipe in User-Agent etc.). FileAuditLogWriter now sanitizes each field; updated the parser's stale comment. [P1/P2] Audit rotation hid retained history from query/export (reader only reads the active file). Default rotation OFF (maxFileSizeMb=0) so the regression cannot ship; multi-file archive reader is a planned follow-up. Refs review findings P1-P3. --- .../spring/user/audit/AuditConfig.java | 12 +++++-- .../user/audit/FileAuditLogQueryService.java | 9 ++--- .../spring/user/audit/FileAuditLogWriter.java | 28 ++++++++++++++-- .../spring/user/gdpr/GdprDeletionService.java | 18 ++++++++-- .../VerificationTokenRepository.java | 13 ++++++++ .../spring/user/service/UserService.java | 28 ++++++++++++---- .../user/service/UserVerificationService.java | 26 ++++++++++++--- ...itional-spring-configuration-metadata.json | 4 +-- .../config/dsspringuserconfig.properties | 6 ++-- .../user/gdpr/GdprDeletionServiceTest.java | 4 +++ .../service/TokenHashingSecurityTest.java | 14 ++++++-- .../service/UserVerificationServiceTest.java | 33 +++++++++++++++---- 12 files changed, 159 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java index c3fab19..1780560 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java @@ -62,10 +62,16 @@ public class AuditConfig { * Maximum size of the active audit log file, in megabytes, before it is rotated. * When the active log file exceeds this size, it is rotated: the current file is renamed to * {@code .1} (shifting any existing {@code .1} to {@code .2}, and so on, up to - * {@link #maxFiles}) and a fresh active file is opened. Set to {@code 0} or a negative value to - * disable rotation (logs grow unbounded). Default is {@code 10} (MB). + * {@link #maxFiles}) and a fresh active file is opened. Set to a positive value to enable rotation. + *

    + * Default is {@code 0} (rotation disabled). Rotation is opt-in because the audit + * query/export reader currently reads only the active log file; once rotation moves older events into + * {@code .1}, {@code .2}, ... they are no longer visible to GDPR exports or investigations. + * Enable rotation only when you have external log shipping/retention, or wait for the query reader to + * read rotated archives (planned follow-up). With the default, logs grow unbounded. + *

    */ - private int maxFileSizeMb = 10; + private int maxFileSizeMb = 0; /** * Maximum number of rotated audit log files to keep (e.g. {@code user-audit.log.1} .. diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java index 9bcd737..8831baa 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java @@ -183,10 +183,11 @@ private Path getLogFilePath() { /** * Parses a single line from the audit log file. * - *

    Note: This parser assumes the audit log writer properly escapes - * pipe characters in message content. If audit messages contain unescaped pipes, - * parsing may be corrupted. Consider migrating to a structured format (JSON lines) - * for production deployments with untrusted input. + *

    Note: {@code FileAuditLogWriter} sanitizes each field (stripping CR/LF and the {@code |} + * delimiter) before writing, so records produced by this library always have exactly ten fields on a + * single line. The defensive rejoin below remains only to tolerate pre-existing log files written before + * that sanitization, or files produced by other tooling. A structured format (JSON lines) is still the + * better long-term choice for deployments that ingest fully untrusted audit input. * * @param line the line to parse * @return the parsed AuditEventDTO, or null if parsing fails diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java index 20aa7d0..9f7c691 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java @@ -142,9 +142,15 @@ public synchronized void writeLog(AuditEvent event) { } } - String output = MessageFormat.format("{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}|{9}", event.getDate(), event.getAction(), - event.getActionStatus(), userId, userEmail, event.getIpAddress(), event.getSessionId(), event.getMessage(), event.getUserAgent(), - event.getExtraData()); + // Sanitize every text field before writing it into the pipe-delimited, line-oriented record. + // Fields such as user-agent, message, email and extra data can be attacker-influenced; an embedded + // newline would forge a fake record and an embedded pipe would shift columns. Stripping CR/LF and the + // delimiter guarantees each record stays on one line with exactly ten fields. The date is rendered by + // MessageFormat (no user content) so the query-service timestamp parser remains compatible. + String output = MessageFormat.format("{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}|{9}", event.getDate(), + sanitizeField(event.getAction()), sanitizeField(event.getActionStatus()), sanitizeField(userId), + sanitizeField(userEmail), sanitizeField(event.getIpAddress()), sanitizeField(event.getSessionId()), + sanitizeField(event.getMessage()), sanitizeField(event.getUserAgent()), sanitizeField(event.getExtraData())); bufferedWriter.write(output); bufferedWriter.newLine(); currentFileBytes += output.length() + 1L; // +1 approximates the newline @@ -161,6 +167,22 @@ public synchronized void writeLog(AuditEvent event) { } + /** + * Sanitizes a single field for the pipe-delimited, line-oriented audit format by removing CR, LF and the + * {@code |} delimiter (each replaced with a single space). This prevents log forging (an injected newline + * starting a fake record) and field corruption (an injected delimiter shifting columns) from + * attacker-influenced values such as the user agent, message, email, or extra data. + * + * @param value the raw field value (may be {@code null}) + * @return the value with CR/LF/{@code |} replaced by spaces, or an empty string when {@code value} is null + */ + private static String sanitizeField(final Object value) { + if (value == null) { + return ""; + } + return value.toString().replaceAll("[\\r\\n|]", " "); + } + /** * Flushes the buffered writer to ensure all data is written to the log file. This method is called by the {@link FileAuditLogFlushScheduler} to * ensure that the buffer is flushed periodically to balance performance with data integrity. diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java index 7f246ca..2ac4b70 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java @@ -1,6 +1,8 @@ package com.digitalsanctuary.spring.user.gdpr; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -46,6 +48,17 @@ public class GdprDeletionService { private final List dataContributors; private final ApplicationEventPublisher eventPublisher; + /** + * Self-reference resolved through the Spring proxy, used to invoke {@link #executeUserDeletion} so its + * {@code @Transactional} boundary actually applies. Calling {@code executeUserDeletion(...)} directly + * ({@code this.}) would be a self-invocation that bypasses the proxy, leaving the deletion non-transactional + * (partial deletes possible) and causing the after-commit event to fire immediately. Injected {@link Lazy} + * to break the construction-time circular dependency on itself. + */ + @Lazy + @Autowired + private GdprDeletionService self; + /** * Result of a GDPR deletion operation. */ @@ -131,8 +144,9 @@ public DeletionResult deleteUser(User user, boolean exportBeforeDeletion) { exportedData = gdprExportService.exportUserData(user); } - // Step 2: Perform deletion in transaction - return executeUserDeletion(user, exportedData, exportBeforeDeletion); + // Step 2: Perform deletion in transaction. Invoke through the proxy (self) so the @Transactional + // boundary on executeUserDeletion applies — a direct this-call would bypass it. + return self.executeUserDeletion(user, exportedData, exportBeforeDeletion); } catch (Exception e) { log.error("GdprDeletionService.deleteUser: Failed to delete user {}: {}", diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java index 5b05159..4750bab 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java @@ -59,4 +59,17 @@ public interface VerificationTokenRepository extends JpaRepository * + *

    + * Concurrency: the conditional {@code DELETE} is the atomicity guard, not the + * surrounding transaction. A plain read-check-delete would let two concurrent requests both read the + * row (under READ_COMMITTED) and both return the user before either delete commits. Instead we delete + * by token value and only return the user when the delete actually removed the row ({@code count == 1}); + * the row lock serializes concurrent deletes so exactly one caller wins. + *

    + * * @param token the raw token to validate and consume * @return the user associated with the token if it was valid, otherwise {@code null} */ @@ -682,14 +690,22 @@ public User validateAndConsumePasswordResetToken(final String token) { if (passToken == null) { return null; } - final Calendar cal = Calendar.getInstance(); - if (passToken.getExpiryDate().before(cal.getTime())) { - passwordTokenRepository.delete(passToken); + final User user = passToken.getUser(); + final boolean expired = passToken.getExpiryDate().before(Calendar.getInstance().getTime()); + + // Atomic single-use guard: consume by deleting the row and act only if THIS call removed it. + // Dual-delete mirrors the dual-read (hashed first, then pre-upgrade plaintext fallback). + int consumed = passwordTokenRepository.deleteByToken(tokenHasher.hash(token)); + if (consumed == 0) { + consumed = passwordTokenRepository.deleteByToken(token); + } + if (consumed == 0) { + // Another concurrent reset attempt consumed the token first. + return null; + } + if (expired) { return null; } - final User user = passToken.getUser(); - // Consume the token immediately so it cannot be reused. - passwordTokenRepository.delete(passToken); return user; } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index 75714c2..ce93950 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -158,6 +158,14 @@ public void createVerificationTokenForUser(final User user, final String token) * and plaintext (pre-upgrade) tokens resolve. * *

    + * Concurrency: the conditional {@code DELETE} is the atomicity guard, not the surrounding + * transaction. A plain read-check-delete would let two concurrent requests both read the row (under + * READ_COMMITTED) and both proceed before either delete commits. Instead we delete by token value and only + * apply the effect when the delete actually removed the row ({@code count == 1}); the row lock serializes + * concurrent deletes so exactly one caller wins and the rest are rejected. + *

    + * + *

    * Because this method consumes the token, callers must obtain any needed {@link User} reference (e.g. via * {@link #getUserByVerificationToken(String)}) before invoking it; a subsequent lookup by the same * raw token will no longer resolve. @@ -174,16 +182,24 @@ public UserService.TokenValidationResult validateVerificationToken(String token) } final User user = verificationToken.getUser(); - final Calendar cal = Calendar.getInstance(); - if (verificationToken.getExpiryDate().before(cal.getTime())) { - tokenRepository.delete(verificationToken); + final boolean expired = verificationToken.getExpiryDate().before(Calendar.getInstance().getTime()); + + // Atomic single-use guard: consume by deleting the row and acting only if THIS call removed it. + // Dual-delete mirrors the dual-read (hashed first, then pre-upgrade plaintext fallback). + int consumed = tokenRepository.deleteByToken(tokenHasher.hash(token)); + if (consumed == 0) { + consumed = tokenRepository.deleteByToken(token); + } + if (consumed == 0) { + // Another concurrent request consumed the token first; treat as already-used. + return UserService.TokenValidationResult.INVALID_TOKEN; + } + if (expired) { return UserService.TokenValidationResult.EXPIRED; } user.setEnabled(true); userRepository.save(user); - // Consume the token in the same transaction so it is single-use and cannot be replayed. - tokenRepository.delete(verificationToken); return UserService.TokenValidationResult.VALID; } diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index c8530aa..09914cf 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -226,8 +226,8 @@ { "name": "user.audit.maxFileSizeMb", "type": "java.lang.Integer", - "description": "Maximum size in megabytes of the active audit log file before it is rotated. When exceeded, the active file is renamed to .1 (shifting archives up to maxFiles) and a fresh file is opened. Set to 0 or negative to disable rotation (unbounded growth).", - "defaultValue": 10 + "description": "Maximum size in megabytes of the active audit log file before it is rotated. When exceeded, the active file is renamed to .1 (shifting archives up to maxFiles) and a fresh file is opened. Default is 0 (rotation disabled, unbounded growth): rotation is opt-in because the audit query/export reader currently reads only the active file, so rotated archives are not visible to GDPR exports/investigations. Set a positive value only with external log retention.", + "defaultValue": 0 }, { "name": "user.audit.maxFiles", diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 7cd4bd4..7582f51 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -29,8 +29,10 @@ user.audit.flushRate=30000 # Maximum size, in megabytes, of the active audit log file before it is rotated. When exceeded, the # current file is rotated to .1 (shifting older archives up to maxFiles) and a fresh file is opened. -# Set to 0 or negative to disable rotation (logs grow unbounded). Default is 10. -user.audit.maxFileSizeMb=10 +# Default is 0 (rotation DISABLED, logs grow unbounded). Rotation is opt-in because the audit query/export +# reader currently reads only the active file: once events are rotated into .1, .2, ... they are +# no longer visible to GDPR exports/investigations. Set a positive value only with external log retention. +user.audit.maxFileSizeMb=0 # Maximum number of rotated audit log files to retain (e.g. user-audit.log.1 .. user-audit.log.5). The # oldest archive beyond this count is deleted on rotation. Default is 5. diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java index acfb157..344c6a4 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java @@ -16,6 +16,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; import com.digitalsanctuary.spring.user.dto.GdprExportDTO; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; @@ -57,6 +58,9 @@ class GdprDeletionServiceTest { @BeforeEach void setUp() { + // In production 'self' is the Spring proxy used to apply @Transactional on executeUserDeletion. + // There is no proxy in a unit test, so point it at the SUT to exercise the real call path. + ReflectionTestUtils.setField(gdprDeletionService, "self", gdprDeletionService); testUser = UserTestDataBuilder.aVerifiedUser() .withId(1L) .withEmail("test@example.com") diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java index 3dd4859..de8d32f 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -195,13 +195,14 @@ void shouldFailWhenReusingConsumedToken() { entity.setUser(testUser); entity.setExpiryDate(future(60)); - // First consume: token found, then deleted + // First consume: token found, then the conditional delete removes it (count 1 = we won the race) when(passwordTokenRepository.findByToken(hashed)).thenReturn(entity, (PasswordResetToken) null); lenient().when(passwordTokenRepository.findByToken(rawToken)).thenReturn(null); + when(passwordTokenRepository.deleteByToken(hashed)).thenReturn(1); User consumed = userService.validateAndConsumePasswordResetToken(rawToken); assertThat(consumed).isEqualTo(testUser); - verify(passwordTokenRepository).delete(entity); + verify(passwordTokenRepository).deleteByToken(hashed); // Second consume: token no longer present -> null user User second = userService.validateAndConsumePasswordResetToken(rawToken); @@ -218,12 +219,13 @@ void shouldRejectAndCleanUpExpiredTokenOnConsume() { expired.setUser(testUser); expired.setExpiryDate(past(60)); when(passwordTokenRepository.findByToken(hashed)).thenReturn(expired); + when(passwordTokenRepository.deleteByToken(hashed)).thenReturn(1); User result = userService.validateAndConsumePasswordResetToken(rawToken); assertThat(result).isNull(); // The expired token is cleaned up (deleted) even though consumption is rejected. - verify(passwordTokenRepository).delete(expired); + verify(passwordTokenRepository).deleteByToken(hashed); } } @@ -292,6 +294,9 @@ void shouldValidatePreUpgradePlaintextVerificationToken() { legacy.setExpiryDate(future(60)); when(tokenRepository.findByToken(hashed)).thenReturn(null); when(tokenRepository.findByToken(rawToken)).thenReturn(legacy); + // Stored as plaintext under the raw value: the hashed delete removes 0, the raw fallback removes 1. + when(tokenRepository.deleteByToken(hashed)).thenReturn(0); + when(tokenRepository.deleteByToken(rawToken)).thenReturn(1); assertThat(verificationService.validateVerificationToken(rawToken)) .isEqualTo(UserService.TokenValidationResult.VALID); @@ -308,6 +313,9 @@ void shouldRejectExpiredPreUpgradePlaintextVerificationToken() { legacy.setExpiryDate(past(60)); when(tokenRepository.findByToken(hashed)).thenReturn(null); when(tokenRepository.findByToken(rawToken)).thenReturn(legacy); + // Stored as plaintext under the raw value: the hashed delete removes 0, the raw fallback removes 1. + when(tokenRepository.deleteByToken(hashed)).thenReturn(0); + when(tokenRepository.deleteByToken(rawToken)).thenReturn(1); assertThat(verificationService.validateVerificationToken(rawToken)) .isEqualTo(UserService.TokenValidationResult.EXPIRED); diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java index 2b0ef4c..a147756 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java @@ -16,6 +16,7 @@ import java.util.Date; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -55,12 +56,14 @@ void getUserByVerificationToken_returnsUserIfTokenExist() { void validateVerificationToken_returnsValidIfTokenValid() { testToken.setExpiryDate(getExpirationDate(1)); when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); - UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString()); - Assertions.assertEquals(result, UserService.TokenValidationResult.VALID); - // The user is enabled and the token is consumed (deleted) atomically so it is strictly single-use. + // The conditional delete is the single-use guard: a count of 1 means THIS call consumed the token. + when(verificationTokenRepository.deleteByToken(anyString())).thenReturn(1); + UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); + Assertions.assertEquals(UserService.TokenValidationResult.VALID, result); + // The user is enabled only after winning the delete; the token is consumed so it is strictly single-use. Assertions.assertTrue(testUser.isEnabled()); Mockito.verify(userRepository).save(testUser); - Mockito.verify(verificationTokenRepository).delete(testToken); + Mockito.verify(verificationTokenRepository).deleteByToken(anyString()); } @Test @@ -71,12 +74,15 @@ void validateVerificationToken_returnsExpiredIfTokenExpired() { // secret the hash is a deterministic SHA-256 of the raw value, so stub findByToken for any // argument to resolve the token regardless of which lookup the service performs. when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); + when(verificationTokenRepository.deleteByToken(anyString())).thenReturn(1); UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); Assertions.assertEquals(UserService.TokenValidationResult.EXPIRED, result); - // Expired tokens are cleaned up (deleted) as part of validation. - Mockito.verify(verificationTokenRepository).delete(testToken); + // Expired tokens are still consumed (deleted) as part of validation, but the user is NOT enabled. + Mockito.verify(verificationTokenRepository).deleteByToken(anyString()); + Mockito.verify(userRepository, never()).save(testToken.getUser()); + Assertions.assertFalse(testUser.isEnabled()); } @Test @@ -86,6 +92,21 @@ void validateVerificationToken_returnInvalidTokenIfTokenNotFound() { Assertions.assertEquals(result, UserService.TokenValidationResult.INVALID_TOKEN); } + @Test + void validateVerificationToken_returnsInvalidWhenConcurrentlyConsumed() { + // The token resolves (a concurrent caller has not yet committed its delete) but our conditional delete + // removes 0 rows because the other caller won the race. We must NOT enable the user in that case. + testToken.setExpiryDate(getExpirationDate(1)); + when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); + when(verificationTokenRepository.deleteByToken(anyString())).thenReturn(0); + + UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); + + Assertions.assertEquals(UserService.TokenValidationResult.INVALID_TOKEN, result); + Assertions.assertFalse(testUser.isEnabled()); + Mockito.verify(userRepository, never()).save(testToken.getUser()); + } + private Date getExpirationDate(int amount) { Date dt = new Date(); Calendar c = Calendar.getInstance(); From fb9694acbf034098a06b3388e1aa6fb2b99dc598 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 16:59:57 -0600 Subject: [PATCH 54/55] test(security): integration tests proving token-replay guard and GDPR after-commit fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AbstractConcurrentTokenConsumeTest (+ PostgreSQL/MariaDB Testcontainers subclasses): two threads race to consume the same password-reset and verification token; assert exactly one wins and the token is gone. Proves the conditional-DELETE single-use guard holds under real concurrency on production-grade databases (not just H2). - GdprDeletionAfterCommitIntegrationTest: proves executeUserDeletion now runs through the transactional proxy — the UserDeletedEvent is delivered from the afterCommit synchronization (synchronization active, user already deleted), and on a contributor failure the deletion rolls back atomically and NO event is published. These are the integration-level proofs the third-pass review requested for findings P1 (token replay) and P2 (GDPR atomicity). --- ...dprDeletionAfterCommitIntegrationTest.java | 195 ++++++++++++++++++ .../AbstractConcurrentTokenConsumeTest.java | 163 +++++++++++++++ .../MariaDBConcurrentTokenConsumeTest.java | 34 +++ .../PostgreSQLConcurrentTokenConsumeTest.java | 34 +++ 4 files changed, 426 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java new file mode 100644 index 0000000..4d97fe3 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java @@ -0,0 +1,195 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Integration test proving that {@link GdprDeletionService} actually runs the deletion inside a transaction and + * publishes {@link UserDeletedEvent} only after that transaction commits. This is the integration-level + * proof for the self-invocation fix: {@code deleteUser} now invokes {@code executeUserDeletion} through the Spring + * proxy ({@code self}), so the {@code @Transactional} boundary is honored. A unit test cannot verify this — the + * transactional proxy only exists in a real context. + * + *

    + * This test is deliberately not {@code @Transactional}: the service must run and commit (or roll + * back) its own transaction so the after-commit synchronization fires for real. Two behaviors are asserted: + *

    + *
      + *
    • Happy path: the user row is committed (gone) by the time the event listener runs, and no transaction is + * active during event delivery — i.e. the event is genuinely after-commit, not mid-transaction.
    • + *
    • Rollback path: when a data contributor throws, the whole deletion rolls back (the user still exists) and + * no {@link UserDeletedEvent} is published — proving the event is not emitted on a failed/partial delete.
    • + *
    + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +@Import(GdprDeletionAfterCommitIntegrationTest.TestBeans.class) +@DisplayName("GdprDeletionService after-commit / rollback integration") +class GdprDeletionAfterCommitIntegrationTest { + + @Autowired + private GdprDeletionService gdprDeletionService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DeletedEventRecorder recorder; + + @Autowired + private TogglableFailingContributor failingContributor; + + @AfterEach + void cleanUp() { + // No test-managed transaction here, so clean up committed rows and reset shared test state. + userRepository.deleteAll(); + recorder.reset(); + failingContributor.disarm(); + } + + @Test + @DisplayName("publishes UserDeletedEvent AFTER the deletion transaction commits") + void publishesEventAfterCommit() { + User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("after-commit-" + System.nanoTime() + "@test.com") + .withFirstName("After").withLastName("Commit").enabled().build()); + Long userId = user.getId(); + + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user, false); + + assertThat(result.isSuccess()).as("deletion should succeed").isTrue(); + assertThat(recorder.isReceived()).as("UserDeletedEvent should be published").isTrue(); + // The whole point of the fix: by the time the event fires, the delete has COMMITTED. + assertThat(recorder.wasUserAbsentAtEventTime()) + .as("user row must already be deleted when the event is delivered").isTrue(); + // Distinguishes the fix from the bug: the event is delivered from within the transaction's afterCommit + // synchronization (synchronization active). In the broken self-invocation version there was no transaction, + // so the event published immediately with NO synchronization active and this would be false. + assertThat(recorder.wasSynchronizationActiveAtEventTime()) + .as("event must be delivered inside the transaction's afterCommit synchronization").isTrue(); + assertThat(userRepository.findById(userId)).as("user is deleted").isEmpty(); + } + + @Test + @DisplayName("rolls back the deletion and publishes NO event when a contributor fails") + void rollsBackAndPublishesNoEventOnFailure() { + failingContributor.arm(); + User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("rollback-" + System.nanoTime() + "@test.com") + .withFirstName("Roll").withLastName("Back").enabled().build()); + Long userId = user.getId(); + + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user, false); + + assertThat(result.isSuccess()).as("deletion should fail when a contributor throws").isFalse(); + assertThat(recorder.isReceived()) + .as("no UserDeletedEvent may be published when the transaction rolled back").isFalse(); + assertThat(userRepository.findById(userId)) + .as("the user must still exist — the deletion transaction rolled back atomically").isPresent(); + } + + @TestConfiguration + static class TestBeans { + @Bean + DeletedEventRecorder deletedEventRecorder(UserRepository userRepository) { + return new DeletedEventRecorder(userRepository); + } + + @Bean + TogglableFailingContributor togglableFailingContributor() { + return new TogglableFailingContributor(); + } + } + + /** + * Records receipt of {@link UserDeletedEvent} and captures, at event-delivery time, whether the user row is + * already gone (committed) and whether a transaction is still active. Used to prove after-commit semantics. + */ + static class DeletedEventRecorder { + private final UserRepository userRepository; + private volatile boolean received; + private volatile boolean userAbsentAtEventTime; + private volatile boolean synchronizationActiveAtEventTime; + + DeletedEventRecorder(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @EventListener + void onUserDeleted(UserDeletedEvent event) { + received = true; + synchronizationActiveAtEventTime = TransactionSynchronizationManager.isSynchronizationActive(); + userAbsentAtEventTime = userRepository.findById(event.getUserId()).isEmpty(); + } + + boolean isReceived() { + return received; + } + + boolean wasUserAbsentAtEventTime() { + return userAbsentAtEventTime; + } + + boolean wasSynchronizationActiveAtEventTime() { + return synchronizationActiveAtEventTime; + } + + void reset() { + received = false; + userAbsentAtEventTime = false; + synchronizationActiveAtEventTime = false; + } + } + + /** + * A {@link GdprDataContributor} that throws during {@code prepareForDeletion} only when armed, used to force a + * transaction rollback in the middle of the deletion. + */ + static class TogglableFailingContributor implements GdprDataContributor { + private final AtomicBoolean armed = new AtomicBoolean(false); + + void arm() { + armed.set(true); + } + + void disarm() { + armed.set(false); + } + + @Override + public String getDataKey() { + return "test-failing-contributor"; + } + + @Override + public Map exportUserData(User user) { + return Map.of(); + } + + @Override + public void prepareForDeletion(User user) { + if (armed.get()) { + throw new RuntimeException("Simulated contributor failure to force rollback"); + } + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java new file mode 100644 index 0000000..a4a0006 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java @@ -0,0 +1,163 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +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.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Validates that single-use token consumption is truly atomic under concurrency on a real, production-grade database + * (not just H2). The fix makes the conditional {@code DELETE} (which returns the affected row count) the atomicity + * guard: the row lock serializes concurrent deletes, so exactly one caller observes a count of {@code 1} (and applies + * the effect) while the rest observe {@code 0} (and are rejected). A plain read-check-delete would let two requests + * both read the token under READ_COMMITTED and both succeed — the replay this test guards against. + * + *

    + * Two threads race to consume the SAME token at the same instant (released together via a {@link CountDownLatch}). + * Exactly one must win. This is asserted for both the password-reset consume path + * ({@link UserService#validateAndConsumePasswordResetToken(String)}) and the email-verification consume path + * ({@link UserVerificationService#validateVerificationToken(String)}). + *

    + * + *

    + * Subclasses provide a real database via Testcontainers. The class is deliberately NOT {@code @Transactional}: each + * consume must run in its own service-managed transaction on its own thread, and a test-managed transaction would + * defeat the race. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +abstract class AbstractConcurrentTokenConsumeTest { + + @Autowired + private UserService userService; + + @Autowired + private UserVerificationService userVerificationService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Autowired + private VerificationTokenRepository verificationTokenRepository; + + @Autowired + private TokenHasher tokenHasher; + + @AfterEach + void cleanUp() { + // The threads commit their own transactions, so clean up explicitly. + passwordResetTokenRepository.deleteAll(); + verificationTokenRepository.deleteAll(); + userRepository.deleteAll(); + } + + @RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]") + @DisplayName("password-reset token is consumed by exactly one of two racing threads") + void shouldConsumePasswordResetTokenExactlyOnceUnderConcurrency() throws InterruptedException { + final User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("pwd-race-" + System.nanoTime() + "@test.com") + .withFirstName("Pwd").withLastName("Race").enabled().build()); + final String rawToken = "pwd-reset-" + System.nanoTime(); + passwordResetTokenRepository.save(new PasswordResetToken(tokenHasher.hash(rawToken), user, 60)); + + final List outcomes = raceTwoThreads(() -> userService.validateAndConsumePasswordResetToken(rawToken)); + + final long wins = outcomes.stream().filter(o -> o instanceof User).count(); + assertThat(wins).as("exactly one thread may consume the password-reset token").isEqualTo(1); + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))) + .as("the token must be gone after consumption").isNull(); + } + + @RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]") + @DisplayName("verification token is consumed by exactly one of two racing threads") + void shouldConsumeVerificationTokenExactlyOnceUnderConcurrency() throws InterruptedException { + final User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("verify-race-" + System.nanoTime() + "@test.com") + .withFirstName("Verify").withLastName("Race").unverified().build()); + final String rawToken = "verify-" + System.nanoTime(); + verificationTokenRepository.save(new VerificationToken(tokenHasher.hash(rawToken), user, 60)); + + final List outcomes = + raceTwoThreads(() -> userVerificationService.validateVerificationToken(rawToken)); + + final long valid = outcomes.stream() + .filter(o -> o == UserService.TokenValidationResult.VALID).count(); + assertThat(valid).as("exactly one thread may validate (consume) the verification token").isEqualTo(1); + assertThat(verificationTokenRepository.findByToken(tokenHasher.hash(rawToken))) + .as("the token must be gone after consumption").isNull(); + assertThat(userRepository.findById(user.getId())) + .get().extracting(User::isEnabled).as("the user is enabled exactly once").isEqualTo(true); + } + + /** + * Runs the given consume action on two threads released simultaneously and returns both outcomes. + * + * @param consume the consume action under test + * @return the two outcomes (a result object, or {@code null} for the losing/rejected call) + */ + private List raceTwoThreads(final Callable consume) throws InterruptedException { + final int threadCount = 2; + final CountDownLatch readyLatch = new CountDownLatch(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + final List> futures = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(() -> { + readyLatch.countDown(); + if (!startLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("start gate was never opened"); + } + return consume.call(); + })); + } + + assertThat(readyLatch.await(30, TimeUnit.SECONDS)) + .as("both consume threads should reach the start gate").isTrue(); + startLatch.countDown(); + + final AtomicInteger unexpected = new AtomicInteger(); + final List outcomes = new ArrayList<>(); + for (Future future : futures) { + try { + outcomes.add(future.get(60, TimeUnit.SECONDS)); + } catch (ExecutionException e) { + unexpected.incrementAndGet(); + } catch (Exception e) { + unexpected.incrementAndGet(); + } + } + assertThat(unexpected.get()).as("neither consume call should throw").isZero(); + return outcomes; + } finally { + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java new file mode 100644 index 0000000..0d176c4 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java @@ -0,0 +1,34 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent single-use token-consume race against a real MariaDB container, proving the conditional DELETE + * guard prevents token replay under InnoDB's default isolation. + */ +@Testcontainers +@DisplayName("MariaDB Concurrent Token Consume Tests") +class MariaDBConcurrentTokenConsumeTest extends AbstractConcurrentTokenConsumeTest { + + @Container + static final MariaDBContainer MARIADB = new MariaDBContainer<>("mariadb:11.4") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MARIADB::getJdbcUrl); + registry.add("spring.datasource.username", MARIADB::getUsername); + registry.add("spring.datasource.password", MARIADB::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java new file mode 100644 index 0000000..c793e0b --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java @@ -0,0 +1,34 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent single-use token-consume race against a real PostgreSQL container, proving the conditional + * DELETE guard prevents token replay under READ_COMMITTED. + */ +@Testcontainers +@DisplayName("PostgreSQL Concurrent Token Consume Tests") +class PostgreSQLConcurrentTokenConsumeTest extends AbstractConcurrentTokenConsumeTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } +} From 2c939bb0665bca3e82620ee6d88398c83ac7a190 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 14 Jun 2026 17:48:50 -0600 Subject: [PATCH 55/55] fix(security): make self-proxied transactional persist methods protected (not package-private) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerNewUserAccount, changeUserPassword, and setInitialPassword route their DB write back through the @Lazy self proxy (self.persistNewUserAccount / persistChangedPassword / persistInitialPassword) so the short @Transactional applies after the bcrypt hash runs outside any transaction. Those persist* methods were package-private. In Spring Framework 7 the CGLIB proxy subclass is generated in a different package, so it cannot override package-private methods: self.persistX(...) then executes the inherited body on the proxy instance — whose @Autowired fields are never injected — throwing NullPointerException ("this.userRepository is null") and silently dropping the transaction. This broke registration, password change, password reset, and set-initial-password for consumers. Caught by the demo-app Playwright E2E (Spring Boot 4.0.4); the library's own CI runs 4.0.6, where the behavior is masked — so a @SpringBootTest on the pinned CI version cannot be relied on to catch it. Fix by making the three methods protected (CGLIB overrides public/protected across packages; matches the already-correct protected GdprDeletionService.executeUserDeletion and public UserEmailService.createPasswordResetTokenForUser). Add SelfProxiedMethodVisibilityTest: a version-independent, bytecode-level guard that fails fast if any self-proxied method is ever made package-private/private again. --- .../spring/user/service/UserService.java | 31 +++++--- .../SelfProxiedMethodVisibilityTest.java | 78 +++++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index d1e7d32..f20b8f7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -372,10 +372,13 @@ public User registerNewUserAccount(final UserDto newUserDto) { *

    * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is - * deliberately package-private so consumers cannot call it directly and bypass the - * centralized RegistrationGuard enforced by {@link #registerNewUserAccount(UserDto)}; CGLIB - * self-invocation still applies the transaction because Spring's proxy subclass is generated in - * this same package. + * deliberately protected so consumers cannot call it directly and bypass the centralized + * RegistrationGuard enforced by {@link #registerNewUserAccount(UserDto)}. It must be + * {@code protected} rather than package-private: the CGLIB proxy subclass is generated in a + * different package, so it can only override (and therefore advise/route) {@code public} or + * {@code protected} methods. A package-private method is not overridden, so the {@code self} + * invocation would execute on the proxy instance — whose {@code @Autowired} fields are never + * populated — and both the transaction and the dependencies would be missing. *

    * * @param user the fully built user entity (password already encoded) @@ -383,7 +386,7 @@ public User registerNewUserAccount(final UserDto newUserDto) { * @throws UserAlreadyExistException if an account with the same email already exists */ @Transactional(isolation = Isolation.SERIALIZABLE) - User persistNewUserAccount(final User user) { + protected User persistNewUserAccount(final User user) { if (emailExists(user.getEmail())) { log.debug("UserService.persistNewUserAccount: email already exists: {}", user.getEmail()); throw new UserAlreadyExistException( @@ -755,15 +758,18 @@ public void changeUserPassword(final User user, final String password) { *

    * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is - * deliberately package-private so it is not part of the public API; CGLIB self-invocation - * still applies the transaction because Spring's proxy subclass is generated in this same package. + * protected so it stays out of the public API yet remains overridable by the CGLIB proxy + * subclass — which is generated in a different package and therefore cannot override a + * package-private method. A package-private method would not be advised and the {@code self} + * invocation would run on the proxy instance (whose {@code @Autowired} fields are null), so it + * must be {@code protected} (or public). *

    * * @param user the user whose password changed (password field already set/encoded) * @param encodedPassword the already-encoded password to record in history */ @Transactional - void persistChangedPassword(final User user, final String encodedPassword) { + protected void persistChangedPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); // Force re-auth on a password change (OWASP). By default the current session is preserved and @@ -856,15 +862,18 @@ public void setInitialPassword(User user, String rawPassword) { *

    * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is - * deliberately package-private so it is not part of the public API; CGLIB self-invocation - * still applies the transaction because Spring's proxy subclass is generated in this same package. + * protected so it stays out of the public API yet remains overridable by the CGLIB proxy + * subclass — which is generated in a different package and therefore cannot override a + * package-private method. A package-private method would not be advised and the {@code self} + * invocation would run on the proxy instance (whose {@code @Autowired} fields are null), so it + * must be {@code protected} (or public). *

    * * @param user the user whose initial password is being set (password field already set) * @param encodedPassword the already-encoded password to record in history */ @Transactional - void persistInitialPassword(final User user, final String encodedPassword) { + protected void persistInitialPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java b/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java new file mode 100644 index 0000000..1447829 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java @@ -0,0 +1,78 @@ +package com.digitalsanctuary.spring.user.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import com.digitalsanctuary.spring.user.gdpr.GdprDeletionService; +import com.digitalsanctuary.spring.user.service.UserEmailService; +import com.digitalsanctuary.spring.user.service.UserService; + +/** + * Structural guard for the Spring self-invocation proxy pattern used across the service layer. + * + *

    + * Several services split a slow, non-transactional step (e.g. bcrypt hashing) from the short DB write by routing the + * write back through their own Spring proxy: {@code self.persistX(...)} where {@code self} is an + * {@code @Lazy @Autowired} reference to the same bean. For that to work the target method must be + * {@code public} or {@code protected}: Spring generates the CGLIB proxy subclass in a different + * package, so it can only override (and therefore advise + route) {@code public}/{@code protected} methods. A + * package-private target is not overridden, so {@code self.persistX(...)} executes the inherited body on the + * proxy instance — whose {@code @Autowired} fields were never populated — yielding a {@link NullPointerException} (e.g. + * {@code "this.userRepository" is null}) and silently dropping the transaction. + *

    + * + *

    + * This bug is version-dependent at runtime: it reproduces on some Spring Framework patch releases and + * is masked on others, so a {@code @SpringBootTest} on the CI's pinned Spring version cannot be relied on to catch a + * regression. This bytecode-level visibility check is version-independent and fails fast if any self-proxied method is + * ever made package-private (or private) again. + *

    + * + *

    + * Note: this rule is intentionally scoped to the specific methods invoked via {@code self}. Other package-private + * {@code @Transactional} helpers (e.g. {@code RolePrivilegeSetupService.getOrCreateRole}) are called via {@code this} + * from within an already-transactional method and run in the caller's transaction, so they do not need to be proxied. + *

    + */ +@DisplayName("Self-proxied (@Lazy self) transactional methods must be proxyable (public/protected)") +class SelfProxiedMethodVisibilityTest { + + /** + * Every method that is invoked through a {@code self} proxy reference in the production code. Keep this list in + * sync with the {@code self.(...)} call sites in the service/gdpr packages. + */ + static List selfProxiedMethods() { + return List.of(Arguments.of(UserService.class, "persistNewUserAccount"), + Arguments.of(UserService.class, "persistChangedPassword"), + Arguments.of(UserService.class, "persistInitialPassword"), + Arguments.of(GdprDeletionService.class, "executeUserDeletion"), + Arguments.of(UserEmailService.class, "createPasswordResetTokenForUser")); + } + + @ParameterizedTest(name = "{0}#{1} is public or protected") + @MethodSource("selfProxiedMethods") + void selfInvokedMethodMustBeProxyable(final Class declaringClass, final String methodName) { + final List matches = Arrays.stream(declaringClass.getDeclaredMethods()) + .filter(m -> m.getName().equals(methodName)).toList(); + + assertThat(matches).as("expected to find method %s#%s — has it been renamed or removed? Update this guard.", + declaringClass.getSimpleName(), methodName).isNotEmpty(); + + for (final Method method : matches) { + final int modifiers = method.getModifiers(); + assertThat(Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers)) + .as("%s#%s is invoked through the Spring self-proxy and MUST be public or protected; a " + + "package-private/private method is not overridden by the CGLIB proxy subclass (generated " + + "in a different package), so the self-invocation runs on the un-injected proxy instance " + + "and throws NPE while silently losing the transaction.", + declaringClass.getSimpleName(), methodName) + .isTrue(); + } + } +}