Skip to content

Commit bf8211b

Browse files
committed
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).
1 parent 5918be4 commit bf8211b

10 files changed

Lines changed: 599 additions & 3 deletions

File tree

src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.util.Locale;
55
import java.util.Optional;
66
import jakarta.validation.Valid;
7+
import org.springframework.beans.factory.annotation.Autowired;
78
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.context.ApplicationEventPublisher;
910
import org.springframework.context.MessageSource;
@@ -12,14 +13,18 @@
1213
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1314
import org.springframework.security.core.context.SecurityContextHolder;
1415
import org.springframework.web.bind.annotation.DeleteMapping;
16+
import org.springframework.web.bind.annotation.GetMapping;
1517
import org.springframework.web.bind.annotation.PostMapping;
1618
import org.springframework.web.bind.annotation.RequestBody;
1719
import org.springframework.web.bind.annotation.RequestMapping;
1820
import org.springframework.web.bind.annotation.RestController;
1921
import com.digitalsanctuary.spring.user.audit.AuditEvent;
22+
import com.digitalsanctuary.spring.user.dto.AuthMethodsResponse;
2023
import com.digitalsanctuary.spring.user.dto.PasswordDto;
2124
import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto;
25+
import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto;
2226
import com.digitalsanctuary.spring.user.dto.SavePasswordDto;
27+
import com.digitalsanctuary.spring.user.dto.SetPasswordDto;
2328
import com.digitalsanctuary.spring.user.dto.UserDto;
2429
import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto;
2530
import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent;
@@ -30,6 +35,7 @@
3035
import com.digitalsanctuary.spring.user.service.PasswordPolicyService;
3136
import com.digitalsanctuary.spring.user.service.UserEmailService;
3237
import com.digitalsanctuary.spring.user.service.UserService;
38+
import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
3339
import com.digitalsanctuary.spring.user.util.JSONResponse;
3440
import com.digitalsanctuary.spring.user.util.UserUtils;
3541
import jakarta.servlet.ServletException;
@@ -61,6 +67,9 @@ public class UserAPI {
6167
private final ApplicationEventPublisher eventPublisher;
6268
private final PasswordPolicyService passwordPolicyService;
6369

70+
@Autowired(required = false)
71+
private WebAuthnCredentialManagementService webAuthnCredentialManagementService;
72+
6473
@Value("${user.security.registrationPendingURI}")
6574
private String registrationPendingURI;
6675

@@ -337,6 +346,112 @@ public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUse
337346
return buildSuccessResponse("Account Deleted", null);
338347
}
339348

