Skip to content

Commit c3dd50c

Browse files
authored
Merge pull request #74 from devondragon/chore/upgrade-ds-5.0.1
Upgrade to ds-spring-user-framework 5.0.1
2 parents eefad16 + d61722d commit c3dd50c

11 files changed

Lines changed: 110 additions & 48 deletions

File tree

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ repositories {
3939

4040
dependencies {
4141
// DigitalSanctuary Spring User Framework
42-
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.4.0'
42+
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.0.1'
4343

4444
// WebAuthn support (Passkey authentication)
4545
implementation 'org.springframework.security:spring-security-webauthn'

playwright/tests/auth/registration.spec.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ test.describe('Registration', () => {
6060
});
6161

6262
test.describe('Validation', () => {
63-
test('should reject registration with existing email', async ({
63+
test('should not reveal account existence when registering with an existing email (anti-enumeration)', async ({
6464
page,
6565
registerPage,
6666
testApiClient,
@@ -87,13 +87,32 @@ test.describe('Registration', () => {
8787
existingUser.password
8888
);
8989
await registerPage.acceptTerms();
90-
await registerPage.submit();
9190

92-
// Registration uses async fetch — wait for the error element to appear
93-
// rather than waiting for page navigation (which doesn't happen)
94-
const globalError = page.locator('#globalError');
95-
const existingAccountError = page.locator('#existingAccountError');
96-
await expect(globalError.or(existingAccountError)).toBeVisible({ timeout: 10000 });
91+
// SpringUserFramework 5.0.0 (task 4.2): registering an existing email returns the SAME
92+
// uniform 200 response as a brand-new registration — it must NOT reveal that the account
93+
// already exists. (Prior versions returned 409 and surfaced #globalError /
94+
// #existingAccountError, which leaked account existence to an attacker.)
95+
const [response] = await Promise.all([
96+
page.waitForResponse(
97+
(r) =>
98+
r.url().endsWith('/user/registration') &&
99+
r.request().method() === 'POST'
100+
),
101+
registerPage.submit(),
102+
]);
103+
expect(response.status()).toBe(200);
104+
105+
// No error revealing the existing account is shown...
106+
await expect(page.locator('#globalError')).toBeHidden();
107+
await expect(page.locator('#existingAccountError')).toBeHidden();
108+
109+
// ...and the flow lands on the generic pending page, indistinguishable from a
110+
// new (unverified) registration.
111+
await page.waitForURL(/registration-pending/, { timeout: 10000 });
112+
113+
// The original account is untouched — no duplicate created or overwritten.
114+
const userExists = await testApiClient.userExists(existingUser.email);
115+
expect(userExists.exists).toBe(true);
97116
});
98117

99118
test('should reject mismatched passwords', async ({

src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.springframework.stereotype.Service;
1010

1111
import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository;
12+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
1213
import com.digitalsanctuary.spring.user.persistence.model.User;
1314
import com.digitalsanctuary.spring.user.mail.MailService;
1415
import com.digitalsanctuary.spring.user.service.SessionInvalidationService;
@@ -36,10 +37,11 @@ public CustomUserEmailService(
3637
MailService mailService,
3738
UserVerificationService userVerificationService,
3839
PasswordResetTokenRepository passwordTokenRepository,
40+
UserRepository userRepository,
3941
ApplicationEventPublisher eventPublisher,
4042
SessionInvalidationService sessionInvalidationService,
4143
TokenHasher tokenHasher) {
42-
super(mailService, userVerificationService, passwordTokenRepository, eventPublisher, sessionInvalidationService, tokenHasher);
44+
super(mailService, userVerificationService, passwordTokenRepository, userRepository, eventPublisher, sessionInvalidationService, tokenHasher);
4345
}
4446

4547
@Override

src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ public ResponseEntity<Map<String, Object>> deleteTestUser(@RequestParam String e
236236

237237
// Let framework listeners clean up their data first (e.g. WebAuthn credentials and user
238238
// entities, which have a foreign key on the user account)
239-
eventPublisher.publishEvent(new UserPreDeleteEvent(this, user));
239+
eventPublisher.publishEvent(new UserPreDeleteEvent(this, user.getId(), user.getEmail()));
240240

241241
// Delete related entities first to avoid foreign key constraints
242242
demoUserProfileRepository.findById(user.getId()).ifPresent(demoUserProfileRepository::delete);

src/main/java/com/digitalsanctuary/spring/demo/user/profile/UserProfileDeletionListener.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class UserProfileDeletionListener {
2121
@EventListener
2222
@Transactional // Joins the transaction started by UserService.deleteUserAccount
2323
public void handleUserPreDelete(UserPreDeleteEvent event) {
24-
Long userId = event.getUser().getId();
24+
Long userId = event.getUserId();
2525
log.info("Received UserPreDeleteEvent for userId: {}. Deleting associated DemoUserProfile...", userId);
2626

2727
// Option 1: Delete profile directly (if no further cascades needed from profile)

src/test/java/com/digitalsanctuary/spring/user/api/UserApiIntegrationTestFixed.java

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.springframework.beans.factory.annotation.Autowired;
1313
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
1414
import org.springframework.boot.test.context.SpringBootTest;
15+
import org.springframework.dao.DataAccessException;
1516
import org.springframework.http.MediaType;
1617
import org.springframework.test.context.ActiveProfiles;
1718
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -99,15 +100,35 @@ void tearDown() {
99100
* would roll back with the test and never actually remove the committed registration row.
100101
*/
101102
private void deleteTestUserCommitted() {
102-
TransactionTemplate tx = new TransactionTemplate(transactionManager);
103-
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
104-
tx.executeWithoutResult(status -> {
105-
User existing = userRepository.findByEmail(TEST_EMAIL);
106-
if (existing != null) {
107-
verificationTokenRepository.deleteByUser(existing);
108-
userRepository.delete(existing);
103+
// Registration creates the verification token via an @Async listener (the demo app enables @Async on
104+
// UserDemoApplication), so the token can be written shortly AFTER the registration request returns. A
105+
// single committed delete can race that write: deleteByUser runs before the token row exists, then the
106+
// user delete trips FK_VERIFY_USER. Retry the committed cleanup until the async token has settled and
107+
// the delete succeeds (bounded so a genuine failure still surfaces).
108+
DataAccessException last = null;
109+
for (int attempt = 0; attempt < 10; attempt++) {
110+
try {
111+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
112+
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
113+
tx.executeWithoutResult(status -> {
114+
User existing = userRepository.findByEmail(TEST_EMAIL);
115+
if (existing != null) {
116+
verificationTokenRepository.deleteByUser(existing);
117+
userRepository.delete(existing);
118+
}
119+
});
120+
return;
121+
} catch (DataAccessException ex) {
122+
last = ex;
123+
try {
124+
Thread.sleep(100);
125+
} catch (InterruptedException ie) {
126+
Thread.currentThread().interrupt();
127+
throw new IllegalStateException("Interrupted while cleaning up the committed test user", ie);
128+
}
109129
}
110-
});
130+
}
131+
throw new IllegalStateException("Failed to delete the committed test user after retries", last);
111132
}
112133

113134
@Test
@@ -118,7 +139,7 @@ void shouldRegisterNewUser() throws Exception {
118139
.perform(post(API_BASE_PATH + "/registration").contentType(MediaType.APPLICATION_JSON)
119140
.content(objectMapper.writeValueAsString(testUserDto)).with(csrf()))
120141
.andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true))
121-
.andExpect(jsonPath("$.messages[0]").value("Registration Successful!")).andReturn();
142+
.andExpect(jsonPath("$.messages[0]").value("If your email address is eligible, you will receive a verification email shortly.")).andReturn();
122143

123144
// Then - Verify user was created
124145
User savedUser = userRepository.findByEmail("test@example.com");
@@ -129,16 +150,18 @@ void shouldRegisterNewUser() throws Exception {
129150
}
130151

131152
@Test
132-
@DisplayName("Should return conflict for duplicate email")
133-
void shouldReturnConflictForDuplicateEmail() throws Exception {
153+
@DisplayName("Should not reveal account existence for duplicate email (anti-enumeration)")
154+
void shouldNotRevealAccountExistenceForDuplicateEmail() throws Exception {
134155
// Given - Register first user
135156
userService.registerNewUserAccount(testUserDto);
136157

137158
// When - Try to register with same email
159+
// Then - anti-enumeration: a duplicate email returns the SAME generic 200 success body as a new
160+
// registration, so a caller cannot distinguish an already-registered address from a new one.
138161
mockMvc.perform(post(API_BASE_PATH + "/registration").contentType(MediaType.APPLICATION_JSON)
139-
.content(objectMapper.writeValueAsString(testUserDto)).with(csrf())).andExpect(status().isConflict())
140-
.andExpect(jsonPath("$.success").value(false))
141-
.andExpect(jsonPath("$.messages[0]").value("An account already exists for the email address"));
162+
.content(objectMapper.writeValueAsString(testUserDto)).with(csrf())).andExpect(status().isOk())
163+
.andExpect(jsonPath("$.success").value(true))
164+
.andExpect(jsonPath("$.messages[0]").value("If your email address is eligible, you will receive a verification email shortly."));
142165
}
143166

144167
@Test

src/test/java/com/digitalsanctuary/spring/user/api/UserApiSimpleTest.java

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ void shouldRegisterNewUser() throws Exception {
7575
// Then
7676
result.andExpect(status().isOk())
7777
.andExpect(jsonPath("$.success").value(true))
78-
.andExpect(jsonPath("$.messages[0]").value("Registration Successful!"));
79-
78+
.andExpect(jsonPath("$.messages[0]").value("If your email address is eligible, you will receive a verification email shortly."));
79+
8080
// Verify user created
8181
User user = userRepository.findByEmail("simple.test@example.com");
8282
assertThat(user).isNotNull();
@@ -86,8 +86,8 @@ void shouldRegisterNewUser() throws Exception {
8686
}
8787

8888
@Test
89-
@DisplayName("Should return conflict for duplicate email")
90-
void shouldReturnConflictForDuplicateEmail() throws Exception {
89+
@DisplayName("Should not reveal account existence for duplicate email (anti-enumeration)")
90+
void shouldNotRevealAccountExistenceForDuplicateEmail() throws Exception {
9191
// Given - Register first user
9292
UserDto firstUser = new UserDto();
9393
firstUser.setFirstName("First");
@@ -115,10 +115,11 @@ void shouldReturnConflictForDuplicateEmail() throws Exception {
115115
.content(objectMapper.writeValueAsString(duplicateUser))
116116
.with(csrf()));
117117

118-
// Then
119-
result.andExpect(status().isConflict())
120-
.andExpect(jsonPath("$.success").value(false))
121-
.andExpect(jsonPath("$.messages[0]").value("An account already exists for the email address"));
118+
// Then - anti-enumeration: a duplicate email returns the SAME generic 200 success body as a new
119+
// registration, so a caller cannot distinguish an already-registered address from a new one.
120+
result.andExpect(status().isOk())
121+
.andExpect(jsonPath("$.success").value(true))
122+
.andExpect(jsonPath("$.messages[0]").value("If your email address is eligible, you will receive a verification email shortly."));
122123
}
123124

124125
@Test

src/test/java/com/digitalsanctuary/spring/user/integration/AuthorityServiceIntegrationTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ void setUp() {
6161
userRepository.deleteAll();
6262
roleRepository.deleteAll();
6363
privilegeRepository.deleteAll();
64+
// Force the DELETEs to hit the DB before the role/privilege INSERTs below. The framework seeds the
65+
// configured roles and privileges at startup, so without an explicit flush Hibernate's action-queue
66+
// ordering would run the new INSERTs before these DELETEs and trip the unique ROLE(NAME) /
67+
// PRIVILEGE(NAME) indexes (added in 5.0.0).
68+
roleRepository.flush();
69+
privilegeRepository.flush();
6470

6571
// Create privileges as defined in config
6672
Privilege loginPrivilege = createAndSavePrivilege("LOGIN_PRIVILEGE", "Allows user login");

src/test/java/com/digitalsanctuary/spring/user/integration/DSUserDetailsServiceIntegrationTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ void setUp() {
7272
passwordHistoryRepository.deleteAll();
7373
userRepository.deleteAll();
7474
roleRepository.deleteAll();
75+
// Force the DELETEs to hit the DB before the role INSERTs below. The framework seeds the configured
76+
// roles (ROLE_USER/ROLE_ADMIN/...) at startup, so without an explicit flush Hibernate's action-queue
77+
// ordering would run the new-role INSERTs before these DELETEs and trip the unique ROLE(NAME) index.
78+
roleRepository.flush();
7579

7680
// Create privileges
7781
Privilege userPrivilege = new Privilege();

src/test/java/com/digitalsanctuary/spring/user/integration/EventSystemIntegrationTest.java

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.ArgumentMatchers.anyLong;
6+
import static org.mockito.ArgumentMatchers.anyString;
57
import static org.mockito.Mockito.doAnswer;
68
import static org.mockito.Mockito.timeout;
79
import static org.mockito.Mockito.verify;
@@ -88,38 +90,39 @@ void registrationEvent_triggersEmailService() throws Exception {
8890
doAnswer(invocation -> {
8991
latch.countDown();
9092
return null;
91-
}).when(userEmailService).sendRegistrationVerificationEmail(any(), any());
93+
}).when(userEmailService).sendRegistrationVerificationEmail(anyLong(), anyString());
9294

9395
// When
94-
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder().user(testUser).locale(locale).appUrl(appUrl).build();
96+
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder().userId(testUser.getId()).userEmail(testUser.getEmail())
97+
.userEnabled(testUser.isEnabled()).locale(locale).appUrl(appUrl).build();
9598
eventPublisher.publishEvent(event);
9699

97100
// Then
98101
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
99-
verify(userEmailService).sendRegistrationVerificationEmail(testUser, appUrl);
102+
verify(userEmailService).sendRegistrationVerificationEmail(testUser.getId(), appUrl);
100103
assertThat(eventCapture.getCapturedEvents()).filteredOn(e -> e instanceof OnRegistrationCompleteEvent).hasSize(1);
101104
}
102105

103106
@Test
104107
@DisplayName("Multiple registration events are handled independently")
105108
void multipleRegistrationEvents_handledIndependently() throws Exception {
106109
// Given
107-
User user1 = UserTestDataBuilder.aUser().withEmail("user1@example.com").build();
108-
User user2 = UserTestDataBuilder.aUser().withEmail("user2@example.com").build();
110+
User user1 = UserTestDataBuilder.aUser().withId(10L).withEmail("user1@example.com").build();
111+
User user2 = UserTestDataBuilder.aUser().withId(20L).withEmail("user2@example.com").build();
109112
CountDownLatch latch = new CountDownLatch(2);
110113
doAnswer(invocation -> {
111114
latch.countDown();
112115
return null;
113-
}).when(userEmailService).sendRegistrationVerificationEmail(any(), any());
116+
}).when(userEmailService).sendRegistrationVerificationEmail(anyLong(), anyString());
114117

115118
// When
116-
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(user1, Locale.ENGLISH, "app1"));
117-
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(user2, Locale.FRENCH, "app2"));
119+
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(user1.getId(), user1.getEmail(), user1.isEnabled(), Locale.ENGLISH, "app1"));
120+
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(user2.getId(), user2.getEmail(), user2.isEnabled(), Locale.FRENCH, "app2"));
118121

119122
// Then
120123
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
121-
verify(userEmailService).sendRegistrationVerificationEmail(user1, "app1");
122-
verify(userEmailService).sendRegistrationVerificationEmail(user2, "app2");
124+
verify(userEmailService).sendRegistrationVerificationEmail(user1.getId(), "app1");
125+
verify(userEmailService).sendRegistrationVerificationEmail(user2.getId(), "app2");
123126
}
124127
}
125128

@@ -167,14 +170,14 @@ class UserDeletionEventFlowTests {
167170
@DisplayName("UserPreDeleteEvent is captured correctly")
168171
void userPreDeleteEvent_capturedCorrectly() {
169172
// When
170-
UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser);
173+
UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser.getId(), testUser.getEmail());
171174
eventPublisher.publishEvent(event);
172175

173176
// Then
174177
assertThat(eventCapture.getCapturedEvents()).filteredOn(e -> e instanceof UserPreDeleteEvent).hasSize(1).first().satisfies(e -> {
175178
UserPreDeleteEvent deleteEvent = (UserPreDeleteEvent) e;
176-
assertThat(deleteEvent.getUser()).isEqualTo(testUser);
177179
assertThat(deleteEvent.getUserId()).isEqualTo(1L);
180+
assertThat(deleteEvent.getUserEmail()).isEqualTo(testUser.getEmail());
178181
});
179182
}
180183
}
@@ -217,7 +220,7 @@ void events_processedInOrder() throws Exception {
217220
processedEvents.add("registration");
218221
latch.countDown();
219222
return null;
220-
}).when(userEmailService).sendRegistrationVerificationEmail(any(), any());
223+
}).when(userEmailService).sendRegistrationVerificationEmail(anyLong(), anyString());
221224

222225
doAnswer(invocation -> {
223226
processedEvents.add("login-success");
@@ -226,9 +229,9 @@ void events_processedInOrder() throws Exception {
226229
}).when(loginAttemptService).loginSucceeded(any());
227230

228231
// When
229-
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(testUser, Locale.ENGLISH, "app"));
232+
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(testUser.getId(), testUser.getEmail(), testUser.isEnabled(), Locale.ENGLISH, "app"));
230233
eventPublisher.publishEvent(new AuthenticationSuccessEvent(new UsernamePasswordAuthenticationToken("user", "pass")));
231-
eventPublisher.publishEvent(new UserPreDeleteEvent(this, testUser));
234+
eventPublisher.publishEvent(new UserPreDeleteEvent(this, testUser.getId(), testUser.getEmail()));
232235
processedEvents.add("delete");
233236
latch.countDown();
234237

0 commit comments

Comments
 (0)