From 13e04a7f42bbd650319999a2abd4996141abacf2 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 26 Oct 2025 12:36:47 -0600 Subject: [PATCH 1/3] Fix critical password validation security vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses 4 critical security issues in password management: **Fix #1: Add password validation to /updatePassword endpoint** - Added passwordPolicyService.validate() call before saving password - Prevents users from setting weak passwords when updating - Enforces all policy rules including history, similarity, and complexity **Fix #2: Implement missing /savePassword endpoint** - Created SavePasswordDto for password reset with token - Implemented complete /savePassword endpoint with full validation - Added deletePasswordResetToken method to UserService - Added message keys for password reset flow - Fixes incomplete password reset functionality **Fix #3: Document password history behavior** - Added JavaDoc explaining null user parameter during registration - Documented that history checks only apply to existing users - Added clarifying comments in registration endpoint **Fix #4: Add transaction isolation for password history cleanup** - Added @Transactional(isolation = SERIALIZABLE) to cleanUpPasswordHistory - Prevents race conditions during concurrent password changes - Added comprehensive JavaDoc documentation **Additional improvements:** - Cascade delete configuration for PasswordHistoryEntry (from earlier work) - LAZY fetch type optimization on both sides of relationship - All existing tests pass (372 tests) See IMPLEMENTATION_PLAN_PASSWORD_FIXES.md for detailed analysis and implementation plan. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- IMPLEMENTATION_PLAN_PASSWORD_FIXES.md | 596 ++++++++++++++++++ .../spring/user/api/UserAPI.java | 83 +++ .../spring/user/dto/SavePasswordDto.java | 29 + .../model/PasswordHistoryEntry.java | 2 +- .../spring/user/persistence/model/User.java | 6 + .../user/service/PasswordPolicyService.java | 6 +- .../spring/user/service/UserService.java | 25 + .../messages/dsspringusermessages.properties | 2 + 8 files changed, 747 insertions(+), 2 deletions(-) create mode 100644 IMPLEMENTATION_PLAN_PASSWORD_FIXES.md create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java diff --git a/IMPLEMENTATION_PLAN_PASSWORD_FIXES.md b/IMPLEMENTATION_PLAN_PASSWORD_FIXES.md new file mode 100644 index 0000000..4605419 --- /dev/null +++ b/IMPLEMENTATION_PLAN_PASSWORD_FIXES.md @@ -0,0 +1,596 @@ +# Password Validation & History - Implementation Plan + +## Executive Summary +This plan addresses 4 critical security issues in the password management system where validation can be bypassed. + +--- + +## Priority 1: Critical Security Fixes + +### Fix #1: Add Password Validation to `/updatePassword` Endpoint +**Status:** ❌ Currently allows weak passwords +**Impact:** HIGH - Logged-in users can set weak passwords +**Estimated Time:** 2 hours + +#### Current Issue +The `/updatePassword` endpoint at [UserAPI.java:186-210](src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java#L186-L210) does not validate the new password against policy rules. + +#### Implementation Steps + +1. **Modify UserAPI.java - `/updatePassword` method** (30 min) + - Location: `src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java:186` + - Add password validation before calling `changeUserPassword()` + - Return validation errors to user if any policy violations + + ```java + @PostMapping("/updatePassword") + public ResponseEntity updatePassword(@AuthenticationPrincipal DSUserDetails userDetails, + @Valid @RequestBody PasswordDto passwordDto, HttpServletRequest request, Locale locale) { + validateAuthenticatedUser(userDetails); + User user = userDetails.getUser(); + + try { + // Verify old password + if (!userService.checkIfValidOldPassword(user, passwordDto.getOldPassword())) { + throw new InvalidOldPasswordException("Invalid old password"); + } + + // ✅ ADD: Validate new password against policy + List errors = passwordPolicyService.validate( + user, + passwordDto.getNewPassword(), + user.getEmail(), + locale + ); + + if (!errors.isEmpty()) { + log.warn("Password validation failed for user {}: {}", user.getEmail(), errors); + return buildErrorResponse(String.join(" ", errors), 1, HttpStatus.BAD_REQUEST); + } + + // Save password (this also saves to history) + 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); + return buildErrorResponse(messages.getMessage("message.update-password.invalid-old", null, locale), 1, + HttpStatus.BAD_REQUEST); + } catch (Exception ex) { + log.error("Unexpected error during password update.", ex); + logAuditEvent("PasswordUpdate", "Failure", ex.getMessage(), user, request); + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + ``` + +2. **Add Unit Tests** (45 min) + - Location: `src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java` + - Test cases: + - ✅ Valid password update succeeds + - ✅ Password too short is rejected + - ✅ Password missing required characters is rejected + - ✅ Password matching history is rejected + - ✅ Password too similar to email is rejected + - ✅ Invalid old password is rejected + +3. **Add Integration Tests** (45 min) + - Location: `src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java` + - Test full flow: login → update password with weak password → verify rejection + - Test full flow: login → update password with reused password → verify rejection + - Test full flow: login → update password with valid password → verify success + +#### Files to Modify +- `src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java` +- `src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java` +- `src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java` + +#### Testing Checklist +- [ ] Unit tests pass +- [ ] Integration tests pass +- [ ] Manual test: Update password with weak password (should fail) +- [ ] Manual test: Update password with reused password (should fail) +- [ ] Manual test: Update password with valid password (should succeed) + +--- + +### Fix #2: Implement Missing `/savePassword` Endpoint +**Status:** ❌ Endpoint missing - password reset flow incomplete +**Impact:** CRITICAL - Password reset doesn't work +**Estimated Time:** 3 hours + +#### Current Issue +The forgot-password form (Demo App) posts to `/user/resetPassword` with a token and new password, but: +- The existing `/resetPassword` endpoint only sends the email (takes email, not token+password) +- No endpoint exists to actually save the new password after token validation +- Config references `/user/savePassword` in unprotected URIs but it's not implemented + +#### Implementation Steps + +1. **Create SavePasswordDto** (15 min) + - Location: `src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java` + - Fields: `token`, `newPassword`, `confirmPassword` + - Add validation annotations + + ```java + package com.digitalsanctuary.spring.user.dto; + + import jakarta.validation.constraints.NotEmpty; + import jakarta.validation.constraints.NotNull; + import lombok.Data; + + @Data + public class SavePasswordDto { + + @NotNull + @NotEmpty + private String token; + + @NotNull + @NotEmpty + private String newPassword; + + @NotNull + @NotEmpty + private String confirmPassword; + } + ``` + +2. **Add `/savePassword` endpoint to UserAPI** (1 hour) + - Location: `src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java` + - Validate token is still valid + - Get user by token + - Validate new password against policy + - Check passwords match + - Save new password + - Invalidate token + + ```java + /** + * Saves a new password after password reset token validation. + * This endpoint is called from the password reset form after the user + * clicks the link in their email and enters a new password. + * + * @param savePasswordDto DTO containing token and new password + * @param request HTTP servlet request + * @param locale locale for messages + * @return ResponseEntity with success or error response + */ + @PostMapping("/savePassword") + public ResponseEntity savePassword( + @Valid @RequestBody SavePasswordDto savePasswordDto, + HttpServletRequest request, + Locale locale) { + + try { + // Validate passwords match + if (!savePasswordDto.getNewPassword().equals(savePasswordDto.getConfirmPassword())) { + return buildErrorResponse( + messages.getMessage("message.password.mismatch", null, locale), + 1, + HttpStatus.BAD_REQUEST + ); + } + + // Validate the reset token + TokenValidationResult tokenResult = userService.validatePasswordResetToken( + savePasswordDto.getToken() + ); + + if (tokenResult != TokenValidationResult.VALID) { + String messageKey = "auth.message." + tokenResult.getValue(); + return buildErrorResponse( + messages.getMessage(messageKey, null, locale), + 2, + HttpStatus.BAD_REQUEST + ); + } + + // Get user by token + Optional userOptional = userService.getUserByPasswordResetToken( + savePasswordDto.getToken() + ); + + if (userOptional.isEmpty()) { + return buildErrorResponse( + messages.getMessage("auth.message.invalid", null, locale), + 3, + HttpStatus.BAD_REQUEST + ); + } + + User user = userOptional.get(); + + // Validate new password against policy + List errors = passwordPolicyService.validate( + user, + savePasswordDto.getNewPassword(), + user.getEmail(), + locale + ); + + if (!errors.isEmpty()) { + log.warn("Password validation failed during reset for user {}: {}", + user.getEmail(), errors); + return buildErrorResponse(String.join(" ", errors), 4, HttpStatus.BAD_REQUEST); + } + + // Save the new password + userService.changeUserPassword(user, savePasswordDto.getNewPassword()); + + // Delete the reset token (it's been used) + PasswordResetToken token = userService.getPasswordResetToken(savePasswordDto.getToken()); + if (token != null) { + // TODO: Add deletePasswordResetToken method to UserService + // For now, the token will expire naturally + } + + logAuditEvent("PasswordReset", "Success", "Password reset completed", user, request); + + return buildSuccessResponse( + messages.getMessage("message.reset-password.success", null, locale), + "/user/login.html" + ); + + } catch (Exception ex) { + log.error("Unexpected error during password reset.", ex); + logAuditEvent("PasswordReset", "Failure", ex.getMessage(), null, request); + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + ``` + +3. **Add helper method to UserService** (15 min) + - Location: `src/main/java/com/digitalsanctuary/spring/user/service/UserService.java` + - Add method to delete password reset token after use + + ```java + /** + * Deletes a password reset token. + * + * @param token the token to delete + */ + public void deletePasswordResetToken(String token) { + PasswordResetToken resetToken = passwordTokenRepository.findByToken(token); + if (resetToken != null) { + passwordTokenRepository.delete(resetToken); + log.debug("Deleted password reset token for user: {}", resetToken.getUser().getEmail()); + } + } + ``` + +4. **Update configuration** (5 min) + - Verify `/user/savePassword` is in unprotected URIs list + - Location: `src/main/resources/config/dsspringuserconfig.properties:57` + - Already present: ✅ + +5. **Add message keys** (10 min) + - Location: `src/main/resources/messages/dsspringusermessages.properties` + - Add keys: + ```properties + message.password.mismatch=Passwords do not match + message.reset-password.success=Your password has been successfully reset. You can now log in with your new password. + ``` + +6. **Update Demo App Form** (15 min) + - Location: `SpringUserFrameworkDemoApp/src/main/resources/templates/user/forgot-password-change.html:25` + - Change form action from `/user/resetPassword` to `/user/savePassword` + - Ensure field names match DTO: `token`, `newPassword`, `confirmPassword` + +7. **Add Unit Tests** (45 min) + - Location: `src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java` + - Test cases: + - ✅ Valid token + valid password succeeds + - ✅ Invalid token is rejected + - ✅ Expired token is rejected + - ✅ Weak password is rejected + - ✅ Password in history is rejected + - ✅ Passwords not matching is rejected + +8. **Add Integration Tests** (45 min) + - Location: Create new test file `src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetFlowTest.java` + - Test full flow: + 1. Request password reset + 2. Extract token from "email" + 3. Submit new password with token + 4. Verify password was changed + 5. Verify old password no longer works + 6. Verify new password works for login + +#### Files to Create +- `src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java` +- `src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetFlowTest.java` + +#### Files to Modify +- `src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java` +- `src/main/java/com/digitalsanctuary/spring/user/service/UserService.java` +- `src/main/resources/messages/dsspringusermessages.properties` +- Demo App: `forgot-password-change.html` (form action) + +#### Testing Checklist +- [ ] Unit tests pass +- [ ] Integration tests pass +- [ ] Manual test: Full password reset flow (request → email → click link → enter password → verify) +- [ ] Manual test: Try to reuse reset token (should fail) +- [ ] Manual test: Try to use expired token (should fail) +- [ ] Manual test: Try weak password in reset (should fail) + +--- + +## Priority 2: Design & Architecture Fixes + +### Fix #3: Make Password History Check Explicit for Registration +**Status:** âš ī¸ Inconsistent - history not checked for new users +**Impact:** MEDIUM - Minor security gap, but acceptable for new users +**Estimated Time:** 1.5 hours + +#### Current Issue +At [UserAPI.java:77](src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java#L77), registration passes `null` for user: +```java +List errors = passwordPolicyService.validate(null, userDto.getPassword(), ...); +``` + +This bypasses history checking because [PasswordPolicyService.java:214-216](src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java#L214-L216) returns early if user is null. + +#### Design Decision Options + +**Option A: Accept Current Behavior (Recommended)** +- Document that password history only applies to existing users +- Add JavaDoc comments explaining this design decision +- Add unit test documenting this behavior + +**Option B: Check History for New Users** +- Could check if ANY user in the system has used this password +- Privacy concern: exposes that password exists in system +- Not recommended for security/privacy reasons + +**Option C: Split Validation Methods** +- Create separate methods: `validateNewUserPassword()` and `validateExistingUserPassword()` +- More explicit, but adds complexity + +#### Implementation Steps (Option A - Recommended) + +1. **Add Documentation** (30 min) + - Update JavaDoc in `PasswordPolicyService.validate()` + - Explain why user can be null for registration + - Document that history checks only apply when user != null + +2. **Add Test for New User Registration** (30 min) + - Location: `src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java` + - Test: `validate_skipsHistoryCheck_whenUserIsNull()` + - Documents expected behavior + +3. **Add Comment in UserAPI** (15 min) + ```java + // Note: Passing null for user during registration means password history + // is not checked (new users have no history). This is intentional - only + // existing users are checked against their own password history. + List errors = passwordPolicyService.validate(null, userDto.getPassword(), ...); + ``` + +#### Files to Modify +- `src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java` +- `src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java` +- `src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java` + +#### Testing Checklist +- [ ] Documentation is clear +- [ ] Test documents expected behavior +- [ ] Code review confirms design decision is acceptable + +--- + +### Fix #4: Add Transaction Isolation for Password History Cleanup +**Status:** âš ī¸ Race condition possible +**Impact:** LOW - Unlikely in practice +**Estimated Time:** 1 hour + +#### Current Issue +The cleanup method at [UserService.java:305-319](src/main/java/com/digitalsanctuary/spring/user/service/UserService.java#L305-L319) is not atomic. If two threads change the same user's password concurrently, they could both fetch the same list and cause issues. + +#### Implementation Steps + +1. **Add @Transactional with Isolation Level** (15 min) + - Location: `src/main/java/com/digitalsanctuary/spring/user/service/UserService.java:305` + - Add SERIALIZABLE isolation to prevent concurrent modifications + + ```java + @Transactional(isolation = Isolation.SERIALIZABLE) + private void cleanUpPasswordHistory(User user) { + if (user == null || historyCount <= 0) { + return; + } + + List entries = passwordHistoryRepository.findByUserOrderByEntryDateDesc(user); + int maxEntries = historyCount + 1; + + if (entries.size() > maxEntries) { + List toDelete = entries.subList(maxEntries, entries.size()); + passwordHistoryRepository.deleteAll(toDelete); + log.debug("Cleaned up {} old password history entries for user: {}", + toDelete.size(), user.getEmail()); + } + } + ``` + +2. **Alternative: Use Database-Level Constraint** (30 min) + - Add trigger or stored procedure to maintain max N entries per user + - More complex but handles concurrency at DB level + - Skip this if transaction isolation is sufficient + +3. **Add Concurrent Modification Test** (45 min) + - Location: `src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java` + - Test: Two threads changing password for same user simultaneously + - Verify cleanup doesn't fail or cause data issues + - Use CountDownLatch to coordinate threads + + ```java + @Test + void changeUserPassword_handlesLowRateConcurrentModification() throws Exception { + // This test verifies that concurrent password changes don't cause issues + // In practice, this is very unlikely as it requires the same user to + // change their password from two different sessions simultaneously + + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword(passwordEncoder.encode("OldPassword1!")); + user = userRepository.save(user); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(2); + User finalUser = user; + + Runnable changePassword = () -> { + try { + startLatch.await(); // Wait for both threads to be ready + userService.changeUserPassword(finalUser, "NewPassword" + + Thread.currentThread().getId() + "!"); + } catch (Exception e) { + log.error("Error in thread", e); + } finally { + doneLatch.countDown(); + } + }; + + new Thread(changePassword).start(); + new Thread(changePassword).start(); + + startLatch.countDown(); // Start both threads + doneLatch.await(5, TimeUnit.SECONDS); // Wait for completion + + // Verify: No exceptions thrown, password was changed + List history = + passwordHistoryRepository.findByUserOrderByEntryDateDesc(finalUser); + assertTrue(history.size() <= historyCount + 1, + "Should not have more than max entries even with concurrent changes"); + } + ``` + +#### Files to Modify +- `src/main/java/com/digitalsanctuary/spring/user/service/UserService.java` +- `src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java` + +#### Testing Checklist +- [ ] Unit tests pass +- [ ] Concurrent modification test passes +- [ ] Performance impact acceptable (SERIALIZABLE is stricter) + +--- + +## Implementation Order + +### Phase 1: Critical Fixes (Day 1) +1. Fix #2: Implement `/savePassword` endpoint (3 hours) + - Highest priority: Currently blocking password reset + - Must be completed first + +2. Fix #1: Add validation to `/updatePassword` (2 hours) + - High priority: Security vulnerability + - Straightforward implementation + +**Total: 5 hours (1 day)** + +### Phase 2: Refinements (Day 2) +3. Fix #3: Document password history behavior (1.5 hours) + - Medium priority: Documentation/consistency + - Low risk + +4. Fix #4: Add transaction isolation (1 hour) + - Low priority: Edge case + - Nice to have + +**Total: 2.5 hours (half day)** + +--- + +## Testing Strategy + +### Unit Tests +- Test each validation path independently +- Mock dependencies (PasswordPolicyService, UserService) +- Fast execution (< 1 second per test) + +### Integration Tests +- Test full flows with real database (H2 in-memory) +- Test cross-component interactions +- Verify end-to-end behavior + +### Manual Testing Checklist +- [ ] Register new user with weak password (should fail) +- [ ] Register new user with strong password (should succeed) +- [ ] Update password to weak password (should fail) +- [ ] Update password to reused password (should fail) +- [ ] Update password to valid password (should succeed) +- [ ] Request password reset +- [ ] Complete password reset with weak password (should fail) +- [ ] Complete password reset with valid password (should succeed) +- [ ] Try to reuse reset token (should fail) +- [ ] Verify password history is maintained correctly + +--- + +## Rollback Plan + +If issues are discovered after deployment: + +1. **Configuration-based Disable** + - Set `user.security.password.enabled=false` to disable validation + - Allows system to continue functioning while fix is developed + +2. **Endpoint-specific Rollback** + - If `/savePassword` has issues, temporarily disable forgot-password feature + - Users can still update passwords via `/updatePassword` when logged in + +3. **Database Rollback** + - Password history entries are append-only + - Safe to rollback without data loss + - Users may need to reset passwords again if rolled back mid-flow + +--- + +## Success Criteria + +### Functional Requirements Met +- ✅ All password changes validate against policy +- ✅ Password reset flow is complete and working +- ✅ Password history is enforced consistently +- ✅ No race conditions in concurrent scenarios + +### Quality Requirements Met +- ✅ All unit tests pass (>95% coverage on modified code) +- ✅ All integration tests pass +- ✅ Manual testing scenarios pass +- ✅ No regression in existing functionality + +### Documentation Requirements Met +- ✅ JavaDoc updated for all modified methods +- ✅ Design decisions documented +- ✅ Test cases document expected behavior + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Break existing password reset flow | Medium | High | Thorough testing of all password flows | +| Performance degradation from SERIALIZABLE isolation | Low | Low | Load testing, can reduce isolation if needed | +| Users frustrated by stricter validation | Medium | Low | Clear error messages, documentation | +| Missed edge case in validation | Low | Medium | Code review, extensive test coverage | + +--- + +## Notes + +- All changes are backwards compatible (additive only) +- No database schema changes required +- Configuration changes are minimal +- Can be deployed incrementally (Fix #2 → Fix #1 → Fix #3 → Fix #4) + +--- + +**Created:** 2025-01-26 +**Last Updated:** 2025-01-26 +**Assigned To:** TBD +**Estimated Total Time:** 7.5 hours (1.5 days) 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 314296f..c004432 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Locale; +import java.util.Optional; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; @@ -18,6 +19,7 @@ import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto; +import com.digitalsanctuary.spring.user.dto.SavePasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; @@ -74,6 +76,9 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User validateUserDto(userDto); // Password Policy Enforcement + // Note: Passing null for user during registration means password history + // is not checked (new users have no history). This is intentional - only + // existing users are checked against their own password history. List errors = passwordPolicyService.validate(null, userDto.getPassword(), userDto.getEmail(), request.getLocale()); @@ -171,6 +176,73 @@ public ResponseEntity resetPassword(@Valid @RequestBody PasswordRe return buildSuccessResponse("If account exists, password reset email has been sent!", forgotPasswordPendingURI); } + /** + * Saves a new password after password reset token validation. + * This endpoint is called from the password reset form after the user + * clicks the link in their email and enters a new password. + * + * @param savePasswordDto DTO containing token and new password + * @param request HTTP servlet request + * @param locale locale for messages + * @return ResponseEntity with success or error response + */ + @PostMapping("/savePassword") + public ResponseEntity savePassword(@Valid @RequestBody SavePasswordDto savePasswordDto, + HttpServletRequest request, Locale locale) { + + try { + // Validate passwords match + if (!savePasswordDto.getNewPassword().equals(savePasswordDto.getConfirmPassword())) { + return buildErrorResponse(messages.getMessage("message.password.mismatch", null, locale), 1, + HttpStatus.BAD_REQUEST); + } + + // Validate the reset token + UserService.TokenValidationResult tokenResult = userService + .validatePasswordResetToken(savePasswordDto.getToken()); + + if (tokenResult != UserService.TokenValidationResult.VALID) { + String messageKey = "auth.message." + tokenResult.getValue(); + return buildErrorResponse(messages.getMessage(messageKey, null, locale), 2, HttpStatus.BAD_REQUEST); + } + + // Get user by token + Optional userOptional = userService.getUserByPasswordResetToken(savePasswordDto.getToken()); + + if (userOptional.isEmpty()) { + return buildErrorResponse(messages.getMessage("auth.message.invalid", null, locale), 3, + HttpStatus.BAD_REQUEST); + } + + User user = userOptional.get(); + + // Validate new password against policy + List errors = passwordPolicyService.validate(user, savePasswordDto.getNewPassword(), + user.getEmail(), locale); + + if (!errors.isEmpty()) { + log.warn("Password validation failed during reset for user {}: {}", user.getEmail(), errors); + return buildErrorResponse(String.join(" ", errors), 4, HttpStatus.BAD_REQUEST); + } + + // Save the new password (this also saves to history) + userService.changeUserPassword(user, savePasswordDto.getNewPassword()); + + // Delete the reset token (it's been used) + userService.deletePasswordResetToken(savePasswordDto.getToken()); + + logAuditEvent("PasswordReset", "Success", "Password reset completed", user, request); + + return buildSuccessResponse(messages.getMessage("message.reset-password.success", null, locale), + "/user/login.html"); + + } catch (Exception ex) { + log.error("Unexpected error during password reset.", ex); + logAuditEvent("PasswordReset", "Failure", ex.getMessage(), null, request); + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + /** * Updates the user's password. This is used when the user is logged in and * wants to change their password. @@ -190,10 +262,21 @@ public ResponseEntity updatePassword(@AuthenticationPrincipal DSUs User user = userDetails.getUser(); try { + // Verify old password is correct if (!userService.checkIfValidOldPassword(user, passwordDto.getOldPassword())) { throw new InvalidOldPasswordException("Invalid old password"); } + // Validate new password against policy + List errors = passwordPolicyService.validate(user, passwordDto.getNewPassword(), user.getEmail(), + locale); + + if (!errors.isEmpty()) { + log.warn("Password validation failed for user {}: {}", user.getEmail(), errors); + return buildErrorResponse(String.join(" ", errors), 2, HttpStatus.BAD_REQUEST); + } + + // Save the new password (this also saves to history) userService.changeUserPassword(user, passwordDto.getNewPassword()); logAuditEvent("PasswordUpdate", "Success", "User password updated", user, request); diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java new file mode 100644 index 0000000..4a7ac64 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java @@ -0,0 +1,29 @@ +package com.digitalsanctuary.spring.user.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 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. + */ +@Data +public class SavePasswordDto { + + /** The password reset token from the email link. */ + @NotNull + @NotEmpty + private String token; + + /** The new password to set. */ + @NotNull + @NotEmpty + private String newPassword; + + /** Confirmation of the new password (must match newPassword). */ + @NotNull + @NotEmpty + private String confirmPassword; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordHistoryEntry.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordHistoryEntry.java index a0fbba7..90c8525 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordHistoryEntry.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordHistoryEntry.java @@ -33,7 +33,7 @@ public class PasswordHistoryEntry { private Long id; /** The user associated with this password entry. */ - @ManyToOne(fetch = FetchType.EAGER) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java index feb35e6..4b71010 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java @@ -105,6 +105,12 @@ public enum Provider { inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id")) private Set roles = new HashSet<>(); + /** The password history entries. */ + @ToString.Exclude + @EqualsAndHashCode.Exclude + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List passwordHistoryEntries = new ArrayList<>(); + /** * Instantiates a new user. */ 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 c006892..67a1e25 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java @@ -119,7 +119,11 @@ private void initCommonPasswords() { /** * Validate the given password against the configured policy rules. * - * @param user The user + *

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 password the password to validate * @param usernameOrEmail optional username/email for similarity checks * @param locale the locale 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 fc2b290..84293fc 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -17,6 +17,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; @@ -302,6 +303,14 @@ private void savePasswordHistory(User user, String encodedPassword) { cleanUpPasswordHistory(user); } + /** + * Cleans up old password history entries for a user, keeping only the most recent entries. + * Uses SERIALIZABLE isolation to prevent race conditions when the same user changes + * their password concurrently from multiple sessions. + * + * @param user the user whose password history should be cleaned up + */ + @Transactional(isolation = Isolation.SERIALIZABLE) private void cleanUpPasswordHistory(User user) { if (user == null || historyCount <= 0) { return; @@ -400,6 +409,22 @@ public Optional getUserByPasswordResetToken(final String token) { return Optional.ofNullable(passwordResetToken.getUser()); } + /** + * Deletes a password reset token after it has been used. + * + * @param token the token string to delete + */ + public void deletePasswordResetToken(final String token) { + if (token == null) { + return; + } + PasswordResetToken resetToken = passwordTokenRepository.findByToken(token); + if (resetToken != null) { + passwordTokenRepository.delete(resetToken); + log.debug("Deleted password reset token for user: {}", resetToken.getUser().getEmail()); + } + } + /** * Gets the user by ID. * diff --git a/src/main/resources/messages/dsspringusermessages.properties b/src/main/resources/messages/dsspringusermessages.properties index 3e8b0fa..d0d6d1b 100644 --- a/src/main/resources/messages/dsspringusermessages.properties +++ b/src/main/resources/messages/dsspringusermessages.properties @@ -14,6 +14,8 @@ email.signature=Best regards,
The DigitalSanctuary Team message.update-user.success=Your profile has been successfully updated. message.update-password.success=Your password has been successfully updated. message.update-password.invalid-old=The old password is incorrect. +message.password.mismatch=Passwords do not match. +message.reset-password.success=Your password has been successfully reset. You can now log in with your new password. message.account.verified=Your account has been successfully verified. message.logout.success=You logged out successfully From 3e19f7eae01f9c723a3e5a71e16c545406406ec2 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 26 Oct 2025 12:42:40 -0600 Subject: [PATCH 2/3] Update Demo App documentation - note existing password strength meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated DEMO_APP_CHANGES_REQUIRED.md to reflect that: - Password strength meter already exists on registration form - Can be extracted and reused on password reset and update forms - Reduced estimated effort from 2-4 hours to 1-2 hours 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- DEMO_APP_CHANGES_REQUIRED.md | 297 +++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 DEMO_APP_CHANGES_REQUIRED.md diff --git a/DEMO_APP_CHANGES_REQUIRED.md b/DEMO_APP_CHANGES_REQUIRED.md new file mode 100644 index 0000000..e3c25f1 --- /dev/null +++ b/DEMO_APP_CHANGES_REQUIRED.md @@ -0,0 +1,297 @@ +# Demo App Changes Required for Password Validation Fixes + +This document outlines the changes needed in the SpringUserFrameworkDemoApp to work with the password validation security fixes. + +--- + +## Critical Changes (Required for Password Reset to Work) + +### 1. Fix Password Reset Form Action +**Status:** ❌ BROKEN - Password reset doesn't work +**Priority:** CRITICAL +**Impact:** Password reset flow is currently non-functional + +#### Issue +The forgot-password-change form posts to the wrong endpoint: +- **Current:** Form posts to `/user/resetPassword` (which only sends email) +- **Expected:** Form posts to `/user/savePassword` (new endpoint that saves password) + +#### Files to Change + +**File:** `SpringUserFrameworkDemoApp/src/main/resources/templates/user/forgot-password-change.html` + +**Line 25:** Change form action +```html + +
+ + + +``` + +#### Field Name Validation +The current field names are CORRECT and match the new SavePasswordDto: +- ✅ `name="newPassword"` matches `SavePasswordDto.newPassword` +- ✅ `name="token"` matches `SavePasswordDto.token` +- ✅ `id="matchPassword"` is client-side only (not sent to server, which is correct) + +The JavaScript sends `confirmPassword` in the payload, which needs to be added to the form: + +**Line 33:** Add name attribute to confirmPassword field +```html + + + + + +``` + +--- + +## Optional Improvements (Non-Breaking, Enhances UX) + +### 2. Update Password Field Name Mismatch +**Status:** âš ī¸ WORKING but inconsistent +**Priority:** MEDIUM +**Impact:** Update password works, but field names don't match backend DTO + +#### Issue +The update-password form uses `currentPassword` but the backend expects `oldPassword`: + +**JavaScript sends:** +```javascript +{ + currentPassword: "...", // ❌ This field name + newPassword: "...", + confirmPassword: "..." +} +``` + +**Backend PasswordDto expects:** +```java +private String oldPassword; // ✅ This field name +private String newPassword; +``` + +**However:** This currently WORKS because Spring's model binding is lenient and maps it correctly, but it's inconsistent. + +#### Recommended Fix (Optional) + +**File:** `SpringUserFrameworkDemoApp/src/main/resources/static/js/user/update-password.js` + +**Lines 27-31:** Update field name +```javascript +// BEFORE +const requestData = { + currentPassword: currentPassword, + newPassword: newPassword, + confirmPassword: confirmPassword, +}; + +// AFTER +const requestData = { + oldPassword: currentPassword, // Changed to match backend DTO + newPassword: newPassword, +}; +// Note: confirmPassword removed - not needed by backend +``` + +**Alternative:** Keep it as-is since it's working. The inconsistency is minor. + +--- + +## Enhanced User Experience Changes + +### 3. Add Password Strength Meter to Other Forms +**Status:** â„šī¸ Enhancement (Partial - only on registration) +**Priority:** LOW +**Impact:** Users get better feedback on password requirements + +#### Current State +The Demo App already has a password strength meter on the **registration form** (`register.js`): +- ✅ Real-time strength calculation (Very Weak → Very Strong) +- ✅ Visual progress bar with color coding +- ✅ Password requirements checklist +- ✅ Checks for length, uppercase, lowercase, digits, special chars + +**Files with existing implementation:** +- `src/main/resources/static/js/user/register.js` (lines 49-188) +- `src/main/resources/templates/user/register.html` + +#### Enhancement Opportunity +Reuse the existing password strength meter code on: +1. **Password reset form** (`forgot-password-change.html`) +2. **Update password form** (`update-password.html`) + +**Implementation Steps:** +1. Extract `calculateStrength()` and `updateStrengthBar()` functions to a shared module: + - Create: `static/js/utils/password-validation.js` + - Export functions for reuse + +2. Add strength meter UI to other password forms: + - Modify: `templates/user/forgot-password-change.html` + - Modify: `templates/user/update-password.html` + - Import and use the shared password validation module + +3. Add password requirements checklist to both forms: + ```html +
+
Password must contain:
+
â€ĸ At least 8 characters
+
â€ĸ An uppercase letter (A-Z)
+
â€ĸ A lowercase letter (a-z)
+
â€ĸ A number (0-9)
+
â€ĸ A special character
+
+ ``` + +**Estimated Effort:** 1-2 hours (much less than creating from scratch) + +**Note:** This is a UX enhancement and not required for functionality. The backend validation already enforces all rules. + +--- + +## Testing Checklist + +After making the required changes, test these scenarios: + +### Password Reset Flow (Critical) +- [ ] Request password reset email +- [ ] Click link in email (opens forgot-password-change.html) +- [ ] Enter weak password (too short) + - Expected: Error message about password requirements +- [ ] Enter password that matches old password + - Expected: Error message about password history +- [ ] Enter valid strong password + - Expected: Success message, redirected to login +- [ ] Log in with new password + - Expected: Login succeeds +- [ ] Try to reuse the reset token + - Expected: Error - token already used/invalid + +### Update Password Flow (Should Continue Working) +- [ ] Log in as existing user +- [ ] Navigate to update-password page +- [ ] Enter wrong old password + - Expected: Error "The old password is incorrect" (error code 1) +- [ ] Enter correct old password but weak new password + - Expected: Error about password requirements (error code 2) +- [ ] Enter correct old password and password from history + - Expected: Error about password reuse (error code 2) +- [ ] Enter correct old password and valid new password + - Expected: Success message +- [ ] Log out and log in with new password + - Expected: Login succeeds + +### Registration Flow (Should Continue Working) +- [ ] Register new user with weak password + - Expected: Error message about password requirements +- [ ] Register new user with strong password + - Expected: Success, email sent (if verification enabled) + +--- + +## Implementation Priority + +### Phase 1: Critical Fix (Must Do Now) +1. **Fix password reset form action** (5 minutes) + - Change form action to `/user/savePassword` + - Add `name="confirmPassword"` to match password field + - Test password reset flow end-to-end + +### Phase 2: Optional Consistency (Do Later) +2. **Fix field name inconsistency** (5 minutes) + - Update `update-password.js` to use `oldPassword` instead of `currentPassword` + - Test update password flow + +### Phase 3: UX Enhancement (Nice to Have) +3. **Reuse password strength meter on other forms** (1-2 hours) + - Extract existing meter code to shared module + - Add to password reset and update password forms + - Note: Registration already has this implemented + +--- + +## Code Changes Summary + +### Required Changes (1 file, 2 lines) +```diff +File: SpringUserFrameworkDemoApp/src/main/resources/templates/user/forgot-password-change.html + +- ++ + +- ++ +``` + +### Optional Changes (1 file, 5 lines) +```diff +File: SpringUserFrameworkDemoApp/src/main/resources/static/js/user/update-password.js + + const requestData = { +- currentPassword: currentPassword, ++ oldPassword: currentPassword, + newPassword: newPassword, +- confirmPassword: confirmPassword, + }; +``` + +--- + +## Validation Reference + +### Backend Password Policy (Configured in Framework) +These rules are enforced by the backend `PasswordPolicyService`: + +```properties +user.security.password.enabled=true +user.security.password.min-length=8 +user.security.password.max-length=128 +user.security.password.require-uppercase=true +user.security.password.require-lowercase=true +user.security.password.require-digit=true +user.security.password.require-special=true +user.security.password.special-chars=~`!@#$%^&*()_-+={}[]|\:;"'<>,.?/ +user.security.password.prevent-common-passwords=true +user.security.password.history-count=3 +user.security.password.similarity-threshold=70 +``` + +### Error Messages (Now Available) +New message keys added to framework: +- `message.password.mismatch` - "Passwords do not match." +- `message.reset-password.success` - "Your password has been successfully reset. You can now log in with your new password." + +Existing message keys: +- `TOO_SHORT`, `TOO_LONG`, `INSUFFICIENT_UPPERCASE`, etc. +- `password.error.history.reuse` - "You cannot reuse your last {0} passwords." +- `password.error.similarity` - "Password is too similar to your username or email ({0}% similarity)." + +--- + +## Backward Compatibility + +All changes are backward compatible: +- ✅ Existing `/user/resetPassword` endpoint still works (sends email) +- ✅ New `/user/savePassword` endpoint is additive +- ✅ Existing `/user/updatePassword` endpoint enhanced with validation +- ✅ No breaking changes to existing APIs +- ✅ No database schema changes + +The only "breaking" change is that the Demo App's password reset flow was already non-functional (form posted to wrong endpoint), so fixing it improves rather than breaks functionality. + +--- + +## Questions? + +If you have questions about these changes: +1. See [IMPLEMENTATION_PLAN_PASSWORD_FIXES.md](IMPLEMENTATION_PLAN_PASSWORD_FIXES.md) for detailed context +2. Review the code review findings that led to these fixes +3. Test the changes in a development environment first + +--- + +**Last Updated:** 2025-01-26 +**Framework Version:** 3.5.1-SNAPSHOT +**Related Branch:** feature/password-validation-fixes From e3fbb96c0b5b47c0fbe637886772d964d47011c5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 26 Oct 2025 12:54:25 -0600 Subject: [PATCH 3/3] Address PR feedback: optimize token deletion and clarify password comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses code review feedback from Copilot AI: **1. Clarify password comparison timing attack concern** - Added detailed comment explaining why equals() is safe for comparing two user-provided strings (newPassword vs confirmPassword) - Timing attacks only matter when comparing against stored secrets - Spring's PasswordEncoder already uses constant-time comparison for actual credential verification **2. Optimize password reset token deletion** - Added @Modifying query method deleteByToken() to PasswordResetTokenRepository - Uses direct DELETE query instead of SELECT + DELETE - Reduces database roundtrips from 2 to 1 - Updated UserService.deletePasswordResetToken() to use optimized method - Maintains logging while improving performance All tests pass (372 tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../com/digitalsanctuary/spring/user/api/UserAPI.java | 4 ++++ .../repository/PasswordResetTokenRepository.java | 11 +++++++++++ .../spring/user/service/UserService.java | 8 ++++---- 3 files changed, 19 insertions(+), 4 deletions(-) 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 c004432..d6d0315 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -192,6 +192,10 @@ public ResponseEntity savePassword(@Valid @RequestBody SavePasswor try { // Validate passwords match + // Note: Using equals() is safe here - we're comparing two user-provided strings + // from the same request (not comparing against a stored secret), so timing attacks + // are not a concern. Constant-time comparison is only needed when comparing + // against stored credentials, which is handled by Spring's PasswordEncoder. if (!savePasswordDto.getNewPassword().equals(savePasswordDto.getConfirmPassword())) { return buildErrorResponse(messages.getMessage("message.password.mismatch", null, locale), 1, HttpStatus.BAD_REQUEST); diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java index 918df9e..7287caa 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java @@ -52,4 +52,15 @@ public interface PasswordResetTokenRepository extends JpaRepository getUserByPasswordResetToken(final String token) { /** * Deletes a password reset token after it has been used. + * Uses a direct DELETE query for efficiency (no SELECT required). * * @param token the token string to delete */ @@ -418,10 +419,9 @@ public void deletePasswordResetToken(final String token) { if (token == null) { return; } - PasswordResetToken resetToken = passwordTokenRepository.findByToken(token); - if (resetToken != null) { - passwordTokenRepository.delete(resetToken); - log.debug("Deleted password reset token for user: {}", resetToken.getUser().getEmail()); + int deletedCount = passwordTokenRepository.deleteByToken(token); + if (deletedCount > 0) { + log.debug("Deleted password reset token: {}", token); } }