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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Locale;
import java.util.Optional;
import jakarta.validation.Valid;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.MessageSource;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -60,6 +66,7 @@ public class UserAPI {
private final MessageSource messages;
private final ApplicationEventPublisher eventPublisher;
private final PasswordPolicyService passwordPolicyService;
private final ObjectProvider<WebAuthnCredentialManagementService> webAuthnCredentialManagementServiceProvider;

@Value("${user.security.registrationPendingURI}")
private String registrationPendingURI;
Expand Down Expand Up @@ -337,6 +344,116 @@ public ResponseEntity<JSONResponse> 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<JSONResponse> 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);
}

WebAuthnCredentialManagementService webAuthnService = webAuthnCredentialManagementServiceProvider.getIfAvailable();
boolean hasPasskeys = false;
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();

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<JSONResponse> 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);
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<JSONResponse> 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<String> 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);
}
}
Comment on lines 353 to 455
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage for the new UserAPI endpoints (/user/auth-methods, /user/registration/passwordless, and /user/setPassword) is missing. The existing test suite UserAPIUnitTest.java contains comprehensive tests for other endpoints like registration, password reset, and profile updates, but there are no tests for the three new API endpoints introduced in this PR. These endpoints should have unit tests that verify success cases, error cases, validation, and edge cases, following the existing test patterns in the codebase.

Copilot uses AI. Check for mistakes.

// Helper Methods
/**
* Validates the user data transfer object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand All @@ -42,6 +48,7 @@
* <li>DELETE /user/webauthn/credentials/{id} - Delete a passkey</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/user/webauthn")
@ConditionalOnProperty(name = "user.webauthn.enabled", havingValue = "true", matchIfMissing = false)
Expand All @@ -51,6 +58,7 @@ public class WebAuthnManagementAPI {

private final WebAuthnCredentialManagementService credentialManagementService;
private final UserService userService;
private final ApplicationEventPublisher eventPublisher;

/**
* Get user's registered passkeys.
Expand Down Expand Up @@ -127,6 +135,44 @@ public ResponseEntity<GenericResponse> deleteCredential(@PathVariable @NotBlank
return ResponseEntity.ok(new GenericResponse("Passkey deleted successfully"));
}

/**
* Remove the user's password, making the account passwordless (passkey-only).
*
* <p>
* Requires the user to have at least one passkey registered. This ensures
* the user can still authenticate after the password is removed.
* </p>
*
* @param userDetails the authenticated user details
* @param request the HTTP servlet request
* @return ResponseEntity with success message or error
*/
@DeleteMapping("/password")
public ResponseEntity<GenericResponse> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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.
* <p>
* Provides information about which authentication methods are configured
* for the current user, enabling the UI to show/hide relevant options.
* </p>
*
* @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 long passkeysCount;

/** Whether WebAuthn is enabled on the server. */
private boolean webAuthnEnabled;

/** The user's authentication provider. */
private User.Provider provider;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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.
* <p>
* Used for registering users who will authenticate exclusively with passkeys,
* without setting an initial password. Contains only the user's name and email.
* </p>
*
* @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;

}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* </p>
*
* @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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,12 @@ public interface PasswordHistoryRepository extends JpaRepository<PasswordHistory
* @return list of password history entries
*/
List<PasswordHistoryEntry> 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);
Comment on lines +37 to +43
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @Modifying annotation for deleteByUser method. The new deleteByUser method is a delete query method that should be annotated with @Modifying to indicate it's a modifying query. While Spring Data JPA can infer delete queries from method names, the existing codebase consistently uses @Modifying annotation for all delete methods (see PasswordResetTokenRepository.deleteByToken and WebAuthnCredentialRepository.deleteByUserEntity). Additionally, @Transactional annotation should be added as shown in the WebAuthnCredentialRepository example to ensure the delete operation runs within a transaction context.

Copilot uses AI. Check for mistakes.
}
Loading
Loading