diff --git a/README.md b/README.md index f526fb9..5724333 100644 --- a/README.md +++ b/README.md @@ -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) @@ -328,6 +330,81 @@ public class CustomUserProfileService implements UserProfileService { + 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. 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 f8df8fe..6d05168 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -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. @@ -158,19 +157,19 @@ public ResponseEntity resetPassword(@Valid @RequestBody UserDto us * @return a ResponseEntity containing a JSONResponse with the password update result */ @PostMapping("/updatePassword") - public ResponseEntity updatePassword(@AuthenticationPrincipal DSUserDetails userDetails, + public ResponseEntity 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); @@ -194,15 +193,9 @@ public ResponseEntity updatePassword(@AuthenticationPrincipal DSUs public ResponseEntity 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); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEvent.java new file mode 100644 index 0000000..054febc --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEvent.java @@ -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. + * + *

+ * 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. + *

+ * + * @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(); + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java b/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java index f0eb8a5..2726752 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java @@ -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; @@ -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; /** @@ -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 + } 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 4d86db1..0e00ec2 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -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; @@ -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; @@ -76,7 +78,7 @@ *
    *
  • {@link #registerNewUserAccount(UserDto)}: Registers a new user account.
  • *
  • {@link #saveRegisteredUser(User)}: Saves a registered user.
  • - *
  • {@link #deleteUser(User)}: Deletes a user and cleans up associated tokens.
  • + *
  • {@link #deleteOrDisableUser(User)}: Deletes a user and cleans up associated tokens.
  • *
  • {@link #findUserByEmail(String)}: Finds a user by email.
  • *
  • {@link #getPasswordResetToken(String)}: Gets a password reset token by token string.
  • *
  • {@link #getUserByPasswordResetToken(String)}: Gets a user by password reset token.
  • @@ -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 @@ -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. * @@ -371,13 +397,13 @@ public List 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. - * - *

    SECURITY WARNING: 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.

    + * 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. + * + *

    + * SECURITY WARNING: 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. + *

    * * @param user The user to authenticate without password verification */ 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 f2072fd..9e78db7 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -1,13 +1,8 @@ package com.digitalsanctuary.spring.user.service; -import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; -import com.digitalsanctuary.spring.user.persistence.model.Role; -import com.digitalsanctuary.spring.user.persistence.model.User; -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 static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import java.util.Collections; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -15,13 +10,17 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.Collections; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; +import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; +import com.digitalsanctuary.spring.user.persistence.model.Role; +import com.digitalsanctuary.spring.user.persistence.model.User; +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; @ExtendWith(MockitoExtension.class) @Disabled("Temporarily disabled due to OAuth2 dependency issues") @@ -46,6 +45,8 @@ public class UserServiceTest { public UserVerificationService userVerificationService; @Mock private DSUserDetailsService dsUserDetailsService; + @Mock + private ApplicationEventPublisher eventPublisher; @Mock private AuthorityService authorityService; @@ -72,7 +73,7 @@ void setUp() { testUserDto.setRole(1); userService = new UserService(userRepository, tokenRepository, passwordTokenRepository, passwordEncoder, roleRepository, sessionRegistry, - userEmailService, userVerificationService, authorityService, dsUserDetailsService); + userEmailService, userVerificationService, authorityService, dsUserDetailsService, eventPublisher); } @Test @@ -103,25 +104,25 @@ void checkIfValidOldPassword_returnTrueIfValid() { when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); Assertions.assertTrue(userService.checkIfValidOldPassword(testUser, testUser.getPassword())); } - + // Tests temporarily disabled until OAuth2 dependency issue is resolved -// @Test -// void checkIfValidOldPassword_returnFalseIfInvalid() { -// when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); -// Assertions.assertFalse(userService.checkIfValidOldPassword(testUser, "wrongPassword")); -// } -// -// @Test -// void changeUserPassword_encodesAndSavesNewPassword() { -// String newPassword = "newTestPassword"; -// String encodedPassword = "encodedNewPassword"; -// -// when(passwordEncoder.encode(newPassword)).thenReturn(encodedPassword); -// when(userRepository.save(any(User.class))).thenReturn(testUser); -// -// userService.changeUserPassword(testUser, newPassword); -// -// Assertions.assertEquals(encodedPassword, testUser.getPassword()); -// } + // @Test + // void checkIfValidOldPassword_returnFalseIfInvalid() { + // when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + // Assertions.assertFalse(userService.checkIfValidOldPassword(testUser, "wrongPassword")); + // } + // + // @Test + // void changeUserPassword_encodesAndSavesNewPassword() { + // String newPassword = "newTestPassword"; + // String encodedPassword = "encodedNewPassword"; + // + // when(passwordEncoder.encode(newPassword)).thenReturn(encodedPassword); + // when(userRepository.save(any(User.class))).thenReturn(testUser); + // + // userService.changeUserPassword(testUser, newPassword); + // + // Assertions.assertEquals(encodedPassword, testUser.getPassword()); + // } }