-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathWebAuthnManagementAPI.java
More file actions
191 lines (175 loc) · 7.4 KB
/
Copy pathWebAuthnManagementAPI.java
File metadata and controls
191 lines (175 loc) · 7.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
package com.digitalsanctuary.spring.user.api;
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;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
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;
/**
* REST API for WebAuthn credential management.
*
* <p>
* This controller provides endpoints for managing WebAuthn credentials (passkeys). Authenticated users can list their
* registered passkeys, rename them for easier identification, and delete passkeys they no longer use.
* </p>
*
* <p>
* Endpoints:
* </p>
* <ul>
* <li>GET /user/webauthn/credentials - List all passkeys for the authenticated user</li>
* <li>GET /user/webauthn/has-credentials - Check if user has any passkeys</li>
* <li>PUT /user/webauthn/credentials/{id}/label - Rename a passkey</li>
* <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)
@RequiredArgsConstructor
@Validated
public class WebAuthnManagementAPI {
private final WebAuthnCredentialManagementService credentialManagementService;
private final UserService userService;
private final ApplicationEventPublisher eventPublisher;
/**
* Get user's registered passkeys.
*
* @param userDetails the authenticated user details
* @return ResponseEntity containing list of credential information
*/
@GetMapping("/credentials")
public ResponseEntity<List<WebAuthnCredentialInfo>> getCredentials(@AuthenticationPrincipal UserDetails userDetails) {
User user = findAuthenticatedUser(userDetails);
List<WebAuthnCredentialInfo> credentials = credentialManagementService.getUserCredentials(user);
return ResponseEntity.ok(credentials);
}
/**
* Check if user has any passkeys.
*
* @param userDetails the authenticated user details
* @return ResponseEntity containing true if user has passkeys, false otherwise
*/
@GetMapping("/has-credentials")
public ResponseEntity<Boolean> hasCredentials(@AuthenticationPrincipal UserDetails userDetails) {
User user = findAuthenticatedUser(userDetails);
boolean hasCredentials = credentialManagementService.hasCredentials(user);
return ResponseEntity.ok(hasCredentials);
}
/**
* Rename a passkey.
*
* <p>
* Updates the user-friendly label for a passkey. The label helps users identify their passkeys (e.g., "My iPhone", "Work Laptop").
* </p>
*
* <p>
* The label must be non-empty and no more than 64 characters.
* </p>
*
* @param id the credential ID to rename
* @param request the rename request containing the new label
* @param userDetails the authenticated user details
* @return ResponseEntity with success message or error
*/
@PutMapping("/credentials/{id}/label")
public ResponseEntity<GenericResponse> renameCredential(@PathVariable @NotBlank @Size(max = 512) String id,
@RequestBody @Valid RenameCredentialRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
User user = findAuthenticatedUser(userDetails);
credentialManagementService.renameCredential(id, request.label(), user);
return ResponseEntity.ok(new GenericResponse("Passkey renamed successfully"));
}
/**
* Delete a passkey.
*
* <p>
* Deletes a passkey. Includes last-credential protection to prevent users from being
* locked out of their accounts.
* </p>
*
* <p>
* If this is the user's last passkey and they have no password, the deletion will be blocked with an error message.
* </p>
*
* @param id the credential ID to delete
* @param userDetails the authenticated user details
* @return ResponseEntity with success message or error
*/
@DeleteMapping("/credentials/{id}")
public ResponseEntity<GenericResponse> deleteCredential(@PathVariable @NotBlank @Size(max = 512) String id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = findAuthenticatedUser(userDetails);
credentialManagementService.deleteCredential(id, user);
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) {
throw new WebAuthnUserNotFoundException("User not found");
}
return user;
}
/**
* Request DTO for renaming credential.
*
* @param label the new label (must not be blank, max 64 chars)
*/
public record RenameCredentialRequest(@NotBlank @Size(max = 64) String label) {
}
}