-
Notifications
You must be signed in to change notification settings - Fork 44
feat: add passwordless passkey-only account support #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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; | ||
| } | ||
|
|
||
| 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
|
||
|
|
||
| // Helper Methods | ||
| /** | ||
| * Validates the user data transfer object. | ||
|
|
||
| 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; | ||||||||
|
||||||||
| /** The role. */ | |
| private Integer role; |
| 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 |
|---|---|---|
|
|
@@ -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
|
||
| } | ||
There was a problem hiding this comment.
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
passkeysCountis cast fromlong(returned bygetCredentialCount) tointwithout 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 thepasskeysCountfield inAuthMethodsResponsetolong, or adding overflow validation before the cast.