Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
115 changes: 115 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.annotation.Autowired;
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 @@ -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;

Expand Down Expand Up @@ -337,6 +346,112 @@ 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);
}

boolean hasPasskeys = false;
int passkeysCount = 0;
if (webAuthnCredentialManagementService != null) {
long count = webAuthnCredentialManagementService.getCredentialCount(user);
hasPasskeys = count > 0;
passkeysCount = (int) count;

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

Potential data loss from long to int cast. The passkeysCount is cast from long (returned by getCredentialCount) to int without overflow checking. While it's unlikely a user would have more than 2.1 billion passkeys (Integer.MAX_VALUE), if the count exceeds this value, the cast will silently overflow and produce incorrect results. Consider either changing the passkeysCount field in AuthMethodsResponse to long, or adding overflow validation before the cast.

Suggested change
passkeysCount = (int) count;
if (count > Integer.MAX_VALUE) {
log.warn("Passkey count {} exceeds Integer.MAX_VALUE; capping passkeysCount at {}", count, Integer.MAX_VALUE);
passkeysCount = Integer.MAX_VALUE;
} else {
passkeysCount = (int) count;
}

Copilot uses AI. Check for mistakes.
}

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<JSONResponse> 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<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 +459

Copilot AI Feb 24, 2026

Copy link

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,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.
* <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 int passkeysCount;

/** The user's authentication provider. */
private User.Provider provider;
}
Original file line number Diff line number Diff line change
@@ -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.
* <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;

/** The role. */
private Integer role;

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The role field in PasswordlessRegistrationDto is defined but never used. Unlike the UserDto which has role handling, the registerPasswordlessAccount method in UserService (line 545) always assigns the default user role via roleRepository.findByName(USER_ROLE_NAME) and doesn't reference dto.getRole(). Either the field should be removed if it's not needed for passwordless registration, or the service method should use it to support custom role assignment during passwordless registration (similar to how regular registration might handle it).

Suggested change
/** The role. */
private Integer role;

Copilot uses AI. Check for mistakes.
}
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

Copilot AI Feb 24, 2026

Copy link

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