-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathWebAuthnCredentialManagementService.java
More file actions
144 lines (129 loc) · 4.92 KB
/
Copy pathWebAuthnCredentialManagementService.java
File metadata and controls
144 lines (129 loc) · 4.92 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
package com.digitalsanctuary.spring.user.service;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialQueryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Service for managing WebAuthn credentials.
*
* <p>
* Handles credential listing, renaming, and deletion. It includes important safety features like last-credential
* protection to prevent users from being locked out of their accounts.
* </p>
*
* <p>
* <b>Last-Credential Protection:</b> The service prevents deletion of the last passkey if the user has no password,
* ensuring users always have a way to authenticate.
* </p>
*
* @see WebAuthnCredentialQueryRepository
* @see WebAuthnException
*/
@Service
@ConditionalOnProperty(name = "user.webauthn.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
@Slf4j
public class WebAuthnCredentialManagementService {
private final WebAuthnCredentialQueryRepository credentialQueryRepository;
/**
* Get all credentials for a user.
*
* @param user the user to get credentials for
* @return list of credential information.
*/
public List<WebAuthnCredentialInfo> getUserCredentials(User user) {
return credentialQueryRepository.findCredentialsByUserId(user.getId());
}
/**
* Check if user has any passkeys.
*
* @param user the user to check
* @return true if user has at least one enabled passkey, false otherwise
*/
public boolean hasCredentials(User user) {
return credentialQueryRepository.hasCredentials(user.getId());
}
/**
* Rename a credential label.
*
* <p>
* Help users identify their passkeys (e.g., "My iPhone", "Work Laptop"). The label must be non-empty and no more than 64 characters.
* </p>
*
* @param credentialId the credential ID to rename
* @param newLabel the new label
* @param user the user performing the operation
* @throws WebAuthnException if the credential is not found, access is denied, or the label is invalid
*/
@Transactional
public void renameCredential(String credentialId, String newLabel, User user) {
validateLabel(newLabel);
int updated = credentialQueryRepository.renameCredential(credentialId, newLabel.trim(), user.getId());
if (updated == 0) {
throw new WebAuthnException("Credential not found or access denied");
}
log.info("User {} renamed credential {}", user.getEmail(), credentialId);
}
/**
* Delete a credential with last-credential protection.
*
* <p>
* Deletes a credential. This operation includes important safety logic:
* </p>
* <ul>
* <li>If this is the user's last passkey AND the user has no password, deletion is blocked</li>
* <li>This prevents users from being locked out of their accounts</li>
* <li>Users must either add a password or register another passkey before deleting their last one</li>
* </ul>
*
* <p>
* <b>Security:</b> This method verifies that the credential belongs to the specified user before allowing deletion.
* </p>
*
* @param credentialId the credential ID to delete
* @param user the user performing the operation
* @throws WebAuthnException if the credential is not found, access is denied, or deletion would lock out the user
*/
@Transactional
public void deleteCredential(String credentialId, User user) {
// Lock all user credentials before checking count and deleting to avoid TOCTOU races.
long enabledCount = credentialQueryRepository.lockAndCountCredentials(user.getId());
if (enabledCount == 1 && (user.getPassword() == null || user.getPassword().isEmpty())) {
throw new WebAuthnException(
"Cannot delete last passkey. User would be locked out. " + "Please add a password or another passkey first.");
}
int updated = credentialQueryRepository.deleteCredential(credentialId, user.getId());
if (updated == 0) {
throw new WebAuthnException("Credential not found or access denied");
}
log.info("User {} deleted credential {}", user.getEmail(), credentialId);
}
/**
* Validate credential label.
*
* <p>
* Ensures the label meets the following requirements:
* </p>
* <ul>
* <li>Not null or empty (after trimming)</li>
* <li>No more than 64 characters</li>
* </ul>
*
* @param label the label to validate
* @throws WebAuthnException if the label is invalid
*/
private void validateLabel(String label) {
if (label == null || label.trim().isEmpty()) {
throw new WebAuthnException("Credential label cannot be empty");
}
if (label.trim().length() > 64) {
throw new WebAuthnException("Credential label too long (max 64 characters)");
}
}
}