Skip to content

Commit 3d6080c

Browse files
authored
Merge pull request #267 from devondragon/feature/passwordless-passkey-accounts
feat: add passwordless passkey-only account support
2 parents 5918be4 + 27fcf6e commit 3d6080c

11 files changed

Lines changed: 653 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ Spring User Framework is a reusable Spring Boot library (not an application) tha
3434
./gradlew publishLocal
3535
```
3636

37+
## Local Testing with Demo App
38+
39+
The [SpringUserFrameworkDemoApp](https://github.com/devondragon/SpringUserFrameworkDemoApp) is a Spring Boot app that consumes this library for testing and demonstration. It is typically checked out alongside this repo at `../SpringUserFrameworkDemoApp`.
40+
41+
### Workflow
42+
43+
1. **Publish the library locally:**
44+
```bash
45+
./gradlew publishLocal
46+
```
47+
This publishes the current SNAPSHOT version (from `gradle.properties`) to your local Maven repository.
48+
49+
2. **Update the demo app dependency** (if needed):
50+
In `../SpringUserFrameworkDemoApp/build.gradle`, ensure the dependency version matches the SNAPSHOT:
51+
```groovy
52+
implementation 'com.digitalsanctuary:ds-spring-user-framework:X.Y.Z-SNAPSHOT'
53+
```
54+
Check `gradle.properties` for the current version.
55+
56+
3. **Start the demo app:**
57+
```bash
58+
cd ../SpringUserFrameworkDemoApp
59+
./gradlew bootRun --args='--spring.profiles.active=local,playwright-test'
60+
```
61+
The app runs on `http://localhost:8080` by default. The `playwright-test` profile activates `TestDataController` and `TestApiSecurityConfig`, which the Playwright tests require for test data setup/teardown. Omit `playwright-test` if only doing manual browser testing.
62+
63+
4. **Run Playwright tests:**
64+
```bash
65+
cd ../SpringUserFrameworkDemoApp/playwright
66+
npx playwright test --project=chromium
67+
```
68+
69+
5. **Manual browser testing** can be done with Playwright MCP tools or directly in Chrome at `http://localhost:8080`.
70+
3771
## Architecture
3872

3973
### Package Structure

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

Lines changed: 121 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.ObjectProvider;
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;
@@ -60,6 +66,7 @@ public class UserAPI {
6066
private final MessageSource messages;
6167
private final ApplicationEventPublisher eventPublisher;
6268
private final PasswordPolicyService passwordPolicyService;
69+
private final ObjectProvider<WebAuthnCredentialManagementService> webAuthnCredentialManagementServiceProvider;
6370

6471
@Value("${user.security.registrationPendingURI}")
6572
private String registrationPendingURI;
@@ -337,6 +344,120 @@ public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUse
337344
return buildSuccessResponse("Account Deleted", null);
338345
}
339346

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+
340461
// Helper Methods
341462
/**
342463
* 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 long passkeysCount;
28+
29+
/** Whether WebAuthn is enabled on the server. */
30+
private boolean webAuthnEnabled;
31+
32+
/** The user's authentication provider. */
33+
private User.Provider provider;
34+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
}
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+
}

0 commit comments

Comments
 (0)