From bf8211b0bea08f78c85bfe95c9aba48118383c95 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 23 Feb 2026 21:50:15 -0700 Subject: [PATCH 1/3] feat: add passwordless passkey-only account support (#254) Enable users to register without a password and go fully passwordless by removing their password after adding passkeys. Adds new DTOs (PasswordlessRegistrationDto, SetPasswordDto, AuthMethodsResponse), new UserService methods (hasPassword, removeUserPassword, setInitialPassword, registerPasswordlessAccount), and new API endpoints (GET /user/auth-methods, POST /user/registration/passwordless, POST /user/setPassword, DELETE /user/webauthn/password). --- .../spring/user/api/UserAPI.java | 115 ++++++++++++ .../user/api/WebAuthnManagementAPI.java | 46 +++++ .../spring/user/dto/AuthMethodsResponse.java | 31 ++++ .../user/dto/PasswordlessRegistrationDto.java | 38 ++++ .../spring/user/dto/SetPasswordDto.java | 30 ++++ .../repository/PasswordHistoryRepository.java | 8 + .../spring/user/service/UserService.java | 87 ++++++++- .../WebAuthnCredentialManagementService.java | 10 ++ .../user/api/WebAuthnManagementAPITest.java | 71 ++++++++ .../spring/user/service/UserServiceTest.java | 166 ++++++++++++++++++ 10 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/SetPasswordDto.java 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 e57cfa5..3162fd8 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -4,6 +4,7 @@ import java.util.Locale; import java.util.Optional; import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; @@ -12,14 +13,18 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.dto.AuthMethodsResponse; import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto; +import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.SavePasswordDto; +import com.digitalsanctuary.spring.user.dto.SetPasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; @@ -30,6 +35,7 @@ import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService; import com.digitalsanctuary.spring.user.util.JSONResponse; import com.digitalsanctuary.spring.user.util.UserUtils; import jakarta.servlet.ServletException; @@ -61,6 +67,9 @@ public class UserAPI { private final ApplicationEventPublisher eventPublisher; private final PasswordPolicyService passwordPolicyService; + @Autowired(required = false) + private WebAuthnCredentialManagementService webAuthnCredentialManagementService; + @Value("${user.security.registrationPendingURI}") private String registrationPendingURI; @@ -337,6 +346,112 @@ public ResponseEntity deleteAccount(@AuthenticationPrincipal DSUse return buildSuccessResponse("Account Deleted", null); } + /** + * Returns the authentication methods configured for the current user. + * + * @param userDetails the authenticated user details + * @return a ResponseEntity containing the auth methods response + */ + @GetMapping("/auth-methods") + public ResponseEntity getAuthMethods(@AuthenticationPrincipal DSUserDetails userDetails) { + validateAuthenticatedUser(userDetails); + User user = userService.findUserByEmail(userDetails.getUser().getEmail()); + if (user == null) { + return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST); + } + + boolean hasPasskeys = false; + int passkeysCount = 0; + if (webAuthnCredentialManagementService != null) { + long count = webAuthnCredentialManagementService.getCredentialCount(user); + hasPasskeys = count > 0; + passkeysCount = (int) count; + } + + AuthMethodsResponse authMethods = AuthMethodsResponse.builder() + .hasPassword(userService.hasPassword(user)) + .hasPasskeys(hasPasskeys) + .passkeysCount(passkeysCount) + .provider(user.getProvider()) + .build(); + + return ResponseEntity.ok(JSONResponse.builder().success(true).data(authMethods).build()); + } + + /** + * Registers a new passwordless user account (passkey-only). + * + * @param dto the passwordless registration DTO + * @param request the HTTP servlet request + * @return a ResponseEntity containing a JSONResponse with the registration result + */ + @PostMapping("/registration/passwordless") + public ResponseEntity registerPasswordlessAccount(@Valid @RequestBody PasswordlessRegistrationDto dto, + HttpServletRequest request) { + try { + User registeredUser = userService.registerPasswordlessAccount(dto); + publishRegistrationEvent(registeredUser, request); + logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request); + + String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; + + return buildSuccessResponse("Registration Successful!", nextURL); + } catch (UserAlreadyExistException ex) { + log.warn("User already exists with email: {}", dto.getEmail()); + logAuditEvent("PasswordlessRegistration", "Failure", "User Already Exists", null, request); + return buildErrorResponse("An account already exists for the email address", 2, HttpStatus.CONFLICT); + } catch (Exception ex) { + log.error("Unexpected error during passwordless registration.", ex); + logAuditEvent("PasswordlessRegistration", "Failure", ex.getMessage(), null, request); + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Sets an initial password for a passwordless account. + * + * @param userDetails the authenticated user details + * @param setPasswordDto the set password DTO + * @param request the HTTP servlet request + * @param locale the locale + * @return a ResponseEntity containing a JSONResponse with the result + */ + @PostMapping("/setPassword") + public ResponseEntity setPassword(@AuthenticationPrincipal DSUserDetails userDetails, + @Valid @RequestBody SetPasswordDto setPasswordDto, HttpServletRequest request, Locale locale) { + validateAuthenticatedUser(userDetails); + User user = userService.findUserByEmail(userDetails.getUser().getEmail()); + if (user == null) { + return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST); + } + + try { + if (userService.hasPassword(user)) { + return buildErrorResponse("User already has a password. Use the change password feature instead.", 1, HttpStatus.BAD_REQUEST); + } + + if (!setPasswordDto.getNewPassword().equals(setPasswordDto.getConfirmPassword())) { + return buildErrorResponse(messages.getMessage("message.password.mismatch", null, "Passwords do not match", locale), 2, + HttpStatus.BAD_REQUEST); + } + + List errors = passwordPolicyService.validate(user, setPasswordDto.getNewPassword(), user.getEmail(), locale); + if (!errors.isEmpty()) { + log.warn("Password validation failed for user {}: {}", user.getEmail(), errors); + return buildErrorResponse(String.join(" ", errors), 3, HttpStatus.BAD_REQUEST); + } + + userService.setInitialPassword(user, setPasswordDto.getNewPassword()); + logAuditEvent("SetPassword", "Success", "Initial password set for passwordless account", user, request); + + return buildSuccessResponse(messages.getMessage("message.set-password.success", null, "Password set successfully", locale), null); + } catch (Exception ex) { + log.error("Unexpected error during set password.", ex); + logAuditEvent("SetPassword", "Failure", ex.getMessage(), user, request); + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + // Helper Methods /** * Validates the user data transfer object. diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java index 24fa845..306ff70 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java @@ -2,6 +2,7 @@ import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; @@ -12,16 +13,21 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo; +import com.digitalsanctuary.spring.user.exceptions.WebAuthnException; import com.digitalsanctuary.spring.user.exceptions.WebAuthnUserNotFoundException; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.service.UserService; import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService; import com.digitalsanctuary.spring.user.util.GenericResponse; +import com.digitalsanctuary.spring.user.util.UserUtils; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; /** @@ -42,6 +48,7 @@ *
  • DELETE /user/webauthn/credentials/{id} - Delete a passkey
  • * */ +@Slf4j @RestController @RequestMapping("/user/webauthn") @ConditionalOnProperty(name = "user.webauthn.enabled", havingValue = "true", matchIfMissing = false) @@ -51,6 +58,7 @@ public class WebAuthnManagementAPI { private final WebAuthnCredentialManagementService credentialManagementService; private final UserService userService; + private final ApplicationEventPublisher eventPublisher; /** * Get user's registered passkeys. @@ -127,6 +135,44 @@ public ResponseEntity deleteCredential(@PathVariable @NotBlank return ResponseEntity.ok(new GenericResponse("Passkey deleted successfully")); } + /** + * Remove the user's password, making the account passwordless (passkey-only). + * + *

    + * Requires the user to have at least one passkey registered. This ensures + * the user can still authenticate after the password is removed. + *

    + * + * @param userDetails the authenticated user details + * @param request the HTTP servlet request + * @return ResponseEntity with success message or error + */ + @DeleteMapping("/password") + public ResponseEntity removePassword(@AuthenticationPrincipal UserDetails userDetails, + HttpServletRequest request) { + User user = findAuthenticatedUser(userDetails); + + if (!userService.hasPassword(user)) { + throw new WebAuthnException("User does not have a password to remove"); + } + + if (!credentialManagementService.hasCredentials(user)) { + throw new WebAuthnException("Cannot remove password. Please register a passkey first."); + } + + userService.removeUserPassword(user); + + AuditEvent event = AuditEvent.builder().source(this).user(user).sessionId(request.getSession().getId()) + .ipAddress(UserUtils.getClientIP(request)) + .userAgent(request.getHeader("User-Agent")).action("PasswordRemoval").actionStatus("Success") + .message("Password removed for passwordless account").build(); + eventPublisher.publishEvent(event); + + log.info("User {} removed their password", user.getEmail()); + + return ResponseEntity.ok(new GenericResponse("Password removed successfully")); + } + private User findAuthenticatedUser(UserDetails userDetails) throws WebAuthnUserNotFoundException { User user = userService.findUserByEmail(userDetails.getUsername()); if (user == null) { diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java b/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java new file mode 100644 index 0000000..d53018d --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java @@ -0,0 +1,31 @@ +package com.digitalsanctuary.spring.user.dto; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.Builder; +import lombok.Data; + +/** + * Response DTO for the auth-methods endpoint. + *

    + * Provides information about which authentication methods are configured + * for the current user, enabling the UI to show/hide relevant options. + *

    + * + * @author Devon Hillard + */ +@Data +@Builder +public class AuthMethodsResponse { + + /** Whether the user has a password set. */ + private boolean hasPassword; + + /** Whether the user has any passkeys registered. */ + private boolean hasPasskeys; + + /** The number of passkeys registered. */ + private int passkeysCount; + + /** The user's authentication provider. */ + private User.Provider provider; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java new file mode 100644 index 0000000..4e9ce0d --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java @@ -0,0 +1,38 @@ +package com.digitalsanctuary.spring.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * Data Transfer Object for passwordless user registration. + *

    + * Used for registering users who will authenticate exclusively with passkeys, + * without setting an initial password. Contains only the user's name and email. + *

    + * + * @author Devon Hillard + */ +@Data +public class PasswordlessRegistrationDto { + + /** The first name. */ + @NotBlank(message = "First name is required") + @Size(max = 50, message = "First name must not exceed 50 characters") + private String firstName; + + /** The last name. */ + @NotBlank(message = "Last name is required") + @Size(max = 50, message = "Last name must not exceed 50 characters") + private String lastName; + + /** The email. */ + @NotBlank(message = "Email is required") + @Email(message = "Please provide a valid email address") + @Size(max = 100, message = "Email must not exceed 100 characters") + private String email; + + /** The role. */ + private Integer role; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/SetPasswordDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/SetPasswordDto.java new file mode 100644 index 0000000..edf22bf --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/SetPasswordDto.java @@ -0,0 +1,30 @@ +package com.digitalsanctuary.spring.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.ToString; + +/** + * Data Transfer Object for setting an initial password on a passwordless account. + *

    + * Used when a user who registered without a password (passkey-only) wants to add + * a password to their account. Contains the new password and confirmation. + *

    + * + * @author Devon Hillard + */ +@Data +public class SetPasswordDto { + + /** The new password to set. */ + @ToString.Exclude + @NotBlank(message = "Password is required") + @Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters") + private String newPassword; + + /** Confirmation of the new password (must match newPassword). */ + @ToString.Exclude + @NotBlank(message = "Password confirmation is required") + private String confirmPassword; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java index d2f8f46..3d3e2f5 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java @@ -33,4 +33,12 @@ public interface PasswordHistoryRepository extends JpaRepository findByUserOrderByEntryDateDesc(User user); + + /** + * Delete all password history entries for a user. + * Used when removing a user's password for passwordless accounts. + * + * @param user the user whose history should be deleted + */ + void deleteByUser(User user); } 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 3eb8fa0..48cba13 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -23,6 +23,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; @@ -264,7 +265,7 @@ public User registerNewUserAccount(final UserDto newUserDto) { User user = new User(); user.setFirstName(newUserDto.getFirstName()); user.setLastName(newUserDto.getLastName()); - user.setPassword(passwordEncoder.encode(newUserDto.getPassword())); + user.setPassword(newUserDto.getPassword() != null ? passwordEncoder.encode(newUserDto.getPassword()) : null); user.setEmail(newUserDto.getEmail().toLowerCase()); user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); @@ -466,12 +467,92 @@ public void changeUserPassword(final User user, final String password) { * @return true, if successful */ public boolean checkIfValidOldPassword(final User user, final String oldPassword) { - // Removed System.out.println, using log.debug for minimal output (avoid logging - // passwords in production) log.debug("Verifying old password for user: {}", user.getEmail()); + if (user.getPassword() == null) { + return false; + } return passwordEncoder.matches(oldPassword, user.getPassword()); } + /** + * Checks whether the user has a password set. + * + * @param user the user to check + * @return true if the user has a non-empty password + */ + public boolean hasPassword(User user) { + return user.getPassword() != null && !user.getPassword().isEmpty(); + } + + /** + * Removes the user's password, making the account passwordless. + * Also clears all password history entries for the user. + * + * @param user the user whose password should be removed + */ + @Transactional + public void removeUserPassword(User user) { + user.setPassword(null); + userRepository.save(user); + passwordHistoryRepository.deleteByUser(user); + log.info("Password removed for user: {}", user.getEmail()); + } + + /** + * Sets an initial password for a passwordless account. + * Throws if the user already has a password. + * + * @param user the user to set the password for + * @param rawPassword the raw password to encode and save + * @throws IllegalStateException if the user already has a password + */ + @Transactional + public void setInitialPassword(User user, String rawPassword) { + if (hasPassword(user)) { + throw new IllegalStateException("User already has a password"); + } + String encodedPassword = passwordEncoder.encode(rawPassword); + user.setPassword(encodedPassword); + userRepository.save(user); + savePasswordHistory(user, encodedPassword); + log.info("Initial password set for user: {}", user.getEmail()); + } + + /** + * Registers a new passwordless user account (no password). + * Uses SERIALIZABLE isolation to prevent race conditions during concurrent registration. + * + * @param dto the passwordless registration data + * @return the newly created user entity + * @throws UserAlreadyExistException if an account with the same email already exists + */ + @Transactional(isolation = Isolation.SERIALIZABLE) + public User registerPasswordlessAccount(final PasswordlessRegistrationDto dto) { + TimeLogger timeLogger = new TimeLogger(log, "UserService.registerPasswordlessAccount"); + log.debug("UserService.registerPasswordlessAccount: called with dto: {}", dto); + + if (emailExists(dto.getEmail())) { + log.debug("UserService.registerPasswordlessAccount: email already exists: {}", dto.getEmail()); + throw new UserAlreadyExistException( + "There is an account with that email address: " + dto.getEmail()); + } + + User user = new User(); + user.setFirstName(dto.getFirstName()); + user.setLastName(dto.getLastName()); + user.setPassword(null); + user.setEmail(dto.getEmail().toLowerCase()); + user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); + + if (!sendRegistrationVerificationEmail) { + user.setEnabled(true); + } + + user = userRepository.save(user); + timeLogger.end(); + return user; + } + /** * See if the Email exists in the user repository. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java index 523fc74..21e47ae 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java @@ -55,6 +55,16 @@ public boolean hasCredentials(User user) { return credentialQueryRepository.hasCredentials(user.getId()); } + /** + * Get the number of passkeys for a user. + * + * @param user the user to count credentials for + * @return the number of registered passkeys + */ + public long getCredentialCount(User user) { + return credentialQueryRepository.countCredentials(user.getId()); + } + /** * Rename a credential label. * diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java index 2af48e6..ae1b1dc 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -17,9 +18,11 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UserDetails; +import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo; import com.digitalsanctuary.spring.user.exceptions.WebAuthnException; import com.digitalsanctuary.spring.user.exceptions.WebAuthnUserNotFoundException; @@ -29,6 +32,8 @@ import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; import com.digitalsanctuary.spring.user.util.GenericResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; @ServiceTest @DisplayName("WebAuthnManagementAPI Tests") @@ -40,6 +45,9 @@ class WebAuthnManagementAPITest { @Mock private UserService userService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock private UserDetails userDetails; @@ -234,4 +242,67 @@ void shouldThrowNotFoundWhenUserNotFound() { verify(credentialManagementService, never()).deleteCredential(any(), any()); } } + + @Nested + @DisplayName("DELETE /user/webauthn/password") + class RemovePasswordTests { + + private HttpServletRequest createMockRequest() { + HttpServletRequest request = mock(HttpServletRequest.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(request.getSession().getId()).thenReturn("test-session-id"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getHeader(any())).thenReturn(null); + return request; + } + + @Test + @DisplayName("should remove password when user has passkeys") + void shouldRemovePasswordWhenUserHasPasskeys() { + // Given + HttpServletRequest mockRequest = createMockRequest(); + testUser.setPassword("encodedPassword"); + when(userService.hasPassword(testUser)).thenReturn(true); + when(credentialManagementService.hasCredentials(testUser)).thenReturn(true); + + // When + ResponseEntity response = api.removePassword(userDetails, mockRequest); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getMessage()).contains("Password removed successfully"); + verify(userService).removeUserPassword(testUser); + verify(eventPublisher).publishEvent(any(AuditEvent.class)); + } + + @Test + @DisplayName("should reject removal when no passkeys") + void shouldRejectRemovalWhenNoPasskeys() { + // Given + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + testUser.setPassword("encodedPassword"); + when(userService.hasPassword(testUser)).thenReturn(true); + when(credentialManagementService.hasCredentials(testUser)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> api.removePassword(userDetails, mockRequest)) + .isInstanceOf(WebAuthnException.class) + .hasMessageContaining("register a passkey first"); + verify(userService, never()).removeUserPassword(any()); + } + + @Test + @DisplayName("should reject removal when already passwordless") + void shouldRejectRemovalWhenAlreadyPasswordless() { + // Given + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + testUser.setPassword(null); + when(userService.hasPassword(testUser)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> api.removePassword(userDetails, mockRequest)) + .isInstanceOf(WebAuthnException.class) + .hasMessageContaining("does not have a password"); + verify(userService, never()).removeUserPassword(any()); + } + } } 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 e9f318b..7815106 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -38,6 +38,7 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; @@ -615,6 +616,171 @@ void authWithoutPassword_handlesNoRequestContext() { } } } + @Nested + @DisplayName("Password Status Tests") + class PasswordStatusTests { + + @Test + @DisplayName("shouldReturnTrueWhenUserHasPassword") + void shouldReturnTrueWhenUserHasPassword() { + // Given + testUser.setPassword("encodedPassword"); + + // When + boolean result = userService.hasPassword(testUser); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("shouldReturnFalseWhenPasswordNull") + void shouldReturnFalseWhenPasswordNull() { + // Given + testUser.setPassword(null); + + // When + boolean result = userService.hasPassword(testUser); + + // Then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldReturnFalseWhenPasswordEmpty") + void shouldReturnFalseWhenPasswordEmpty() { + // Given + testUser.setPassword(""); + + // When + boolean result = userService.hasPassword(testUser); + + // Then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldReturnFalseForValidOldPasswordWhenPasswordNull") + void shouldReturnFalseForValidOldPasswordWhenPasswordNull() { + // Given + testUser.setPassword(null); + + // When + boolean result = userService.checkIfValidOldPassword(testUser, "anyPassword"); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("Password Removal Tests") + class PasswordRemovalTests { + + @Test + @DisplayName("shouldRemovePasswordAndClearHistory") + void shouldRemovePasswordAndClearHistory() { + // Given + testUser.setPassword("encodedPassword"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.removeUserPassword(testUser); + + // Then + assertThat(testUser.getPassword()).isNull(); + verify(userRepository).save(testUser); + verify(passwordHistoryRepository).deleteByUser(testUser); + } + } + + @Nested + @DisplayName("Set Initial Password Tests") + class SetInitialPasswordTests { + + @Test + @DisplayName("shouldSetInitialPasswordWhenNoPassword") + void shouldSetInitialPasswordWhenNoPassword() { + // Given + testUser.setPassword(null); + String rawPassword = "NewSecurePassword123!"; + String encodedPassword = "encodedNewPassword"; + when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.setInitialPassword(testUser, rawPassword); + + // Then + assertThat(testUser.getPassword()).isEqualTo(encodedPassword); + verify(passwordEncoder).encode(rawPassword); + verify(userRepository).save(testUser); + } + + @Test + @DisplayName("shouldThrowWhenUserAlreadyHasPassword") + void shouldThrowWhenUserAlreadyHasPassword() { + // Given + testUser.setPassword("existingPassword"); + + // When & Then + assertThatThrownBy(() -> userService.setInitialPassword(testUser, "newPassword")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already has a password"); + } + } + + @Nested + @DisplayName("Passwordless Registration Tests") + class PasswordlessRegistrationTests { + + @Test + @DisplayName("shouldRegisterPasswordlessAccountSuccessfully") + void shouldRegisterPasswordlessAccountSuccessfully() { + // Given + PasswordlessRegistrationDto dto = new PasswordlessRegistrationDto(); + dto.setFirstName("Test"); + dto.setLastName("User"); + dto.setEmail("passwordless@example.com"); + + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + ReflectionTestUtils.setField(userService, "sendRegistrationVerificationEmail", false); + + // When + User saved = userService.registerPasswordlessAccount(dto); + + // Then + assertThat(saved).isNotNull(); + assertThat(saved.getEmail()).isEqualTo("passwordless@example.com"); + assertThat(saved.getFirstName()).isEqualTo("Test"); + assertThat(saved.getLastName()).isEqualTo("User"); + assertThat(saved.getPassword()).isNull(); + assertThat(saved.isEnabled()).isTrue(); + verify(userRepository).save(any(User.class)); + verify(passwordEncoder, never()).encode(anyString()); + } + + @Test + @DisplayName("shouldThrowWhenEmailExists") + void shouldThrowWhenEmailExists() { + // Given + PasswordlessRegistrationDto dto = new PasswordlessRegistrationDto(); + dto.setFirstName("Test"); + dto.setLastName("User"); + dto.setEmail(testUser.getEmail()); + + when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser); + + // When & Then + assertThatThrownBy(() -> userService.registerPasswordlessAccount(dto)) + .isInstanceOf(UserAlreadyExistException.class) + .hasMessageContaining("There is an account with that email address"); + } + } + // Tests temporarily disabled until OAuth2 dependency issue is resolved // @Test // void checkIfValidOldPassword_returnFalseIfInvalid() { From e0a3fe78567cf061d60c080b98048657d1c34795 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 24 Feb 2026 07:25:24 -0700 Subject: [PATCH 2/3] fix: address PR review feedback for passwordless account support - Guard passwordless registration when WebAuthn is unavailable (L1) - Invalidate sessions on password removal for security (L2) - Reject null password in registerNewUserAccount (L3) - Replace @Autowired field with ObjectProvider constructor injection (L4) - Remove unused role field from PasswordlessRegistrationDto (L5) - Change passkeysCount from int to long (L6) - Add webAuthnEnabled to AuthMethodsResponse (L7) - Verify savePasswordHistory in setInitialPassword test (L8) --- .../spring/user/api/UserAPI.java | 20 ++++++++++--------- .../spring/user/dto/AuthMethodsResponse.java | 5 ++++- .../user/dto/PasswordlessRegistrationDto.java | 2 -- .../spring/user/service/UserService.java | 10 +++++++++- .../spring/user/service/UserServiceTest.java | 5 +++++ 5 files changed, 29 insertions(+), 13 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 3162fd8..35d0a05 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -4,7 +4,7 @@ import java.util.Locale; import java.util.Optional; import jakarta.validation.Valid; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; @@ -66,9 +66,7 @@ public class UserAPI { private final MessageSource messages; private final ApplicationEventPublisher eventPublisher; private final PasswordPolicyService passwordPolicyService; - - @Autowired(required = false) - private WebAuthnCredentialManagementService webAuthnCredentialManagementService; + private final ObjectProvider webAuthnCredentialManagementServiceProvider; @Value("${user.security.registrationPendingURI}") private String registrationPendingURI; @@ -360,18 +358,19 @@ public ResponseEntity getAuthMethods(@AuthenticationPrincipal DSUs return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST); } + WebAuthnCredentialManagementService webAuthnService = webAuthnCredentialManagementServiceProvider.getIfAvailable(); boolean hasPasskeys = false; - int passkeysCount = 0; - if (webAuthnCredentialManagementService != null) { - long count = webAuthnCredentialManagementService.getCredentialCount(user); - hasPasskeys = count > 0; - passkeysCount = (int) count; + long passkeysCount = 0; + if (webAuthnService != null) { + passkeysCount = webAuthnService.getCredentialCount(user); + hasPasskeys = passkeysCount > 0; } AuthMethodsResponse authMethods = AuthMethodsResponse.builder() .hasPassword(userService.hasPassword(user)) .hasPasskeys(hasPasskeys) .passkeysCount(passkeysCount) + .webAuthnEnabled(webAuthnService != null) .provider(user.getProvider()) .build(); @@ -388,6 +387,9 @@ public ResponseEntity getAuthMethods(@AuthenticationPrincipal DSUs @PostMapping("/registration/passwordless") public ResponseEntity registerPasswordlessAccount(@Valid @RequestBody PasswordlessRegistrationDto dto, HttpServletRequest request) { + if (webAuthnCredentialManagementServiceProvider.getIfAvailable() == null) { + return buildErrorResponse("Passwordless registration is not available", 1, HttpStatus.BAD_REQUEST); + } try { User registeredUser = userService.registerPasswordlessAccount(dto); publishRegistrationEvent(registeredUser, request); diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java b/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java index d53018d..d180e57 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/AuthMethodsResponse.java @@ -24,7 +24,10 @@ public class AuthMethodsResponse { private boolean hasPasskeys; /** The number of passkeys registered. */ - private int passkeysCount; + private long passkeysCount; + + /** Whether WebAuthn is enabled on the server. */ + private boolean webAuthnEnabled; /** The user's authentication provider. */ private User.Provider provider; diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java index 4e9ce0d..3c307cd 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/PasswordlessRegistrationDto.java @@ -33,6 +33,4 @@ public class PasswordlessRegistrationDto { @Size(max = 100, message = "Email must not exceed 100 characters") private String email; - /** The role. */ - private Integer role; } 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 48cba13..31e1595 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -224,6 +224,8 @@ public String getValue() { private final PasswordHistoryRepository passwordHistoryRepository; + private final SessionInvalidationService sessionInvalidationService; + /** The send registration verification email flag. */ @Value("${user.registration.sendVerificationEmail:false}") private boolean sendRegistrationVerificationEmail; @@ -249,8 +251,13 @@ public User registerNewUserAccount(final UserDto newUserDto) { TimeLogger timeLogger = new TimeLogger(log, "UserService.registerNewUserAccount"); log.debug("UserService.registerNewUserAccount: called with userDto: {}", newUserDto); + if (newUserDto.getPassword() == null) { + throw new IllegalArgumentException( + "Password is required for standard registration. Use registerPasswordlessAccount() for passwordless accounts."); + } + // Validate password match only if both are provided - if (newUserDto.getPassword() != null && newUserDto.getMatchingPassword() != null + if (newUserDto.getMatchingPassword() != null && !newUserDto.getPassword().equals(newUserDto.getMatchingPassword())) { throw new IllegalArgumentException("Passwords do not match"); } @@ -495,6 +502,7 @@ public void removeUserPassword(User user) { user.setPassword(null); userRepository.save(user); passwordHistoryRepository.deleteByUser(user); + sessionInvalidationService.invalidateUserSessions(user); log.info("Password removed for user: {}", user.getEmail()); } 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 7815106..540e153 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -42,6 +42,7 @@ 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.PasswordHistoryEntry; import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; @@ -87,6 +88,8 @@ public class UserServiceTest { private AuthorityService authorityService; @Mock private PasswordHistoryRepository passwordHistoryRepository; + @Mock + private SessionInvalidationService sessionInvalidationService; @InjectMocks private UserService userService; private User testUser; @@ -691,6 +694,7 @@ void shouldRemovePasswordAndClearHistory() { assertThat(testUser.getPassword()).isNull(); verify(userRepository).save(testUser); verify(passwordHistoryRepository).deleteByUser(testUser); + verify(sessionInvalidationService).invalidateUserSessions(testUser); } } @@ -715,6 +719,7 @@ void shouldSetInitialPasswordWhenNoPassword() { assertThat(testUser.getPassword()).isEqualTo(encodedPassword); verify(passwordEncoder).encode(rawPassword); verify(userRepository).save(testUser); + verify(passwordHistoryRepository).save(any(PasswordHistoryEntry.class)); } @Test From 27fcf6e617a670b2f469200e4cc861536adbe63e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 27 Feb 2026 21:07:09 -0700 Subject: [PATCH 3/3] fix: remove dead code and document passwordless endpoint requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify unreachable ternary in registerNewUserAccount — the null guard already throws before line 275, so the null branch can never execute. Add JavaDoc note that /user/registration/passwordless must be in unprotectedURIs for deny-by-default apps. Add local testing workflow with demo app to CLAUDE.md including playwright-test profile requirement. --- CLAUDE.md | 34 +++++++++++++++++++ .../spring/user/api/UserAPI.java | 4 +++ .../spring/user/service/UserService.java | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e94a08..1d9d774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,40 @@ Spring User Framework is a reusable Spring Boot library (not an application) tha ./gradlew publishLocal ``` +## Local Testing with Demo App + +The [SpringUserFrameworkDemoApp](https://github.com/devondragon/SpringUserFrameworkDemoApp) is a Spring Boot app that consumes this library for testing and demonstration. It is typically checked out alongside this repo at `../SpringUserFrameworkDemoApp`. + +### Workflow + +1. **Publish the library locally:** + ```bash + ./gradlew publishLocal + ``` + This publishes the current SNAPSHOT version (from `gradle.properties`) to your local Maven repository. + +2. **Update the demo app dependency** (if needed): + In `../SpringUserFrameworkDemoApp/build.gradle`, ensure the dependency version matches the SNAPSHOT: + ```groovy + implementation 'com.digitalsanctuary:ds-spring-user-framework:X.Y.Z-SNAPSHOT' + ``` + Check `gradle.properties` for the current version. + +3. **Start the demo app:** + ```bash + cd ../SpringUserFrameworkDemoApp + ./gradlew bootRun --args='--spring.profiles.active=local,playwright-test' + ``` + The app runs on `http://localhost:8080` by default. The `playwright-test` profile activates `TestDataController` and `TestApiSecurityConfig`, which the Playwright tests require for test data setup/teardown. Omit `playwright-test` if only doing manual browser testing. + +4. **Run Playwright tests:** + ```bash + cd ../SpringUserFrameworkDemoApp/playwright + npx playwright test --project=chromium + ``` + +5. **Manual browser testing** can be done with Playwright MCP tools or directly in Chrome at `http://localhost:8080`. + ## Architecture ### Package Structure 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 35d0a05..4852b03 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -380,6 +380,10 @@ public ResponseEntity getAuthMethods(@AuthenticationPrincipal DSUs /** * Registers a new passwordless user account (passkey-only). * + *

    Note: Consuming applications using {@code user.security.defaultAction: deny} + * must add {@code /user/registration/passwordless} to their {@code user.security.unprotectedURIs} + * configuration to allow unauthenticated access to this endpoint. + * * @param dto the passwordless registration DTO * @param request the HTTP servlet request * @return a ResponseEntity containing a JSONResponse with the registration result 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 31e1595..18867dd 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -272,7 +272,7 @@ public User registerNewUserAccount(final UserDto newUserDto) { User user = new User(); user.setFirstName(newUserDto.getFirstName()); user.setLastName(newUserDto.getLastName()); - user.setPassword(newUserDto.getPassword() != null ? passwordEncoder.encode(newUserDto.getPassword()) : null); + user.setPassword(passwordEncoder.encode(newUserDto.getPassword())); user.setEmail(newUserDto.getEmail().toLowerCase()); user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME)));