Skip to content

Commit 80f7c47

Browse files
authored
Merge pull request #176 from devondragon/issue-175-BUG---Delete-User-Not-Cascading-to-BaseUserProfile-sub-classes
Add user account deletion handling and event publishing for cleanup
2 parents 9a891b2 + 7285da9 commit 80f7c47

File tree

6 files changed

+223
-73
lines changed

6 files changed

+223
-73
lines changed

README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
3333
- [**SSO OIDC with Keycloak**](#sso-oidc-with-keycloak)
3434
- [Extensibility](#extensibility)
3535
- [Custom User Profiles](#custom-user-profiles)
36+
- [Handling User Account Deletion and Profile Cleanup](#handling-user-account-deletion-and-profile-cleanup)
37+
- [Enabling Actual Deletion](#enabling-actual-deletion)
3638
- [SSO OAuth2 with Google and Facebook](#sso-oauth2-with-google-and-facebook)
3739
- [Examples](#examples)
3840
- [Contributing](#contributing)
@@ -328,6 +330,81 @@ public class CustomUserProfileService implements UserProfileService<CustomUserPr
328330
```
329331
Read more in the [Profile Guide](PROFILE.md).
330332

333+
### Handling User Account Deletion and Profile Cleanup
334+
By default, when a user account is "deleted" through the framework's services or APIs, the account is marked as disabled (`enabled=false`) rather than being physically removed from the database. This is controlled by the `user.actuallyDeleteAccount` configuration property, which defaults to `false`.
335+
336+
#### Enabling Actual Deletion
337+
338+
If you require user accounts to be physically deleted from the database, set the following property in your `application.properties` or `application.yml`:
339+
340+
```properties
341+
user.actuallyDeleteAccount=true
342+
```
343+
344+
Cleaning Up Related Data (e.g., User Profiles)
345+
When user.actuallyDeleteAccount is set to true, the framework needs a way to ensure that related data, such as application-specific user profiles extending BaseUserProfile, is also cleaned up to avoid orphaned data or foreign key constraint violations.
346+
347+
To facilitate this in a decoupled manner, the framework publishes a UserPreDeleteEvent immediately before the User entity is deleted from the database. This event is published within the same transaction as the user deletion.
348+
349+
Consuming applications that have extended BaseUserProfile (or have other user-related data) should listen for this event and perform the necessary cleanup operations.
350+
351+
Event Class: com.digitalsanctuary.spring.user.event.UserPreDeleteEvent Event Data: Contains the User entity that is about to be deleted (event.getUser()).
352+
353+
Example Event Listener:
354+
355+
Here's an example of how a consuming application can implement an event listener to delete its specific user profile (DemoUserProfile in this case) when a user is deleted:
356+
357+
```java
358+
package com.digitalsanctuary.spring.demo.user.profile;
359+
360+
import org.springframework.context.event.EventListener;
361+
import org.springframework.stereotype.Component;
362+
import org.springframework.transaction.annotation.Transactional;
363+
import lombok.RequiredArgsConstructor;
364+
import lombok.extern.slf4j.Slf4j;
365+
import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent;
366+
367+
/**
368+
* Listener for user profile deletion events. This class listens for UserPreDeleteEvent and deletes the associated DemoUserProfile. It is assumed that
369+
* the DemoUserProfile is mapped to the User entity with a one-to-one relationship.
370+
*/
371+
@Component
372+
@RequiredArgsConstructor
373+
@Slf4j
374+
public class UserProfileDeletionListener {
375+
private final DemoUserProfileRepository demoUserProfileRepository;
376+
// Inject other repositories if needed (e.g., EventRegistrationRepository)
377+
378+
@EventListener
379+
@Transactional // Joins the transaction started by UserService.deleteUserAccount
380+
public void handleUserPreDelete(UserPreDeleteEvent event) {
381+
Long userId = event.getUser().getId();
382+
log.info("Received UserPreDeleteEvent for userId: {}. Deleting associated DemoUserProfile...", userId);
383+
384+
// Option 1: Delete profile directly (if no further cascades needed from profile)
385+
// Since DemoUserProfile uses @MapsId, its ID is the same as the User's ID
386+
demoUserProfileRepository.findById(userId).ifPresent(profile -> {
387+
log.debug("Found DemoUserProfile for userId: {}. Deleting...", userId);
388+
// If DemoUserProfile itself has relationships needing cleanup (like EventRegistrations)
389+
// that aren't handled by CascadeType.REMOVE or orphanRemoval=true,
390+
// handle them here *before* deleting the profile.
391+
// Example: eventRegistrationRepository.deleteByUserProfile(profile);
392+
demoUserProfileRepository.delete(profile);
393+
log.debug("DemoUserProfile deleted for userId: {}", userId);
394+
});
395+
396+
// Option 2: If DemoUserProfile has CascadeType.REMOVE/orphanRemoval=true
397+
// on its collections (like eventRegistrations), deleting the profile might be enough.
398+
// demoUserProfileRepository.deleteById(userId);
399+
400+
log.info("Finished processing UserPreDeleteEvent for userId: {}", userId);
401+
}
402+
}
403+
```
404+
405+
By implementing such a listener, your application ensures data integrity when the actual user account deletion feature is enabled, without requiring the core framework library to have knowledge of your specific profile entities. If you leave user.actuallyDeleteAccount as false, this event is not published, and no listener implementation is required for profile cleanup
406+
407+
331408

332409
### SSO OAuth2 with Google and Facebook
333410
The framework supports SSO OAuth2 with Google, Facebook and Keycloak. To enable this you need to configure the client id and secret for each provider. This is done in the application.yml (or application.properties) file using the [Spring Security OAuth2 properties](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html). You can see the example configuration in the Demo Project's `application.yml` file.

src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,7 @@ public class UserAPI {
5454
@Value("${user.security.forgotPasswordPendingURI}")
5555
private String forgotPasswordPendingURI;
5656

57-
@Value("${user.actuallyDeleteAccount:false}")
58-
private boolean actuallyDeleteAccount;
57+
5958

6059
/**
6160
* Registers a new user account.
@@ -158,19 +157,19 @@ public ResponseEntity<JSONResponse> resetPassword(@Valid @RequestBody UserDto us
158157
* @return a ResponseEntity containing a JSONResponse with the password update result
159158
*/
160159
@PostMapping("/updatePassword")
161-
public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUserDetails userDetails,
160+
public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUserDetails userDetails,
162161
@Valid @RequestBody PasswordDto passwordDto, HttpServletRequest request, Locale locale) {
163162
validateAuthenticatedUser(userDetails);
164163
User user = userDetails.getUser();
165-
164+
166165
try {
167166
if (!userService.checkIfValidOldPassword(user, passwordDto.getOldPassword())) {
168167
throw new InvalidOldPasswordException("Invalid old password");
169168
}
170-
169+
171170
userService.changeUserPassword(user, passwordDto.getNewPassword());
172171
logAuditEvent("PasswordUpdate", "Success", "User password updated", user, request);
173-
172+
174173
return buildSuccessResponse(messages.getMessage("message.update-password.success", null, locale), null);
175174
} catch (InvalidOldPasswordException ex) {
176175
logAuditEvent("PasswordUpdate", "Failure", "Invalid old password", user, request);
@@ -194,15 +193,9 @@ public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUs
194193
public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUserDetails userDetails, HttpServletRequest request) {
195194
validateAuthenticatedUser(userDetails);
196195
User user = userDetails.getUser();
197-
198-
if (actuallyDeleteAccount) {
199-
userService.deleteUser(user);
200-
} else {
201-
user.setEnabled(false);
202-
userService.saveRegisteredUser(user);
203-
}
196+
userService.deleteOrDisableUser(user);
197+
logAuditEvent("AccountDelete", "Success", "User account deleted", user, request);
204198
logoutUser(request);
205-
206199
return buildSuccessResponse("Account Deleted", null);
207200
}
208201

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.digitalsanctuary.spring.user.event;
2+
3+
import org.springframework.context.ApplicationEvent;
4+
import com.digitalsanctuary.spring.user.persistence.model.User;
5+
6+
/**
7+
* Event published before a user entity is deleted. This event can be used to perform any necessary actions or checks before the deletion occurs.
8+
*
9+
* <p>
10+
* This event is typically used in conjunction with an event listener that can handle the pre-deletion logic, such as logging, validation, or
11+
* cascading deletions.
12+
* </p>
13+
*
14+
* @see User
15+
*/
16+
public class UserPreDeleteEvent extends ApplicationEvent {
17+
18+
/**
19+
* The user entity that is about to be deleted.
20+
*/
21+
private final User user;
22+
23+
/**
24+
* Create a new UserDeleteEvent.
25+
*
26+
* @param source The object on which the event initially occurred (never {@code null})
27+
* @param user The user entity that is about to be deleted (never {@code null})
28+
*/
29+
public UserPreDeleteEvent(Object source, User user) {
30+
super(source);
31+
this.user = user;
32+
}
33+
34+
/**
35+
* Get the user entity that is about to be deleted.
36+
*
37+
* @return The user entity (never {@code null})
38+
*/
39+
public User getUser() {
40+
return user;
41+
}
42+
43+
/**
44+
* Get the ID of the user entity that is about to be deleted.
45+
*
46+
* @return The ID of the user entity (never {@code null})
47+
*/
48+
public Long getUserId() {
49+
return user.getId();
50+
}
51+
52+
}

src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.time.LocalDateTime;
44
import com.digitalsanctuary.spring.user.persistence.model.User;
55
import jakarta.persistence.Column;
6+
import jakarta.persistence.ForeignKey;
67
import jakarta.persistence.Id;
78
import jakarta.persistence.JoinColumn;
89
import jakarta.persistence.MappedSuperclass;
@@ -47,7 +48,7 @@ public abstract class BaseUserProfile {
4748
*/
4849
@OneToOne
4950
@MapsId
50-
@JoinColumn(name = "user_id")
51+
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "fk_user"))
5152
private User user;
5253

5354
/**
@@ -64,5 +65,5 @@ public abstract class BaseUserProfile {
6465
@Column(name = "preferred_locale")
6566
private String locale;
6667

67-
// Note: Getters and setters are provided by Lombok @Data annotation
68+
6869
}

src/main/java/com/digitalsanctuary/spring/user/service/UserService.java

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.util.Optional;
88
import java.util.stream.Collectors;
99
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.context.ApplicationEventPublisher;
1011
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1112
import org.springframework.security.core.Authentication;
1213
import org.springframework.security.core.GrantedAuthority;
@@ -19,6 +20,7 @@
1920
import org.springframework.web.context.request.RequestContextHolder;
2021
import org.springframework.web.context.request.ServletRequestAttributes;
2122
import com.digitalsanctuary.spring.user.dto.UserDto;
23+
import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent;
2224
import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException;
2325
import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken;
2426
import com.digitalsanctuary.spring.user.persistence.model.User;
@@ -76,7 +78,7 @@
7678
* <ul>
7779
* <li>{@link #registerNewUserAccount(UserDto)}: Registers a new user account.</li>
7880
* <li>{@link #saveRegisteredUser(User)}: Saves a registered user.</li>
79-
* <li>{@link #deleteUser(User)}: Deletes a user and cleans up associated tokens.</li>
81+
* <li>{@link #deleteOrDisableUser(User)}: Deletes a user and cleans up associated tokens.</li>
8082
* <li>{@link #findUserByEmail(String)}: Finds a user by email.</li>
8183
* <li>{@link #getPasswordResetToken(String)}: Gets a password reset token by token string.</li>
8284
* <li>{@link #getUserByPasswordResetToken(String)}: Gets a user by password reset token.</li>
@@ -191,14 +193,18 @@ public String getValue() {
191193
/** The user details service. */
192194
private final DSUserDetailsService dsUserDetailsService;
193195

196+
private final ApplicationEventPublisher eventPublisher;
197+
194198
/** The send registration verification email flag. */
195199
@Value("${user.registration.sendVerificationEmail:false}")
196200
private boolean sendRegistrationVerificationEmail;
197201

202+
@Value("${user.actuallyDeleteAccount:false}")
203+
private boolean actuallyDeleteAccount;
204+
198205
/**
199-
* Registers a new user account with the provided user data.
200-
* If the email already exists, throws a UserAlreadyExistException.
201-
* If sendRegistrationVerificationEmail is false, the user is enabled immediately.
206+
* Registers a new user account with the provided user data. If the email already exists, throws a UserAlreadyExistException. If
207+
* sendRegistrationVerificationEmail is false, the user is enabled immediately.
202208
*
203209
* @param newUserDto the data transfer object containing the user registration information
204210
* @return the newly created user entity
@@ -243,25 +249,45 @@ public User saveRegisteredUser(final User user) {
243249
}
244250

245251
/**
246-
* Delete user.
252+
* Delete user and clean up associated tokens. If actuallyDeleteAccount is true, the user is deleted from the database. Otherwise, the user is
253+
* disabled.
247254
*
248-
* @param user the user
255+
* Transactional method to ensure that the operation is atomic. If any part of the operation fails, the entire transaction is rolled back. This
256+
* includes the Event to allow the consuming application to handle data cleanup as needed before the User is deleted.
257+
*
258+
* @param user the user to delete or disable
249259
*/
250-
public void deleteUser(final User user) {
251-
// Clean up any Tokens associated with this user
252-
final VerificationToken verificationToken = tokenRepository.findByUser(user);
253-
if (verificationToken != null) {
254-
tokenRepository.delete(verificationToken);
255-
}
260+
@Transactional
261+
public void deleteOrDisableUser(final User user) {
262+
log.debug("UserService.deleteOrDisableUser: called with user: {}", user);
263+
if (actuallyDeleteAccount) {
264+
log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user);
265+
// Publish the UserPreDeleteEvent before deleting the user
266+
// This allows any listeners to perform actions before the user is deleted
267+
log.debug("Publishing UserPreDeleteEvent");
268+
eventPublisher.publishEvent(new UserPreDeleteEvent(this, user));
269+
270+
// Clean up any Tokens associated with this user
271+
final VerificationToken verificationToken = tokenRepository.findByUser(user);
272+
if (verificationToken != null) {
273+
tokenRepository.delete(verificationToken);
274+
}
256275

257-
final PasswordResetToken passwordToken = passwordTokenRepository.findByUser(user);
258-
if (passwordToken != null) {
259-
passwordTokenRepository.delete(passwordToken);
276+
final PasswordResetToken passwordToken = passwordTokenRepository.findByUser(user);
277+
if (passwordToken != null) {
278+
passwordTokenRepository.delete(passwordToken);
279+
}
280+
// Delete the user
281+
userRepository.delete(user);
282+
} else {
283+
log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user);
284+
user.setEnabled(false);
285+
userRepository.save(user);
286+
log.debug("UserService.deleteOrDisableUser: user {} has been disabled", user.getEmail());
260287
}
261-
// Delete the user
262-
userRepository.delete(user);
263288
}
264289

290+
265291
/**
266292
* Find user by email.
267293
*
@@ -371,13 +397,13 @@ public List<String> getUsersFromSessionRegistry() {
371397
}
372398

373399
/**
374-
* Authenticates the given user without requiring a password. This method loads the user's details,
375-
* generates their authorities from their roles and privileges, and stores these details in the
376-
* security context and session.
377-
*
378-
* <p><strong>SECURITY WARNING:</strong> This is a potentially dangerous method as it authenticates
379-
* a user without password verification. This method should only be used in specific controlled scenarios,
380-
* such as after successful email verification or OAuth authentication.</p>
400+
* Authenticates the given user without requiring a password. This method loads the user's details, generates their authorities from their roles
401+
* and privileges, and stores these details in the security context and session.
402+
*
403+
* <p>
404+
* <strong>SECURITY WARNING:</strong> This is a potentially dangerous method as it authenticates a user without password verification. This method
405+
* should only be used in specific controlled scenarios, such as after successful email verification or OAuth authentication.
406+
* </p>
381407
*
382408
* @param user The user to authenticate without password verification
383409
*/

0 commit comments

Comments
 (0)