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 d6d0315..4901af7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -103,6 +103,11 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User log.error("Unexpected error during registration.", ex); logAuditEvent("Registration", "Failure", ex.getMessage(), null, request); return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } finally { + // Clear sensitive password data from memory + if (userDto != null) { + userDto.clearPasswords(); + } } } @@ -244,6 +249,11 @@ public ResponseEntity savePassword(@Valid @RequestBody SavePasswor log.error("Unexpected error during password reset.", ex); logAuditEvent("PasswordReset", "Failure", ex.getMessage(), null, request); return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } finally { + // Clear sensitive password data from memory + if (savePasswordDto != null) { + savePasswordDto.clearPasswords(); + } } } @@ -293,6 +303,11 @@ public ResponseEntity updatePassword(@AuthenticationPrincipal DSUs log.error("Unexpected error during password update.", ex); logAuditEvent("PasswordUpdate", "Failure", ex.getMessage(), user, request); return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } finally { + // Clear sensitive password data from memory + if (passwordDto != null) { + passwordDto.clearPasswords(); + } } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordDto.java index 72a6819..11fe893 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordDto.java +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordDto.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.dto; +import com.digitalsanctuary.spring.user.util.PasswordSecurityUtil; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data; @@ -8,9 +9,13 @@ /** * A new password dto. This object is used for password form actions (change password, forgot password token, save * password, etc..). + * + *

Note: This DTO supports both String and char[] for passwords. The char[] methods are preferred + * for enhanced security as they allow explicit memory clearing. String methods are maintained + * for backward compatibility. */ @Data -public class PasswordDto { +public class PasswordDto implements AutoCloseable { /** The old password. */ @ToString.Exclude @@ -26,4 +31,86 @@ public class PasswordDto { /** The token. */ private String token; + /** The old password as char array (for secure handling). */ + @ToString.Exclude + private transient char[] oldPasswordChars; + + /** The new password as char array (for secure handling). */ + @ToString.Exclude + private transient char[] newPasswordChars; + + /** + * Gets the old password as a char array for secure handling. + * If oldPasswordChars is null but oldPassword is set, converts from String. + * + * @return the old password as char array, or null if not set + */ + public char[] getOldPasswordChars() { + if (oldPasswordChars == null && oldPassword != null) { + return PasswordSecurityUtil.toCharArray(oldPassword); + } + return oldPasswordChars; + } + + /** + * Sets the old password from a char array (preferred for security). + * Also updates the String oldPassword field for backward compatibility. + * + * @param oldPasswordChars the old password as char array + */ + public void setOldPasswordChars(char[] oldPasswordChars) { + this.oldPasswordChars = oldPasswordChars; + if (oldPasswordChars != null) { + this.oldPassword = PasswordSecurityUtil.toString(oldPasswordChars); + } else { + this.oldPassword = null; + } + } + + /** + * Gets the new password as a char array for secure handling. + * If newPasswordChars is null but newPassword is set, converts from String. + * + * @return the new password as char array, or null if not set + */ + public char[] getNewPasswordChars() { + if (newPasswordChars == null && newPassword != null) { + return PasswordSecurityUtil.toCharArray(newPassword); + } + return newPasswordChars; + } + + /** + * Sets the new password from a char array (preferred for security). + * Also updates the String newPassword field for backward compatibility. + * + * @param newPasswordChars the new password as char array + */ + public void setNewPasswordChars(char[] newPasswordChars) { + this.newPasswordChars = newPasswordChars; + if (newPasswordChars != null) { + this.newPassword = PasswordSecurityUtil.toString(newPasswordChars); + } else { + this.newPassword = null; + } + } + + /** + * Clears sensitive password data from memory. + * This method should be called in a finally block to ensure passwords are cleared. + */ + public void clearPasswords() { + PasswordSecurityUtil.clearPassword(oldPasswordChars); + PasswordSecurityUtil.clearPassword(newPasswordChars); + oldPasswordChars = null; + newPasswordChars = null; + } + + /** + * Closes this resource, clearing password data from memory. + */ + @Override + public void close() { + clearPasswords(); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java index 4a7ac64..0ed8761 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java @@ -1,16 +1,22 @@ package com.digitalsanctuary.spring.user.dto; +import com.digitalsanctuary.spring.user.util.PasswordSecurityUtil; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; +import lombok.ToString; /** * Data Transfer Object for saving a new password after password reset. * Used in the password reset flow after the user clicks the link in their email * and enters a new password. + * + *

Note: This DTO supports both String and char[] for passwords. The char[] methods are preferred + * for enhanced security as they allow explicit memory clearing. String methods are maintained + * for backward compatibility. */ @Data -public class SavePasswordDto { +public class SavePasswordDto implements AutoCloseable { /** The password reset token from the email link. */ @NotNull @@ -18,12 +24,97 @@ public class SavePasswordDto { private String token; /** The new password to set. */ + @ToString.Exclude @NotNull @NotEmpty private String newPassword; /** Confirmation of the new password (must match newPassword). */ + @ToString.Exclude @NotNull @NotEmpty private String confirmPassword; + + /** The new password as char array (for secure handling). */ + @ToString.Exclude + private transient char[] newPasswordChars; + + /** The confirm password as char array (for secure handling). */ + @ToString.Exclude + private transient char[] confirmPasswordChars; + + /** + * Gets the new password as a char array for secure handling. + * If newPasswordChars is null but newPassword is set, converts from String. + * + * @return the new password as char array, or null if not set + */ + public char[] getNewPasswordChars() { + if (newPasswordChars == null && newPassword != null) { + return PasswordSecurityUtil.toCharArray(newPassword); + } + return newPasswordChars; + } + + /** + * Sets the new password from a char array (preferred for security). + * Also updates the String newPassword field for backward compatibility. + * + * @param newPasswordChars the new password as char array + */ + public void setNewPasswordChars(char[] newPasswordChars) { + this.newPasswordChars = newPasswordChars; + if (newPasswordChars != null) { + this.newPassword = PasswordSecurityUtil.toString(newPasswordChars); + } else { + this.newPassword = null; + } + } + + /** + * Gets the confirm password as a char array for secure handling. + * If confirmPasswordChars is null but confirmPassword is set, converts from String. + * + * @return the confirm password as char array, or null if not set + */ + public char[] getConfirmPasswordChars() { + if (confirmPasswordChars == null && confirmPassword != null) { + return PasswordSecurityUtil.toCharArray(confirmPassword); + } + return confirmPasswordChars; + } + + /** + * Sets the confirm password from a char array (preferred for security). + * Also updates the String confirmPassword field for backward compatibility. + * + * @param confirmPasswordChars the confirm password as char array + */ + public void setConfirmPasswordChars(char[] confirmPasswordChars) { + this.confirmPasswordChars = confirmPasswordChars; + if (confirmPasswordChars != null) { + this.confirmPassword = PasswordSecurityUtil.toString(confirmPasswordChars); + } else { + this.confirmPassword = null; + } + } + + /** + * Clears sensitive password data from memory. + * This method should be called in a finally block to ensure passwords are cleared. + */ + public void clearPasswords() { + PasswordSecurityUtil.clearPassword(newPasswordChars); + PasswordSecurityUtil.clearPassword(confirmPasswordChars); + newPasswordChars = null; + confirmPasswordChars = null; + } + + /** + * Closes this resource, clearing password data from memory. + */ + @Override + public void close() { + clearPasswords(); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java index ff503b5..3510ba7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.dto; +import com.digitalsanctuary.spring.user.util.PasswordSecurityUtil; import com.digitalsanctuary.spring.user.validation.PasswordMatches; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -10,10 +11,14 @@ /** * A user dto. This object is used for handling user related form data (registration, forms passing in email addresses, * etc...). + * + *

Note: This DTO supports both String and char[] for passwords. The char[] methods are preferred + * for enhanced security as they allow explicit memory clearing. String methods are maintained + * for backward compatibility. */ @Data @PasswordMatches -public class UserDto { +public class UserDto implements AutoCloseable { /** The first name. */ @NotBlank(message = "First name is required") @@ -44,4 +49,87 @@ public class UserDto { /** The role. */ private Integer role; + + /** The password as char array (for secure handling). */ + @ToString.Exclude + private transient char[] passwordChars; + + /** The matching password as char array (for secure handling). */ + @ToString.Exclude + private transient char[] matchingPasswordChars; + + /** + * Gets the password as a char array for secure handling. + * If passwordChars is null but password is set, converts from String. + * + * @return the password as char array, or null if not set + */ + public char[] getPasswordChars() { + if (passwordChars == null && password != null) { + return PasswordSecurityUtil.toCharArray(password); + } + return passwordChars; + } + + /** + * Sets the password from a char array (preferred for security). + * Also updates the String password field for backward compatibility. + * + * @param passwordChars the password as char array + */ + public void setPasswordChars(char[] passwordChars) { + this.passwordChars = passwordChars; + if (passwordChars != null) { + this.password = PasswordSecurityUtil.toString(passwordChars); + } else { + this.password = null; + } + } + + /** + * Gets the matching password as a char array for secure handling. + * If matchingPasswordChars is null but matchingPassword is set, converts from String. + * + * @return the matching password as char array, or null if not set + */ + public char[] getMatchingPasswordChars() { + if (matchingPasswordChars == null && matchingPassword != null) { + return PasswordSecurityUtil.toCharArray(matchingPassword); + } + return matchingPasswordChars; + } + + /** + * Sets the matching password from a char array (preferred for security). + * Also updates the String matchingPassword field for backward compatibility. + * + * @param matchingPasswordChars the matching password as char array + */ + public void setMatchingPasswordChars(char[] matchingPasswordChars) { + this.matchingPasswordChars = matchingPasswordChars; + if (matchingPasswordChars != null) { + this.matchingPassword = PasswordSecurityUtil.toString(matchingPasswordChars); + } else { + this.matchingPassword = null; + } + } + + /** + * Clears sensitive password data from memory. + * This method should be called in a finally block to ensure passwords are cleared. + */ + public void clearPasswords() { + PasswordSecurityUtil.clearPassword(passwordChars); + PasswordSecurityUtil.clearPassword(matchingPasswordChars); + passwordChars = null; + matchingPasswordChars = null; + } + + /** + * Closes this resource, clearing password data from memory. + */ + @Override + public void close() { + clearPasswords(); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java index 67a1e25..2ec11e7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java @@ -154,6 +154,41 @@ public List validate(User user, String password, String usernameOrEmail, return validateWithPassay(password, rules, locale); } + /** + * Validate the given password (as char array) against the configured policy rules. + * This is the preferred method for security as it allows explicit password clearing. + * + *

Note: The password char array is converted to String internally only when needed + * for validation, and the String is not retained beyond the validation process.

+ * + *

Note: The user parameter may be null during new user registration. + * When null, password history checking is skipped (since new users have no history). + * This is intentional - history checks only apply to existing users changing their passwords.

+ * + * @param user The user (may be null for new registrations) + * @param passwordChars the password as char array to validate + * @param usernameOrEmail optional username/email for similarity checks + * @param locale the locale + * @return list of error messages if validation fails, empty if valid + */ + public List validate(User user, char[] passwordChars, String usernameOrEmail, Locale locale) { + if (passwordChars == null) { + return List.of("Password cannot be null"); + } + + // Convert to String for validation + // Note: We need String for Passay library and password encoder + String password = new String(passwordChars); + try { + return validate(user, password, usernameOrEmail, locale); + } finally { + // Clear the temporary String from memory (best effort) + // Note: String is immutable so this won't actually clear the String, + // but it helps signal intent and clears the reference + password = null; + } + } + /** * Build the list of Passay rules based on configuration. * 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 280b71f..6e5454f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -448,6 +448,28 @@ public void changeUserPassword(final User user, final String password) { savePasswordHistory(user, encodedPassword); } + /** + * Change user password (char array version - preferred for security). + * This method converts the char array to String for encoding and immediately clears it. + * + * @param user the user + * @param passwordChars the password as char array + */ + public void changeUserPassword(final User user, final char[] passwordChars) { + if (passwordChars == null) { + throw new IllegalArgumentException("Password cannot be null"); + } + + // Convert to String for password encoder (required by BCrypt) + String password = new String(passwordChars); + try { + changeUserPassword(user, password); + } finally { + // Clear the temporary String reference + password = null; + } + } + /** * Check if valid old password. * @@ -462,6 +484,29 @@ public boolean checkIfValidOldPassword(final User user, final String oldPassword return passwordEncoder.matches(oldPassword, user.getPassword()); } + /** + * Check if valid old password (char array version - preferred for security). + * This method converts the char array to String for verification. + * + * @param user the user + * @param oldPasswordChars the old password as char array + * @return true, if successful + */ + public boolean checkIfValidOldPassword(final User user, final char[] oldPasswordChars) { + if (oldPasswordChars == null) { + return false; + } + + // Convert to String for password encoder + String oldPassword = new String(oldPasswordChars); + try { + return checkIfValidOldPassword(user, oldPassword); + } finally { + // Clear the temporary String reference + oldPassword = null; + } + } + /** * See if the Email exists in the user repository. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java b/src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java new file mode 100644 index 0000000..38eb544 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java @@ -0,0 +1,135 @@ +package com.digitalsanctuary.spring.user.util; + +import java.util.Arrays; + +/** + * Utility class for secure password handling to minimize password exposure in memory. + * + *

This class provides methods for: + *

    + *
  • Constant-time password comparison to prevent timing attacks
  • + *
  • Safe conversion between char[] and String when required
  • + *
  • Explicit memory clearing of sensitive data
  • + *
+ * + *

Security Rationale: + *

    + *
  • char[] arrays can be explicitly cleared from memory after use
  • + *
  • String objects are immutable and remain in memory until garbage collected
  • + *
  • Using char[] reduces password exposure time in memory
  • + *
+ * + * @author SpringUserFramework + * @since 3.1.0 + */ +public final class PasswordSecurityUtil { + + /** + * Private constructor to prevent instantiation. + */ + private PasswordSecurityUtil() { + throw new UnsupportedOperationException("Utility class should not be instantiated"); + } + + /** + * Compares two char[] arrays in constant time to prevent timing attacks. + * + *

This method always compares the full length of both arrays to avoid + * leaking information about password length or content through timing. + * + * @param password1 first password array + * @param password2 second password array + * @return true if both arrays are non-null and contain the same characters + */ + public static boolean constantTimeEquals(char[] password1, char[] password2) { + if (password1 == null || password2 == null) { + return password1 == password2; + } + + // Different lengths - always return false but continue timing to avoid leak + if (password1.length != password2.length) { + // Still perform a comparison to maintain constant time + int diff = 0; + for (int i = 0; i < password1.length && i < password2.length; i++) { + diff |= password1[i] ^ password2[i]; + } + return false; + } + + // Same length - compare all characters + int diff = 0; + for (int i = 0; i < password1.length; i++) { + diff |= password1[i] ^ password2[i]; + } + + return diff == 0; + } + + /** + * Securely clears a char[] array by overwriting with zeros. + * + *

This method should be called in a finally block to ensure + * sensitive data is cleared even if an exception occurs. + * + * @param password the password array to clear (can be null) + */ + public static void clearPassword(char[] password) { + if (password != null) { + Arrays.fill(password, '\0'); + } + } + + /** + * Converts a char[] to String when required by libraries. + * + *

Important: The resulting String cannot be cleared + * from memory until garbage collected. Use this method only when necessary + * and clear the char[] array immediately after conversion. + * + * @param password the password char array + * @return String representation of the password, or null if input is null + */ + public static String toString(char[] password) { + if (password == null) { + return null; + } + return new String(password); + } + + /** + * Converts a String to char[] for secure handling. + * + *

Note: The original String will still remain in memory until + * garbage collected. This method is primarily useful for transitioning + * existing String-based code to char[]-based handling. + * + * @param password the password string + * @return char array representation, or null if input is null + */ + public static char[] toCharArray(String password) { + if (password == null) { + return null; + } + return password.toCharArray(); + } + + /** + * Validates that a char[] password is not null and not empty. + * + * @param password the password to validate + * @return true if password is non-null and has length > 0 + */ + public static boolean isNotEmpty(char[] password) { + return password != null && password.length > 0; + } + + /** + * Validates that a char[] password is null or empty. + * + * @param password the password to validate + * @return true if password is null or has length 0 + */ + public static boolean isEmpty(char[] password) { + return password == null || password.length == 0; + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/dto/PasswordDtoCharArrayTest.java b/src/test/java/com/digitalsanctuary/spring/user/dto/PasswordDtoCharArrayTest.java new file mode 100644 index 0000000..6b291df --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/dto/PasswordDtoCharArrayTest.java @@ -0,0 +1,238 @@ +package com.digitalsanctuary.spring.user.dto; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.digitalsanctuary.spring.user.util.PasswordSecurityUtil; + +/** + * Unit tests for DTO password char[] handling. + */ +class PasswordDtoCharArrayTest { + + @Test + void userDto_getPasswordChars_returnsCharArray() { + UserDto dto = new UserDto(); + dto.setPassword("TestP@ssw0rd"); + + char[] passwordChars = dto.getPasswordChars(); + char[] expected = "TestP@ssw0rd".toCharArray(); + try { + assertNotNull(passwordChars); + assertArrayEquals(expected, passwordChars); + } finally { + PasswordSecurityUtil.clearPassword(expected); + } + } + + @Test + void userDto_setPasswordChars_updatesStringPassword() { + UserDto dto = new UserDto(); + char[] password = "TestP@ssw0rd".toCharArray(); + dto.setPasswordChars(password); + + assertEquals("TestP@ssw0rd", dto.getPassword()); + } + + @Test + void userDto_clearPasswords_clearsArrays() { + UserDto dto = new UserDto(); + char[] password = "TestP@ssw0rd".toCharArray(); + char[] matching = "TestP@ssw0rd".toCharArray(); + + dto.setPasswordChars(password); + dto.setMatchingPasswordChars(matching); + + dto.clearPasswords(); + + // Verify arrays are cleared + for (char c : password) { + assertEquals('\0', c); + } + for (char c : matching) { + assertEquals('\0', c); + } + } + + @Test + void userDto_autoCloseable_clearsPasswordsOnClose() { + UserDto dto = new UserDto(); + char[] password = "TestP@ssw0rd".toCharArray(); + dto.setPasswordChars(password); + + // Try-with-resources should call close() + try (UserDto closeable = dto) { + assertNotNull(closeable.getPassword()); + } + + // Verify password is cleared + for (char c : password) { + assertEquals('\0', c); + } + } + + @Test + void passwordDto_getOldPasswordChars_returnsCharArray() { + PasswordDto dto = new PasswordDto(); + dto.setOldPassword("OldP@ssw0rd"); + + char[] passwordChars = dto.getOldPasswordChars(); + char[] expected = "OldP@ssw0rd".toCharArray(); + try { + assertNotNull(passwordChars); + assertArrayEquals(expected, passwordChars); + } finally { + PasswordSecurityUtil.clearPassword(expected); + } + } + + @Test + void passwordDto_setOldPasswordChars_updatesStringPassword() { + PasswordDto dto = new PasswordDto(); + char[] password = "OldP@ssw0rd".toCharArray(); + dto.setOldPasswordChars(password); + + assertEquals("OldP@ssw0rd", dto.getOldPassword()); + } + + @Test + void passwordDto_clearPasswords_clearsArrays() { + PasswordDto dto = new PasswordDto(); + char[] oldPassword = "OldP@ssw0rd".toCharArray(); + char[] newPassword = "NewP@ssw0rd".toCharArray(); + + dto.setOldPasswordChars(oldPassword); + dto.setNewPasswordChars(newPassword); + + dto.clearPasswords(); + + // Verify arrays are cleared + for (char c : oldPassword) { + assertEquals('\0', c); + } + for (char c : newPassword) { + assertEquals('\0', c); + } + } + + @Test + void passwordDto_autoCloseable_clearsPasswordsOnClose() { + PasswordDto dto = new PasswordDto(); + char[] oldPassword = "OldP@ssw0rd".toCharArray(); + char[] newPassword = "NewP@ssw0rd".toCharArray(); + + dto.setOldPasswordChars(oldPassword); + dto.setNewPasswordChars(newPassword); + + // Try-with-resources should call close() + try (PasswordDto closeable = dto) { + assertNotNull(closeable.getOldPassword()); + assertNotNull(closeable.getNewPassword()); + } + + // Verify passwords are cleared + for (char c : oldPassword) { + assertEquals('\0', c); + } + for (char c : newPassword) { + assertEquals('\0', c); + } + } + + @Test + void savePasswordDto_getNewPasswordChars_returnsCharArray() { + SavePasswordDto dto = new SavePasswordDto(); + dto.setNewPassword("NewP@ssw0rd"); + + char[] passwordChars = dto.getNewPasswordChars(); + char[] expected = "NewP@ssw0rd".toCharArray(); + try { + assertNotNull(passwordChars); + assertArrayEquals(expected, passwordChars); + } finally { + PasswordSecurityUtil.clearPassword(expected); + } + } + + @Test + void savePasswordDto_setNewPasswordChars_updatesStringPassword() { + SavePasswordDto dto = new SavePasswordDto(); + char[] password = "NewP@ssw0rd".toCharArray(); + dto.setNewPasswordChars(password); + + assertEquals("NewP@ssw0rd", dto.getNewPassword()); + } + + @Test + void savePasswordDto_clearPasswords_clearsArrays() { + SavePasswordDto dto = new SavePasswordDto(); + char[] newPassword = "NewP@ssw0rd".toCharArray(); + char[] confirmPassword = "NewP@ssw0rd".toCharArray(); + + dto.setNewPasswordChars(newPassword); + dto.setConfirmPasswordChars(confirmPassword); + + dto.clearPasswords(); + + // Verify arrays are cleared + for (char c : newPassword) { + assertEquals('\0', c); + } + for (char c : confirmPassword) { + assertEquals('\0', c); + } + } + + @Test + void savePasswordDto_autoCloseable_clearsPasswordsOnClose() { + SavePasswordDto dto = new SavePasswordDto(); + char[] newPassword = "NewP@ssw0rd".toCharArray(); + char[] confirmPassword = "NewP@ssw0rd".toCharArray(); + + dto.setNewPasswordChars(newPassword); + dto.setConfirmPasswordChars(confirmPassword); + + // Try-with-resources should call close() + try (SavePasswordDto closeable = dto) { + assertNotNull(closeable.getNewPassword()); + assertNotNull(closeable.getConfirmPassword()); + } + + // Verify passwords are cleared + for (char c : newPassword) { + assertEquals('\0', c); + } + for (char c : confirmPassword) { + assertEquals('\0', c); + } + } + + @Test + void integrationTest_fullPasswordFlow() { + // Simulate a secure password handling flow + UserDto userDto = new UserDto(); + + // 1. Set password using char array + char[] password = "SecureP@ssw0rd123".toCharArray(); + char[] matching = "SecureP@ssw0rd123".toCharArray(); + + userDto.setPasswordChars(password); + userDto.setMatchingPasswordChars(matching); + + // 2. Verify String fields are updated + assertEquals("SecureP@ssw0rd123", userDto.getPassword()); + assertEquals("SecureP@ssw0rd123", userDto.getMatchingPassword()); + + // 3. Clear passwords + userDto.clearPasswords(); + + // 4. Verify char arrays are cleared + for (char c : password) { + assertEquals('\0', c); + } + for (char c : matching) { + assertEquals('\0', c); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java b/src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java new file mode 100644 index 0000000..d50b823 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java @@ -0,0 +1,197 @@ +package com.digitalsanctuary.spring.user.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for PasswordSecurityUtil. + */ +class PasswordSecurityUtilTest { + + @Test + void constantTimeEquals_returnsTrue_whenBothNull() { + assertTrue(PasswordSecurityUtil.constantTimeEquals(null, null)); + } + + @Test + void constantTimeEquals_returnsFalse_whenOneNull() { + assertFalse(PasswordSecurityUtil.constantTimeEquals(null, "password".toCharArray())); + assertFalse(PasswordSecurityUtil.constantTimeEquals("password".toCharArray(), null)); + } + + @Test + void constantTimeEquals_returnsTrue_whenIdentical() { + char[] password1 = "MySecureP@ssw0rd".toCharArray(); + char[] password2 = "MySecureP@ssw0rd".toCharArray(); + assertTrue(PasswordSecurityUtil.constantTimeEquals(password1, password2)); + } + + @Test + void constantTimeEquals_returnsFalse_whenDifferent() { + char[] password1 = "MySecureP@ssw0rd".toCharArray(); + char[] password2 = "DifferentP@ss".toCharArray(); + assertFalse(PasswordSecurityUtil.constantTimeEquals(password1, password2)); + } + + @Test + void constantTimeEquals_returnsFalse_whenDifferentLength() { + char[] password1 = "short".toCharArray(); + char[] password2 = "muchlongerpassword".toCharArray(); + assertFalse(PasswordSecurityUtil.constantTimeEquals(password1, password2)); + } + + @Test + void constantTimeEquals_returnsFalse_whenOneCharacterDifferent() { + char[] password1 = "MySecureP@ssw0rd".toCharArray(); + char[] password2 = "MySecureP@ssw0rD".toCharArray(); // last char different + assertFalse(PasswordSecurityUtil.constantTimeEquals(password1, password2)); + } + + @Test + void constantTimeEquals_returnsTrue_whenBothEmpty() { + char[] password1 = new char[0]; + char[] password2 = new char[0]; + assertTrue(PasswordSecurityUtil.constantTimeEquals(password1, password2)); + } + + @Test + void clearPassword_clearsArray() { + char[] password = "MySecureP@ssw0rd".toCharArray(); + PasswordSecurityUtil.clearPassword(password); + + // Verify all characters are cleared to '\0' + for (char c : password) { + assertEquals('\0', c, "Password should be cleared to null characters"); + } + } + + @Test + void clearPassword_handlesNull() { + // Should not throw exception + assertDoesNotThrow(() -> PasswordSecurityUtil.clearPassword(null)); + } + + @Test + void clearPassword_handlesEmptyArray() { + char[] password = new char[0]; + assertDoesNotThrow(() -> PasswordSecurityUtil.clearPassword(password)); + } + + @Test + void toString_convertsCorrectly() { + char[] password = "MySecureP@ssw0rd".toCharArray(); + String result = PasswordSecurityUtil.toString(password); + assertEquals("MySecureP@ssw0rd", result); + } + + @Test + void toString_handlesNull() { + assertNull(PasswordSecurityUtil.toString(null)); + } + + @Test + void toString_handlesEmptyArray() { + char[] password = new char[0]; + String result = PasswordSecurityUtil.toString(password); + assertEquals("", result); + } + + @Test + void toCharArray_convertsCorrectly() { + String password = "MySecureP@ssw0rd"; + char[] result = PasswordSecurityUtil.toCharArray(password); + assertArrayEquals("MySecureP@ssw0rd".toCharArray(), result); + } + + @Test + void toCharArray_handlesNull() { + assertNull(PasswordSecurityUtil.toCharArray(null)); + } + + @Test + void toCharArray_handlesEmptyString() { + String password = ""; + char[] result = PasswordSecurityUtil.toCharArray(password); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + void isNotEmpty_returnsTrue_whenNotEmpty() { + char[] password = "password".toCharArray(); + assertTrue(PasswordSecurityUtil.isNotEmpty(password)); + } + + @Test + void isNotEmpty_returnsFalse_whenNull() { + assertFalse(PasswordSecurityUtil.isNotEmpty(null)); + } + + @Test + void isNotEmpty_returnsFalse_whenEmpty() { + char[] password = new char[0]; + assertFalse(PasswordSecurityUtil.isNotEmpty(password)); + } + + @Test + void isEmpty_returnsTrue_whenNull() { + assertTrue(PasswordSecurityUtil.isEmpty(null)); + } + + @Test + void isEmpty_returnsTrue_whenEmpty() { + char[] password = new char[0]; + assertTrue(PasswordSecurityUtil.isEmpty(password)); + } + + @Test + void isEmpty_returnsFalse_whenNotEmpty() { + char[] password = "password".toCharArray(); + assertFalse(PasswordSecurityUtil.isEmpty(password)); + } + + @Test + void constructor_throwsException() { + assertThrows(java.lang.reflect.InvocationTargetException.class, () -> { + // Use reflection to try to instantiate + java.lang.reflect.Constructor constructor = + PasswordSecurityUtil.class.getDeclaredConstructor(); + constructor.setAccessible(true); + constructor.newInstance(); + }); + } + + @Test + void integrationTest_securePasswordFlow() { + // Simulate a secure password handling flow + String userInput = "MySecureP@ssw0rd!"; + + // 1. Convert to char[] + char[] password = PasswordSecurityUtil.toCharArray(userInput); + assertNotNull(password); + + // 2. Validate + assertTrue(PasswordSecurityUtil.isNotEmpty(password)); + + // 3. Compare with another password + char[] matchingPassword = "MySecureP@ssw0rd!".toCharArray(); + assertTrue(PasswordSecurityUtil.constantTimeEquals(password, matchingPassword)); + + // 4. Convert to String when needed (e.g., for PasswordEncoder) + String passwordString = PasswordSecurityUtil.toString(password); + assertEquals(userInput, passwordString); + + // 5. Clear both arrays + PasswordSecurityUtil.clearPassword(password); + PasswordSecurityUtil.clearPassword(matchingPassword); + + // 6. Verify cleared + for (char c : password) { + assertEquals('\0', c); + } + for (char c : matchingPassword) { + assertEquals('\0', c); + } + } +}