349+
/**
350+
* Returns the authentication methods configured for the current user.
351+
*
352+
* @param userDetails the authenticated user details
353+
* @return a ResponseEntity containing the auth methods response
354+
*/
355+
@GetMapping("/auth-methods")
356+
public ResponseEntity<JSONResponse> getAuthMethods(@AuthenticationPrincipal DSUserDetails userDetails) {
357+
validateAuthenticatedUser(userDetails);
358+
User user = userService.findUserByEmail(userDetails.getUser().getEmail());
359+
if (user == null) {
360+
return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST);
361+
}
362+
363+
boolean hasPasskeys = false;
364+
int passkeysCount = 0;
365+
if (webAuthnCredentialManagementService != null) {
366+
long count = webAuthnCredentialManagementService.getCredentialCount(user);
367+
hasPasskeys = count > 0;
368+
passkeysCount = (int) count;
369+
}
370+
371+
AuthMethodsResponse authMethods = AuthMethodsResponse.builder()
372+
.hasPassword(userService.hasPassword(user))
373+
.hasPasskeys(hasPasskeys)
374+
.passkeysCount(passkeysCount)
375+
.provider(user.getProvider())
376+
.build();
377+
378+
return ResponseEntity.ok(JSONResponse.builder().success(true).data(authMethods).build());
379+
}
380+
381+
/**
382+
* Registers a new passwordless user account (passkey-only).
383+
*
384+
* @param dto the passwordless registration DTO
385+
* @param request the HTTP servlet request
386+
* @return a ResponseEntity containing a JSONResponse with the registration result
387+
*/
388+
@PostMapping("/registration/passwordless")
389+
public ResponseEntity<JSONResponse> registerPasswordlessAccount(@Valid @RequestBody PasswordlessRegistrationDto dto,
390+
HttpServletRequest request) {
391+
try {
392+
User registeredUser = userService.registerPasswordlessAccount(dto);
393+
publishRegistrationEvent(registeredUser, request);
394+
logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request);
395+
396+
String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI;
397+
398+
return buildSuccessResponse("Registration Successful!", nextURL);
399+
} catch (UserAlreadyExistException ex) {
400+
log.warn("User already exists with email: {}", dto.getEmail());
401+
logAuditEvent("PasswordlessRegistration", "Failure", "User Already Exists", null, request);
402+
return buildErrorResponse("An account already exists for the email address", 2, HttpStatus.CONFLICT);
403+
} catch (Exception ex) {
404+
log.error("Unexpected error during passwordless registration.", ex);
405+
logAuditEvent("PasswordlessRegistration", "Failure", ex.getMessage(), null, request);
406+
return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR);
407+
}
408+
}
409+
410+
/**
411+
* Sets an initial password for a passwordless account.
412+
*
413+
* @param userDetails the authenticated user details
414+
* @param setPasswordDto the set password DTO
415+
* @param request the HTTP servlet request
416+
* @param locale the locale
417+
* @return a ResponseEntity containing a JSONResponse with the result
418+
*/
419+
@PostMapping("/setPassword")
420+
public ResponseEntity<JSONResponse> setPassword(@AuthenticationPrincipal DSUserDetails userDetails,
421+
@Valid @RequestBody SetPasswordDto setPasswordDto, HttpServletRequest request, Locale locale) {
422+
validateAuthenticatedUser(userDetails);
423+
User user = userService.findUserByEmail(userDetails.getUser().getEmail());
424+
if (user == null) {
425+
return buildErrorResponse("User not found", 1, HttpStatus.BAD_REQUEST);
426+
}
427+
428+
try {
429+
if (userService.hasPassword(user)) {
430+
return buildErrorResponse("User already has a password. Use the change password feature instead.", 1, HttpStatus.BAD_REQUEST);
431+
}
432+
433+
if (!setPasswordDto.getNewPassword().equals(setPasswordDto.getConfirmPassword())) {
434+
return buildErrorResponse(messages.getMessage("message.password.mismatch", null, "Passwords do not match", locale), 2,
435+
HttpStatus.BAD_REQUEST);
436+
}
437+
438+
List<String> errors = passwordPolicyService.validate(user, setPasswordDto.getNewPassword(), user.getEmail(), locale);
439+
if (!errors.isEmpty()) {
440+
log.warn("Password validation failed for user {}: {}", user.getEmail(), errors);
441+
return buildErrorResponse(String.join(" ", errors), 3, HttpStatus.BAD_REQUEST);
442+
}
443+
444+
userService.setInitialPassword(user, setPasswordDto.getNewPassword());
445+
logAuditEvent("SetPassword", "Success", "Initial password set for passwordless account", user, request);
446+
447+
return buildSuccessResponse(messages.getMessage("message.set-password.success", null, "Password set successfully", locale), null);
448+
} catch (Exception ex) {
449+
log.error("Unexpected error during set password.", ex);
450+
logAuditEvent("SetPassword", "Failure", ex.getMessage(), user, request);
451+
return buildErrorResponse("System Error!", 5, HttpStatus.INTERNAL_SERVER_ERROR);
452+
}
453+
}
454+
340455
// Helper Methods
341456
/**
342457
* Validates the user data transfer object.

src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
5+
import org.springframework.context.ApplicationEventPublisher;
56
import org.springframework.http.ResponseEntity;
67
import org.springframework.security.core.annotation.AuthenticationPrincipal;
78
import org.springframework.security.core.userdetails.UserDetails;
@@ -12,16 +13,21 @@
1213
import org.springframework.web.bind.annotation.RequestBody;
1314
import org.springframework.web.bind.annotation.RequestMapping;
1415
import org.springframework.web.bind.annotation.RestController;
16+
import com.digitalsanctuary.spring.user.audit.AuditEvent;
1517
import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
18+
import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
1619
import com.digitalsanctuary.spring.user.exceptions.WebAuthnUserNotFoundException;
1720
import com.digitalsanctuary.spring.user.persistence.model.User;
1821
import com.digitalsanctuary.spring.user.service.UserService;
1922
import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
2023
import com.digitalsanctuary.spring.user.util.GenericResponse;
24+
import com.digitalsanctuary.spring.user.util.UserUtils;
25+
import jakarta.servlet.http.HttpServletRequest;
2126
import jakarta.validation.Valid;
2227
import jakarta.validation.constraints.NotBlank;
2328
import jakarta.validation.constraints.Size;
2429
import lombok.RequiredArgsConstructor;
30+
import lombok.extern.slf4j.Slf4j;
2531
import org.springframework.validation.annotation.Validated;
2632

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

5259
private final WebAuthnCredentialManagementService credentialManagementService;
5360
private final UserService userService;
61+
private final ApplicationEventPublisher eventPublisher;
5462

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

138+
/**
139+
* Remove the user's password, making the account passwordless (passkey-only).
140+
*
141+
* <p>
142+
* Requires the user to have at least one passkey registered. This ensures
143+
* the user can still authenticate after the password is removed.
144+
* </p>
145+
*
146+
* @param userDetails the authenticated user details
147+
* @param request the HTTP servlet request
148+
* @return ResponseEntity with success message or error
149+
*/
150+
@DeleteMapping("/password")
151+
public ResponseEntity<GenericResponse> removePassword(@AuthenticationPrincipal UserDetails userDetails,
152+
HttpServletRequest request) {
153+
User user = findAuthenticatedUser(userDetails);
154+
155+
if (!userService.hasPassword(user)) {
156+
throw new WebAuthnException("User does not have a password to remove");
157+
}
158+
159+
if (!credentialManagementService.hasCredentials(user)) {
160+
throw new WebAuthnException("Cannot remove password. Please register a passkey first.");
161+
}
162+
163+
userService.removeUserPassword(user);
164+
165+
AuditEvent event = AuditEvent.builder().source(this).user(user).sessionId(request.getSession().getId())
166+
.ipAddress(UserUtils.getClientIP(request))
167+
.userAgent(request.getHeader("User-Agent")).action("PasswordRemoval").actionStatus("Success")
168+
.message("Password removed for passwordless account").build();
169+
eventPublisher.publishEvent(event);
170+
171+
log.info("User {} removed their password", user.getEmail());
172+
173+
return ResponseEntity.ok(new GenericResponse("Password removed successfully"));
174+
}
175+
130176
private User findAuthenticatedUser(UserDetails userDetails) throws WebAuthnUserNotFoundException {
131177
User user = userService.findUserByEmail(userDetails.getUsername());
132178
if (user == null) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.digitalsanctuary.spring.user.dto;
2+
3+
import com.digitalsanctuary.spring.user.persistence.model.User;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
7+
/**
8+
* Response DTO for the auth-methods endpoint.
9+
* <p>
10+
* Provides information about which authentication methods are configured
11+
* for the current user, enabling the UI to show/hide relevant options.
12+
* </p>
13+
*
14+
* @author Devon Hillard
15+
*/
16+
@Data
17+
@Builder
18+
public class AuthMethodsResponse {
19+
20+
/** Whether the user has a password set. */
21+
private boolean hasPassword;
22+
23+
/** Whether the user has any passkeys registered. */
24+
private boolean hasPasskeys;
25+
26+
/** The number of passkeys registered. */
27+
private int passkeysCount;
28+
29+
/** The user's authentication provider. */
30+
private User.Provider provider;
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.digitalsanctuary.spring.user.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
import lombok.Data;
7+
8+
/**
9+
* Data Transfer Object for passwordless user registration.
10+
* <p>
11+
* Used for registering users who will authenticate exclusively with passkeys,
12+
* without setting an initial password. Contains only the user's name and email.
13+
* </p>
14+
*
15+
* @author Devon Hillard
16+
*/
17+
@Data
18+
public class PasswordlessRegistrationDto {
19+
20+
/** The first name. */
21+
@NotBlank(message = "First name is required")
22+
@Size(max = 50, message = "First name must not exceed 50 characters")
23+
private String firstName;
24+
25+
/** The last name. */
26+
@NotBlank(message = "Last name is required")
27+
@Size(max = 50, message = "Last name must not exceed 50 characters")
28+
private String lastName;
29+
30+
/** The email. */
31+
@NotBlank(message = "Email is required")
32+
@Email(message = "Please provide a valid email address")
33+
@Size(max = 100, message = "Email must not exceed 100 characters")
34+
private String email;
35+
36+
/** The role. */
37+
private Integer role;
38+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.digitalsanctuary.spring.user.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Size;
5+
import lombok.Data;
6+
import lombok.ToString;
7+
8+
/**
9+
* Data Transfer Object for setting an initial password on a passwordless account.
10+
* <p>
11+
* Used when a user who registered without a password (passkey-only) wants to add
12+
* a password to their account. Contains the new password and confirmation.
13+
* </p>
14+
*
15+
* @author Devon Hillard
16+
*/
17+
@Data
18+
public class SetPasswordDto {
19+
20+
/** The new password to set. */
21+
@ToString.Exclude
22+
@NotBlank(message = "Password is required")
23+
@Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters")
24+
private String newPassword;
25+
26+
/** Confirmation of the new password (must match newPassword). */
27+
@ToString.Exclude
28+
@NotBlank(message = "Password confirmation is required")
29+
private String confirmPassword;
30+
}

src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,12 @@ public interface PasswordHistoryRepository extends JpaRepository<PasswordHistory
3333
* @return list of password history entries
3434
*/
3535
List<PasswordHistoryEntry> findByUserOrderByEntryDateDesc(User user);
36+
37+
/**
38+
* Delete all password history entries for a user.
39+
* Used when removing a user's password for passwordless accounts.
40+
*
41+
* @param user the user whose history should be deleted
42+
*/
43+
void deleteByUser(User user);
3644
}

0 commit comments

Comments
 (0)