Skip to content

Commit 750863e

Browse files
authored
fix(webauthn): apply PR #256 review fixes (#258)
Apply all 14 review fixes that were missing from the squash merge: - Safe-parse AuthenticatorTransport enums, default label, trim-before-length - Fix TOCTOU race in last-credential protection with in-transaction recount - Add ON DELETE CASCADE to WebAuthn foreign keys in schema - Add WebAuthnPreDeleteEventListener for cleanup on user deletion - Add WebAuthnAuthenticationToken for passkey session distinction - Change transports to List<String> in credential response DTO - Add @notblank on path variable parameters in management API - Add @ConditionalOnProperty to WebAuthnManagementAPIAdvice - Change WebAuthnException to extend RuntimeException - Use SecureRandom for user handle generation - Use @transactional(propagation = MANDATORY) on credential operations - Update all tests for new behavior
1 parent ff45644 commit 750863e

17 files changed

Lines changed: 295 additions & 46 deletions

db-scripts/mariadb-schema.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ CREATE TABLE `user_entities` (
106106
PRIMARY KEY (`id`),
107107
UNIQUE KEY `UK_user_entities_name` (`name`),
108108
KEY `FK_user_entities_user` (`user_account_id`),
109-
CONSTRAINT `FK_user_entities_user` FOREIGN KEY (`user_account_id`) REFERENCES `user_account` (`id`)
109+
CONSTRAINT `FK_user_entities_user` FOREIGN KEY (`user_account_id`) REFERENCES `user_account` (`id`) ON DELETE CASCADE
110110
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
111111

112112
CREATE TABLE `user_credentials` (
@@ -126,5 +126,5 @@ CREATE TABLE `user_credentials` (
126126
`label` VARCHAR(64) NOT NULL,
127127
PRIMARY KEY (`credential_id`),
128128
KEY `FK_user_credentials_entity` (`user_entity_user_id`),
129-
CONSTRAINT `FK_user_credentials_entity` FOREIGN KEY (`user_entity_user_id`) REFERENCES `user_entities` (`id`)
129+
CONSTRAINT `FK_user_credentials_entity` FOREIGN KEY (`user_entity_user_id`) REFERENCES `user_entities` (`id`) ON DELETE CASCADE
130130
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import org.springframework.web.bind.annotation.RequestMapping;
1414
import org.springframework.web.bind.annotation.RestController;
1515
import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
16-
import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
1716
import com.digitalsanctuary.spring.user.exceptions.WebAuthnUserNotFoundException;
1817
import com.digitalsanctuary.spring.user.persistence.model.User;
1918
import com.digitalsanctuary.spring.user.service.UserService;
@@ -96,9 +95,9 @@ public ResponseEntity<Boolean> hasCredentials(@AuthenticationPrincipal UserDetai
9695
* @return ResponseEntity with success message or error
9796
*/
9897
@PutMapping("/credentials/{id}/label")
99-
public ResponseEntity<GenericResponse> renameCredential(@PathVariable @Size(max = 512) String id,
98+
public ResponseEntity<GenericResponse> renameCredential(@PathVariable @NotBlank @Size(max = 512) String id,
10099
@RequestBody @Valid RenameCredentialRequest request,
101-
@AuthenticationPrincipal UserDetails userDetails) throws WebAuthnException {
100+
@AuthenticationPrincipal UserDetails userDetails) {
102101
User user = findAuthenticatedUser(userDetails);
103102
credentialManagementService.renameCredential(id, request.label(), user);
104103
return ResponseEntity.ok(new GenericResponse("Passkey renamed successfully"));
@@ -121,8 +120,8 @@ public ResponseEntity<GenericResponse> renameCredential(@PathVariable @Size(max
121120
* @return ResponseEntity with success message or error
122121
*/
123122
@DeleteMapping("/credentials/{id}")
124-
public ResponseEntity<GenericResponse> deleteCredential(@PathVariable @Size(max = 512) String id,
125-
@AuthenticationPrincipal UserDetails userDetails) throws WebAuthnException {
123+
public ResponseEntity<GenericResponse> deleteCredential(@PathVariable @NotBlank @Size(max = 512) String id,
124+
@AuthenticationPrincipal UserDetails userDetails) {
126125
User user = findAuthenticatedUser(userDetails);
127126
credentialManagementService.deleteCredential(id, user);
128127
return ResponseEntity.ok(new GenericResponse("Passkey deleted successfully"));

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.digitalsanctuary.spring.user.api;
22

3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
34
import org.springframework.http.HttpStatus;
45
import org.springframework.http.ResponseEntity;
56
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -15,6 +16,7 @@
1516
* Centralized exception handling for WebAuthn credential management endpoints.
1617
*/
1718
@RestControllerAdvice(assignableTypes = WebAuthnManagementAPI.class)
19+
@ConditionalOnProperty(name = "user.webauthn.enabled", havingValue = "true", matchIfMissing = false)
1820
@Slf4j
1921
public class WebAuthnManagementAPIAdvice {
2022

src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.digitalsanctuary.spring.user.dto;
22

33
import java.time.Instant;
4+
import java.util.List;
45
import lombok.AllArgsConstructor;
56
import lombok.Builder;
67
import lombok.Data;
@@ -28,7 +29,7 @@ public class WebAuthnCredentialInfo {
2829
private Instant lastUsed;
2930

3031
/** Supported transports (usb, nfc, ble, internal). */
31-
private String transports;
32+
private List<String> transports;
3233

3334
/** Whether credential is backup-eligible (synced passkey). */
3435
private Boolean backupEligible;

src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Exception thrown for WebAuthn-related errors.
55
*
66
* <p>
7-
* This is a checked exception used to signal WebAuthn-specific business logic errors such as:
7+
* This is an unchecked exception used to signal WebAuthn-specific business logic errors such as:
88
* </p>
99
* <ul>
1010
* <li>Attempting to delete the last passkey when user has no password</li>
@@ -13,7 +13,7 @@
1313
* <li>User not found during credential operations</li>
1414
* </ul>
1515
*/
16-
public class WebAuthnException extends Exception {
16+
public class WebAuthnException extends RuntimeException {
1717

1818
/** Serial Version UID. */
1919
private static final long serialVersionUID = 1L;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.digitalsanctuary.spring.user.listener;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4+
import org.springframework.context.event.EventListener;
5+
import org.springframework.stereotype.Component;
6+
import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent;
7+
import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity;
8+
import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository;
9+
import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityRepository;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
13+
/**
14+
* Listens for {@link UserPreDeleteEvent} and cleans up all WebAuthn data for the user being deleted.
15+
*
16+
* <p>
17+
* Deletes all credentials associated with the user's WebAuthn entity, then deletes the entity itself.
18+
* This listener is synchronous so that failures cause the enclosing transaction to roll back, preventing
19+
* orphaned user accounts.
20+
* </p>
21+
*/
22+
@Slf4j
23+
@Component
24+
@ConditionalOnProperty(name = "user.webauthn.enabled", havingValue = "true", matchIfMissing = false)
25+
@RequiredArgsConstructor
26+
public class WebAuthnPreDeleteEventListener {
27+
28+
private final WebAuthnCredentialRepository credentialRepository;
29+
private final WebAuthnUserEntityRepository userEntityRepository;
30+
31+
/**
32+
* Handle user pre-delete by removing all WebAuthn credentials and the user entity.
33+
*
34+
* @param event the user pre-delete event
35+
*/
36+
@EventListener
37+
public void onUserPreDelete(UserPreDeleteEvent event) {
38+
Long userId = event.getUserId();
39+
log.debug("Cleaning up WebAuthn data for user {}", userId);
40+
41+
userEntityRepository.findByUserId(userId).ifPresent(this::deleteUserEntityAndCredentials);
42+
}
43+
44+
private void deleteUserEntityAndCredentials(WebAuthnUserEntity userEntity) {
45+
credentialRepository.findByUserEntity(userEntity).forEach(credentialRepository::delete);
46+
userEntityRepository.delete(userEntity);
47+
log.info("Deleted WebAuthn data for user entity {}", userEntity.getName());
48+
}
49+
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.digitalsanctuary.spring.user.persistence.repository;
22

3+
import java.util.Arrays;
4+
import java.util.Collections;
35
import java.util.List;
46
import java.util.Optional;
57
import java.util.stream.Collectors;
68
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
79
import org.springframework.stereotype.Repository;
10+
import org.springframework.transaction.annotation.Propagation;
811
import org.springframework.transaction.annotation.Transactional;
912
import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
1013
import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential;
@@ -59,6 +62,7 @@ public long countCredentials(Long userId) {
5962
* @param userId the user ID
6063
* @return count of locked credential rows
6164
*/
65+
@Transactional(propagation = Propagation.MANDATORY)
6266
public long lockAndCountCredentials(Long userId) {
6367
return credentialRepository.lockCredentialIdsByUserEntityUserId(userId).size();
6468
}
@@ -135,9 +139,18 @@ private Optional<WebAuthnCredential> findCredentialForUser(String credentialId,
135139
* @return the DTO
136140
*/
137141
private WebAuthnCredentialInfo toCredentialInfo(WebAuthnCredential entity) {
142+
List<String> transportList = parseTransportList(entity.getAuthenticatorTransports());
138143
return WebAuthnCredentialInfo.builder().id(entity.getCredentialId()).label(entity.getLabel())
139144
.created(entity.getCreated()).lastUsed(entity.getLastUsed())
140-
.transports(entity.getAuthenticatorTransports()).backupEligible(entity.isBackupEligible())
145+
.transports(transportList).backupEligible(entity.isBackupEligible())
141146
.backupState(entity.isBackupState()).build();
142147
}
148+
149+
private List<String> parseTransportList(String transports) {
150+
if (transports == null || transports.isEmpty()) {
151+
return Collections.emptyList();
152+
}
153+
return Arrays.stream(transports.split(",")).map(String::trim).filter(s -> !s.isEmpty())
154+
.collect(Collectors.toList());
155+
}
143156
}

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package com.digitalsanctuary.spring.user.persistence.repository;
22

3-
import java.nio.ByteBuffer;
3+
import java.security.SecureRandom;
44
import java.util.Base64;
55
import java.util.Optional;
66
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
77
import org.springframework.context.annotation.Primary;
8+
import org.springframework.dao.DataIntegrityViolationException;
89
import org.springframework.security.web.webauthn.api.Bytes;
910
import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
1011
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
@@ -45,6 +46,7 @@ public PublicKeyCredentialUserEntity findById(Bytes id) {
4546
}
4647

4748
@Override
49+
@Transactional
4850
public PublicKeyCredentialUserEntity findByUsername(String username) {
4951
// Handle edge cases that can occur during login
5052
if (username == null || username.isEmpty() || "anonymousUser".equals(username)) {
@@ -65,8 +67,13 @@ public PublicKeyCredentialUserEntity findByUsername(String username) {
6567
return null;
6668
}
6769

68-
// Create WebAuthn user entity for this application user
69-
return createUserEntity(user);
70+
// Create WebAuthn user entity for this application user, handling concurrent creation
71+
try {
72+
return createUserEntity(user);
73+
} catch (DataIntegrityViolationException e) {
74+
log.debug("Concurrent WebAuthn user entity creation for {}, retrying lookup", username);
75+
return webAuthnUserEntityRepository.findByName(username).map(this::toSpringSecurityEntity).orElse(null);
76+
}
7077
}
7178

7279
@Override
@@ -107,7 +114,9 @@ public void delete(Bytes id) {
107114
*/
108115
@Transactional
109116
public PublicKeyCredentialUserEntity createUserEntity(User user) {
110-
Bytes userId = new Bytes(longToBytes(user.getId()));
117+
byte[] randomHandle = new byte[64];
118+
new SecureRandom().nextBytes(randomHandle);
119+
Bytes userId = new Bytes(randomHandle);
111120
String idStr = Base64.getUrlEncoder().withoutPadding().encodeToString(userId.getBytes());
112121
String displayName = user.getFullName();
113122

@@ -148,13 +157,4 @@ private PublicKeyCredentialUserEntity toSpringSecurityEntity(WebAuthnUserEntity
148157
.displayName(entity.getDisplayName()).build();
149158
}
150159

151-
/**
152-
* Convert Long ID to byte array for WebAuthn user ID.
153-
*
154-
* @param value the Long value to convert
155-
* @return byte array representation of the Long
156-
*/
157-
private byte[] longToBytes(Long value) {
158-
return ByteBuffer.allocate(Long.BYTES).putLong(value).array();
159-
}
160160
}

src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.digitalsanctuary.spring.user.security;
22

33
import java.io.IOException;
4-
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
54
import org.springframework.security.core.Authentication;
65
import org.springframework.security.core.context.SecurityContext;
76
import org.springframework.security.core.context.SecurityContextHolder;
@@ -73,7 +72,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
7372
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
7473

7574
// Create new authentication with DSUserDetails as principal, preserving authorities
76-
Authentication convertedAuth = UsernamePasswordAuthenticationToken.authenticated(userDetails, null,
75+
Authentication convertedAuth = new WebAuthnAuthenticationToken(userDetails,
7776
authentication.getAuthorities());
7877

7978
// Update SecurityContext with the converted authentication
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.digitalsanctuary.spring.user.security;
2+
3+
import java.util.Collection;
4+
import org.springframework.security.authentication.AbstractAuthenticationToken;
5+
import org.springframework.security.core.GrantedAuthority;
6+
import org.springframework.security.core.userdetails.UserDetails;
7+
8+
/**
9+
* Authentication token representing a successful WebAuthn/passkey authentication.
10+
*
11+
* <p>
12+
* This token replaces the use of {@link org.springframework.security.authentication.UsernamePasswordAuthenticationToken}
13+
* for WebAuthn logins, making it possible to distinguish passkey-based sessions from password-based sessions
14+
* in security logic and audit trails.
15+
* </p>
16+
*
17+
* <p>
18+
* The principal is the application's {@link UserDetails} (typically {@code DSUserDetails}), and
19+
* {@link #getCredentials()} returns {@code null} because no password is involved in WebAuthn authentication.
20+
* </p>
21+
*/
22+
public class WebAuthnAuthenticationToken extends AbstractAuthenticationToken {
23+
24+
private static final long serialVersionUID = 1L;
25+
26+
private final UserDetails principal;
27+
28+
/**
29+
* Creates a new WebAuthn authentication token.
30+
*
31+
* @param principal the authenticated user details
32+
* @param authorities the granted authorities
33+
*/
34+
public WebAuthnAuthenticationToken(UserDetails principal, Collection<? extends GrantedAuthority> authorities) {
35+
super(authorities);
36+
this.principal = principal;
37+
setAuthenticated(true);
38+
}
39+
40+
@Override
41+
public Object getCredentials() {
42+
return null;
43+
}
44+
45+
@Override
46+
public Object getPrincipal() {
47+
return principal;
48+
}
49+
}

0 commit comments

Comments
 (0)