Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
- [**SSO OIDC with Keycloak**](#sso-oidc-with-keycloak)
- [Extensibility](#extensibility)
- [Custom User Profiles](#custom-user-profiles)
- [Handling User Account Deletion and Profile Cleanup](#handling-user-account-deletion-and-profile-cleanup)
- [Enabling Actual Deletion](#enabling-actual-deletion)
- [SSO OAuth2 with Google and Facebook](#sso-oauth2-with-google-and-facebook)
- [Examples](#examples)
- [Contributing](#contributing)
Expand Down Expand Up @@ -328,6 +330,81 @@ public class CustomUserProfileService implements UserProfileService<CustomUserPr
```
Read more in the [Profile Guide](PROFILE.md).

### Handling User Account Deletion and Profile Cleanup
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`.

#### Enabling Actual Deletion

If you require user accounts to be physically deleted from the database, set the following property in your `application.properties` or `application.yml`:

```properties
user.actuallyDeleteAccount=true
```

Cleaning Up Related Data (e.g., User Profiles)
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.

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.

Consuming applications that have extended BaseUserProfile (or have other user-related data) should listen for this event and perform the necessary cleanup operations.

Event Class: com.digitalsanctuary.spring.user.event.UserPreDeleteEvent Event Data: Contains the User entity that is about to be deleted (event.getUser()).

Example Event Listener:

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:

```java
package com.digitalsanctuary.spring.demo.user.profile;

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent;

/**
* Listener for user profile deletion events. This class listens for UserPreDeleteEvent and deletes the associated DemoUserProfile. It is assumed that
* the DemoUserProfile is mapped to the User entity with a one-to-one relationship.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class UserProfileDeletionListener {
private final DemoUserProfileRepository demoUserProfileRepository;
// Inject other repositories if needed (e.g., EventRegistrationRepository)

@EventListener
@Transactional // Joins the transaction started by UserService.deleteUserAccount
public void handleUserPreDelete(UserPreDeleteEvent event) {
Long userId = event.getUser().getId();
log.info("Received UserPreDeleteEvent for userId: {}. Deleting associated DemoUserProfile...", userId);

// Option 1: Delete profile directly (if no further cascades needed from profile)
// Since DemoUserProfile uses @MapsId, its ID is the same as the User's ID
demoUserProfileRepository.findById(userId).ifPresent(profile -> {
log.debug("Found DemoUserProfile for userId: {}. Deleting...", userId);
// If DemoUserProfile itself has relationships needing cleanup (like EventRegistrations)
// that aren't handled by CascadeType.REMOVE or orphanRemoval=true,
// handle them here *before* deleting the profile.
// Example: eventRegistrationRepository.deleteByUserProfile(profile);
demoUserProfileRepository.delete(profile);
log.debug("DemoUserProfile deleted for userId: {}", userId);
});

// Option 2: If DemoUserProfile has CascadeType.REMOVE/orphanRemoval=true
// on its collections (like eventRegistrations), deleting the profile might be enough.
// demoUserProfileRepository.deleteById(userId);

log.info("Finished processing UserPreDeleteEvent for userId: {}", userId);
}
}
```

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



### SSO OAuth2 with Google and Facebook
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.
Expand Down
21 changes: 7 additions & 14 deletions src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ public class UserAPI {
@Value("${user.security.forgotPasswordPendingURI}")
private String forgotPasswordPendingURI;

@Value("${user.actuallyDeleteAccount:false}")
private boolean actuallyDeleteAccount;


/**
* Registers a new user account.
Expand Down Expand Up @@ -158,19 +157,19 @@ public ResponseEntity<JSONResponse> resetPassword(@Valid @RequestBody UserDto us
* @return a ResponseEntity containing a JSONResponse with the password update result
*/
@PostMapping("/updatePassword")
public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUserDetails userDetails,
public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUserDetails userDetails,
@Valid @RequestBody PasswordDto passwordDto, HttpServletRequest request, Locale locale) {
validateAuthenticatedUser(userDetails);
User user = userDetails.getUser();

try {
if (!userService.checkIfValidOldPassword(user, passwordDto.getOldPassword())) {
throw new InvalidOldPasswordException("Invalid old password");
}

userService.changeUserPassword(user, passwordDto.getNewPassword());
logAuditEvent("PasswordUpdate", "Success", "User password updated", user, request);

return buildSuccessResponse(messages.getMessage("message.update-password.success", null, locale), null);
} catch (InvalidOldPasswordException ex) {
logAuditEvent("PasswordUpdate", "Failure", "Invalid old password", user, request);
Expand All @@ -194,15 +193,9 @@ public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUs
public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUserDetails userDetails, HttpServletRequest request) {
validateAuthenticatedUser(userDetails);
User user = userDetails.getUser();

if (actuallyDeleteAccount) {
userService.deleteUser(user);
} else {
user.setEnabled(false);
userService.saveRegisteredUser(user);
}
userService.deleteOrDisableUser(user);
logAuditEvent("AccountDelete", "Success", "User account deleted", user, request);
logoutUser(request);

return buildSuccessResponse("Account Deleted", null);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.digitalsanctuary.spring.user.event;

import org.springframework.context.ApplicationEvent;
import com.digitalsanctuary.spring.user.persistence.model.User;

/**
* Event published before a user entity is deleted. This event can be used to perform any necessary actions or checks before the deletion occurs.
*
* <p>
* This event is typically used in conjunction with an event listener that can handle the pre-deletion logic, such as logging, validation, or
* cascading deletions.
* </p>
*
* @see User
*/
public class UserPreDeleteEvent extends ApplicationEvent {

/**
* The user entity that is about to be deleted.
*/
private final User user;

/**
* Create a new UserDeleteEvent.
*
* @param source The object on which the event initially occurred (never {@code null})
* @param user The user entity that is about to be deleted (never {@code null})
*/
public UserPreDeleteEvent(Object source, User user) {
super(source);
this.user = user;
}

/**
* Get the user entity that is about to be deleted.
*
* @return The user entity (never {@code null})
*/
public User getUser() {
return user;
}

/**
* Get the ID of the user entity that is about to be deleted.
*
* @return The ID of the user entity (never {@code null})
*/
public Long getUserId() {
return user.getId();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.time.LocalDateTime;
import com.digitalsanctuary.spring.user.persistence.model.User;
import jakarta.persistence.Column;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MappedSuperclass;
Expand Down Expand Up @@ -47,7 +48,7 @@ public abstract class BaseUserProfile {
*/
@OneToOne
@MapsId
@JoinColumn(name = "user_id")
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "fk_user"))
private User user;

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

// Note: Getters and setters are provided by Lombok @Data annotation

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
Expand All @@ -19,6 +20,7 @@
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.digitalsanctuary.spring.user.dto.UserDto;
import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent;
import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException;
import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken;
import com.digitalsanctuary.spring.user.persistence.model.User;
Expand Down Expand Up @@ -76,7 +78,7 @@
* <ul>
* <li>{@link #registerNewUserAccount(UserDto)}: Registers a new user account.</li>
* <li>{@link #saveRegisteredUser(User)}: Saves a registered user.</li>
* <li>{@link #deleteUser(User)}: Deletes a user and cleans up associated tokens.</li>
* <li>{@link #deleteOrDisableUser(User)}: Deletes a user and cleans up associated tokens.</li>
* <li>{@link #findUserByEmail(String)}: Finds a user by email.</li>
* <li>{@link #getPasswordResetToken(String)}: Gets a password reset token by token string.</li>
* <li>{@link #getUserByPasswordResetToken(String)}: Gets a user by password reset token.</li>
Expand Down Expand Up @@ -191,14 +193,18 @@ public String getValue() {
/** The user details service. */
private final DSUserDetailsService dsUserDetailsService;

private final ApplicationEventPublisher eventPublisher;

/** The send registration verification email flag. */
@Value("${user.registration.sendVerificationEmail:false}")
private boolean sendRegistrationVerificationEmail;

@Value("${user.actuallyDeleteAccount:false}")
private boolean actuallyDeleteAccount;

/**
* Registers a new user account with the provided user data.
* If the email already exists, throws a UserAlreadyExistException.
* If sendRegistrationVerificationEmail is false, the user is enabled immediately.
* Registers a new user account with the provided user data. If the email already exists, throws a UserAlreadyExistException. If
* sendRegistrationVerificationEmail is false, the user is enabled immediately.
*
* @param newUserDto the data transfer object containing the user registration information
* @return the newly created user entity
Expand Down Expand Up @@ -243,25 +249,45 @@ public User saveRegisteredUser(final User user) {
}

/**
* Delete user.
* Delete user and clean up associated tokens. If actuallyDeleteAccount is true, the user is deleted from the database. Otherwise, the user is
* disabled.
*
* @param user the user
* Transactional method to ensure that the operation is atomic. If any part of the operation fails, the entire transaction is rolled back. This
* includes the Event to allow the consuming application to handle data cleanup as needed before the User is deleted.
*
* @param user the user to delete or disable
*/
public void deleteUser(final User user) {
// Clean up any Tokens associated with this user
final VerificationToken verificationToken = tokenRepository.findByUser(user);
if (verificationToken != null) {
tokenRepository.delete(verificationToken);
}
@Transactional
public void deleteOrDisableUser(final User user) {
log.debug("UserService.deleteOrDisableUser: called with user: {}", user);
if (actuallyDeleteAccount) {
log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user);
// Publish the UserPreDeleteEvent before deleting the user
// This allows any listeners to perform actions before the user is deleted
log.debug("Publishing UserPreDeleteEvent");
eventPublisher.publishEvent(new UserPreDeleteEvent(this, user));

// Clean up any Tokens associated with this user
final VerificationToken verificationToken = tokenRepository.findByUser(user);
if (verificationToken != null) {
tokenRepository.delete(verificationToken);
}

final PasswordResetToken passwordToken = passwordTokenRepository.findByUser(user);
if (passwordToken != null) {
passwordTokenRepository.delete(passwordToken);
final PasswordResetToken passwordToken = passwordTokenRepository.findByUser(user);
if (passwordToken != null) {
passwordTokenRepository.delete(passwordToken);
}
// Delete the user
userRepository.delete(user);
} else {
log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user);
user.setEnabled(false);
userRepository.save(user);
log.debug("UserService.deleteOrDisableUser: user {} has been disabled", user.getEmail());
}
// Delete the user
userRepository.delete(user);
}


/**
* Find user by email.
*
Expand Down Expand Up @@ -371,13 +397,13 @@ public List<String> getUsersFromSessionRegistry() {
}

/**
* Authenticates the given user without requiring a password. This method loads the user's details,
* generates their authorities from their roles and privileges, and stores these details in the
* security context and session.
*
* <p><strong>SECURITY WARNING:</strong> This is a potentially dangerous method as it authenticates
* a user without password verification. This method should only be used in specific controlled scenarios,
* such as after successful email verification or OAuth authentication.</p>
* Authenticates the given user without requiring a password. This method loads the user's details, generates their authorities from their roles
* and privileges, and stores these details in the security context and session.
*
* <p>
* <strong>SECURITY WARNING:</strong> This is a potentially dangerous method as it authenticates a user without password verification. This method
* should only be used in specific controlled scenarios, such as after successful email verification or OAuth authentication.
* </p>
*
* @param user The user to authenticate without password verification
*/
Expand Down
Loading