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:
+ *
+ *
+ * Locate the id of the oldest entry to keep (the {@code maxEntries}-th most recent entry,
+ * ordered by primary key descending).
+ * Delete all of the user's entries with an id strictly less than that cutoff.
+ *
+ *
+ *
+ * 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:
+ *
+ *
+ * {@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.
+ * 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").
+ *
+ *
+ * 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:
- *
- *
- * {@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.
- * 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").
- *
- *
- * 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.
*
- *
- * A reflection assertion that the auto-configuration's {@code securityFilterChain} method carries
- * {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} and a low-precedence {@code @Order}.
- * 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.
- *
*/
@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:
*
*
* 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();
+ }
+ }
+}