|
4 | 4 | import java.util.Locale; |
5 | 5 | import java.util.Optional; |
6 | 6 | import jakarta.validation.Valid; |
| 7 | +import org.springframework.beans.factory.ObjectProvider; |
7 | 8 | import org.springframework.beans.factory.annotation.Value; |
8 | 9 | import org.springframework.context.ApplicationEventPublisher; |
9 | 10 | import org.springframework.context.MessageSource; |
|
12 | 13 | import org.springframework.security.core.annotation.AuthenticationPrincipal; |
13 | 14 | import org.springframework.security.core.context.SecurityContextHolder; |
14 | 15 | import org.springframework.web.bind.annotation.DeleteMapping; |
| 16 | +import org.springframework.web.bind.annotation.GetMapping; |
15 | 17 | import org.springframework.web.bind.annotation.PostMapping; |
16 | 18 | import org.springframework.web.bind.annotation.RequestBody; |
17 | 19 | import org.springframework.web.bind.annotation.RequestMapping; |
18 | 20 | import org.springframework.web.bind.annotation.RestController; |
19 | 21 | import com.digitalsanctuary.spring.user.audit.AuditEvent; |
| 22 | +import com.digitalsanctuary.spring.user.dto.AuthMethodsResponse; |
20 | 23 | import com.digitalsanctuary.spring.user.dto.PasswordDto; |
21 | 24 | import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto; |
| 25 | +import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; |
22 | 26 | import com.digitalsanctuary.spring.user.dto.SavePasswordDto; |
| 27 | +import com.digitalsanctuary.spring.user.dto.SetPasswordDto; |
23 | 28 | import com.digitalsanctuary.spring.user.dto.UserDto; |
24 | 29 | import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; |
25 | 30 | import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; |
|
30 | 35 | import com.digitalsanctuary.spring.user.service.PasswordPolicyService; |
31 | 36 | import com.digitalsanctuary.spring.user.service.UserEmailService; |
32 | 37 | import com.digitalsanctuary.spring.user.service.UserService; |
| 38 | +import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService; |
33 | 39 | import com.digitalsanctuary.spring.user.util.JSONResponse; |
34 | 40 | import com.digitalsanctuary.spring.user.util.UserUtils; |
35 | 41 | import jakarta.servlet.ServletException; |
@@ -60,6 +66,7 @@ public class UserAPI { |
60 | 66 | private final MessageSource messages; |
61 | 67 | private final ApplicationEventPublisher eventPublisher; |
62 | 68 | private final PasswordPolicyService passwordPolicyService; |
| 69 | + private final ObjectProvider<WebAuthnCredentialManagementService> webAuthnCredentialManagementServiceProvider; |
63 | 70 |
|
64 | 71 | @Value("${user.security.registrationPendingURI}") |
65 | 72 | private String registrationPendingURI; |
@@ -337,6 +344,120 @@ public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUse |
337 | 344 | return buildSuccessResponse("Account Deleted", null); |
338 | 345 | } |
339 | 346 |
|
| 347 | + /** |
| 348 | + * Returns the authentication methods configured for the current user. |
| 349 | + * |
| 350 | + * @param userDetails the authenticated user details |
| 351 | + * @return a ResponseEntity containing the auth methods response |
| 352 | + */ |
| 353 | + @GetMapping("/auth-methods") |
| 354 | + public ResponseEntity<JSONResponse> getAuthMethods(@AuthenticationPrincipal DSUserDetails userDetails) { |
| 355 | + validateAuthenticatedUser(userDetails); |
| 356 | + User user = userService.findUserByEmail(userDetails.getUser().getEmail()); |
| 357 | + if (user == null) { |
| 358 | + return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST); |
| 359 | + } |
| 360 | + |
| 361 | + WebAuthnCredentialManagementService webAuthnService = webAuthnCredentialManagementServiceProvider.getIfAvailable(); |
| 362 | + boolean hasPasskeys = false; |
| 363 | + long passkeysCount = 0; |
| 364 | + if (webAuthnService != null) { |
| 365 | + passkeysCount = webAuthnService.getCredentialCount(user); |
| 366 | + hasPasskeys = passkeysCount > 0; |
| 367 | + } |
| 368 | + |
| 369 | + AuthMethodsResponse authMethods = AuthMethodsResponse.builder() |
| 370 | + .hasPassword(userService.hasPassword(user)) |
| 371 | + .hasPasskeys(hasPasskeys) |
| 372 | + .passkeysCount(passkeysCount) |
| 373 | + .webAuthnEnabled(webAuthnService != null) |
| 374 | + .provider(user.getProvider()) |
| 375 | + .build(); |
| 376 | + |
| 377 | + return ResponseEntity.ok(JSONResponse.builder().success(true).data(authMethods).build()); |
| 378 | + } |
| 379 | + |
| 380 | + /** |
| 381 | + * Registers a new passwordless user account (passkey-only). |
| 382 | + * |
| 383 | + * <p><strong>Note:</strong> Consuming applications using {@code user.security.defaultAction: deny} |
| 384 | + * must add {@code /user/registration/passwordless} to their {@code user.security.unprotectedURIs} |
| 385 | + * configuration to allow unauthenticated access to this endpoint. |
| 386 | + * |
| 387 | + * @param dto the passwordless registration DTO |
| 388 | + * @param request the HTTP servlet request |
| 389 | + * @return a ResponseEntity containing a JSONResponse with the registration result |
| 390 | + */ |
| 391 | + @PostMapping("/registration/passwordless") |
| 392 | + public ResponseEntity<JSONResponse> registerPasswordlessAccount(@Valid @RequestBody PasswordlessRegistrationDto dto, |
| 393 | + HttpServletRequest request) { |
| 394 | + if (webAuthnCredentialManagementServiceProvider.getIfAvailable() == null) { |
| 395 | + return buildErrorResponse("Passwordless registration is not available", 1, HttpStatus.BAD_REQUEST); |
| 396 | + } |
| 397 | + try { |
| 398 | + User registeredUser = userService.registerPasswordlessAccount(dto); |
| 399 | + publishRegistrationEvent(registeredUser, request); |
| 400 | + logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request); |
| 401 | + |
| 402 | + String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; |
| 403 | + |
| 404 | + return buildSuccessResponse("Registration Successful!", nextURL); |
| 405 | + } catch (UserAlreadyExistException ex) { |
| 406 | + log.warn("User already exists with email: {}", dto.getEmail()); |
| 407 | + logAuditEvent("PasswordlessRegistration", "Failure", "User Already Exists", null, request); |
| 408 | + return buildErrorResponse("An account already exists for the email address", 2, HttpStatus.CONFLICT); |
| 409 | + } catch (Exception ex) { |
| 410 | + log.error("Unexpected error during passwordless registration.", ex); |
| 411 | + logAuditEvent("PasswordlessRegistration", "Failure", ex.getMessage(), null, request); |
| 412 | + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); |
| 413 | + } |
| 414 | + } |
| 415 | + |
| 416 | + /** |
| 417 | + * Sets an initial password for a passwordless account. |
| 418 | + * |
| 419 | + * @param userDetails the authenticated user details |
| 420 | + * @param setPasswordDto the set password DTO |
| 421 | + * @param request the HTTP servlet request |
| 422 | + * @param locale the locale |
| 423 | + * @return a ResponseEntity containing a JSONResponse with the result |
| 424 | + */ |
| 425 | + @PostMapping("/setPassword") |
| 426 | + public ResponseEntity<JSONResponse> setPassword(@AuthenticationPrincipal DSUserDetails userDetails, |
| 427 | + @Valid @RequestBody SetPasswordDto setPasswordDto, HttpServletRequest request, Locale locale) { |
| 428 | + validateAuthenticatedUser(userDetails); |
| 429 | + User user = userService.findUserByEmail(userDetails.getUser().getEmail()); |
| 430 | + if (user == null) { |
| 431 | + return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST); |
| 432 | + } |
| 433 | + |
| 434 | + try { |
| 435 | + if (userService.hasPassword(user)) { |
| 436 | + return buildErrorResponse("User already has a password. Use the change password feature instead.", 1, HttpStatus.BAD_REQUEST); |
| 437 | + } |
| 438 | + |
| 439 | + if (!setPasswordDto.getNewPassword().equals(setPasswordDto.getConfirmPassword())) { |
| 440 | + return buildErrorResponse(messages.getMessage("message.password.mismatch", null, "Passwords do not match", locale), 2, |
| 441 | + HttpStatus.BAD_REQUEST); |
| 442 | + } |
| 443 | + |
| 444 | + List<String> errors = passwordPolicyService.validate(user, setPasswordDto.getNewPassword(), user.getEmail(), locale); |
| 445 | + if (!errors.isEmpty()) { |
| 446 | + log.warn("Password validation failed for user {}: {}", user.getEmail(), errors); |
| 447 | + return buildErrorResponse(String.join(" ", errors), 3, HttpStatus.BAD_REQUEST); |
| 448 | + } |
| 449 | + |
| 450 | + userService.setInitialPassword(user, setPasswordDto.getNewPassword()); |
| 451 | + logAuditEvent("SetPassword", "Success", "Initial password set for passwordless account", user, request); |
| 452 | + |
| 453 | + return buildSuccessResponse(messages.getMessage("message.set-password.success", null, "Password set successfully", locale), null); |
| 454 | + } catch (Exception ex) { |
| 455 | + log.error("Unexpected error during set password.", ex); |
| 456 | + logAuditEvent("SetPassword", "Failure", ex.getMessage(), user, request); |
| 457 | + return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR); |
| 458 | + } |
| 459 | + } |
| 460 | + |
340 | 461 | // Helper Methods |
341 | 462 | /** |
342 | 463 | * Validates the user data transfer object. |
|
0 commit comments