upgrade to Spring Boot 4, add webauthn support, some cleanup
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0"?>
|
||||
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>de.bstly.we</groupId>
|
||||
<artifactId>webstly-main</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>webauthn</artifactId>
|
||||
<name>webauthn</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.bstly.we</groupId>
|
||||
<artifactId>webstly-core</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yubico</groupId>
|
||||
<artifactId>webauthn-server-core</artifactId>
|
||||
<version>${webauthn-server-core.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jdk8</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.bstly.we.webauthn;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.yubico.webauthn.RelyingParty;
|
||||
import com.yubico.webauthn.data.RelyingPartyIdentity;
|
||||
|
||||
import de.bstly.we.webauthn.businesslogic.WebAuthnCredentialRepositoryManager;
|
||||
|
||||
@Configuration
|
||||
public class WebAuthnConfiguration {
|
||||
|
||||
@Value("${webauthn.rp.id:localhost}")
|
||||
private String rpId;
|
||||
|
||||
@Value("${webauthn.rp.name:we.bstly}")
|
||||
private String rpName;
|
||||
|
||||
@Value("${webauthn.rp.origins:http://localhost:4200}")
|
||||
private List<String> origins;
|
||||
|
||||
@Bean
|
||||
public RelyingParty relyingParty(WebAuthnCredentialRepositoryManager credentialRepository) {
|
||||
RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder()
|
||||
.id(rpId)
|
||||
.name(rpName)
|
||||
.build();
|
||||
|
||||
Set<String> originsSet = new HashSet<>(origins);
|
||||
|
||||
return RelyingParty.builder()
|
||||
.identity(rpIdentity)
|
||||
.credentialRepository(credentialRepository)
|
||||
.origins(originsSet)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package de.bstly.we.webauthn.businesslogic;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.yubico.webauthn.CredentialRepository;
|
||||
import com.yubico.webauthn.RegisteredCredential;
|
||||
import com.yubico.webauthn.data.ByteArray;
|
||||
import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
|
||||
|
||||
import de.bstly.we.businesslogic.UserManager;
|
||||
import de.bstly.we.model.User;
|
||||
import de.bstly.we.webauthn.model.WebAuthnCredential;
|
||||
import de.bstly.we.webauthn.model.WebAuthnUserHandle;
|
||||
import de.bstly.we.webauthn.repository.WebAuthnCredentialRepository;
|
||||
import de.bstly.we.webauthn.repository.WebAuthnUserHandleRepository;
|
||||
|
||||
@Component
|
||||
public class WebAuthnCredentialRepositoryManager implements CredentialRepository {
|
||||
|
||||
@Autowired
|
||||
private WebAuthnCredentialRepository credentialRepository;
|
||||
@Autowired
|
||||
private WebAuthnUserHandleRepository webAuthnUserHandleRepository;
|
||||
@Autowired
|
||||
private UserManager userManager;
|
||||
|
||||
@Override
|
||||
public Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username) {
|
||||
User user = userManager.getByUsername(username);
|
||||
if (user == null) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
return credentialRepository.findByTarget(user.getId()).stream().map(c -> {
|
||||
try {
|
||||
return PublicKeyCredentialDescriptor.builder()
|
||||
.id(ByteArray.fromBase64Url(c.getCredentialId()))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse credential ID", e);
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ByteArray> getUserHandleForUsername(String username) {
|
||||
User user = userManager.getByUsername(username);
|
||||
|
||||
if (user == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
WebAuthnUserHandle webAuthnUserHandle = webAuthnUserHandleRepository.findByTarget(user.getId());
|
||||
|
||||
if (webAuthnUserHandle == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(ByteArray.fromBase64Url(webAuthnUserHandle.getUserHandle()));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse user handle", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getUsernameForUserHandle(ByteArray userHandle) {
|
||||
WebAuthnUserHandle webAuthnUserHandle = webAuthnUserHandleRepository
|
||||
.findByUserHandle(userHandle.getBase64Url());
|
||||
|
||||
if (webAuthnUserHandle == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
User user = userManager.get(webAuthnUserHandle.getTarget());
|
||||
|
||||
if (user == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(user.getUsername());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RegisteredCredential> lookup(ByteArray credentialId, ByteArray userHandle) {
|
||||
WebAuthnCredential credential = credentialRepository.findByCredentialId(credentialId.getBase64Url());
|
||||
|
||||
if (credential == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(RegisteredCredential.builder()
|
||||
.credentialId(credential.getCredentialIdBytes())
|
||||
.userHandle(credential.getUserHandleBytes())
|
||||
.publicKeyCose(credential.getPublicKeyBytes())
|
||||
.signatureCount(credential.getSignatureCount())
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<RegisteredCredential> lookupAll(ByteArray credentialId) {
|
||||
WebAuthnCredential credential = credentialRepository.findByCredentialId(credentialId.getBase64Url());
|
||||
|
||||
if (credential == null) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
return Set.of(RegisteredCredential.builder()
|
||||
.credentialId(credential.getCredentialIdBytes())
|
||||
.userHandle(credential.getUserHandleBytes())
|
||||
.publicKeyCose(credential.getPublicKeyBytes())
|
||||
.signatureCount(credential.getSignatureCount())
|
||||
.build());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package de.bstly.we.webauthn.businesslogic;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.beust.jcommander.internal.Lists;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
|
||||
import com.yubico.webauthn.AssertionRequest;
|
||||
import com.yubico.webauthn.AssertionResult;
|
||||
import com.yubico.webauthn.FinishAssertionOptions;
|
||||
import com.yubico.webauthn.FinishRegistrationOptions;
|
||||
import com.yubico.webauthn.RegistrationResult;
|
||||
import com.yubico.webauthn.RelyingParty;
|
||||
import com.yubico.webauthn.StartAssertionOptions;
|
||||
import com.yubico.webauthn.StartRegistrationOptions;
|
||||
import com.yubico.webauthn.data.AuthenticatorAssertionResponse;
|
||||
import com.yubico.webauthn.data.AuthenticatorAttachment;
|
||||
import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
|
||||
import com.yubico.webauthn.data.AuthenticatorSelectionCriteria;
|
||||
import com.yubico.webauthn.data.ByteArray;
|
||||
import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
|
||||
import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
|
||||
import com.yubico.webauthn.data.PublicKeyCredential;
|
||||
import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
|
||||
import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
|
||||
import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions;
|
||||
import com.yubico.webauthn.data.ResidentKeyRequirement;
|
||||
import com.yubico.webauthn.data.UserIdentity;
|
||||
import com.yubico.webauthn.data.UserVerificationRequirement;
|
||||
import com.yubico.webauthn.exception.RegistrationFailedException;
|
||||
|
||||
import de.bstly.we.businesslogic.UserDataProvider;
|
||||
import de.bstly.we.businesslogic.UserManager;
|
||||
import de.bstly.we.model.User;
|
||||
import de.bstly.we.model.UserData;
|
||||
import de.bstly.we.webauthn.model.WebAuthnChallenge;
|
||||
import de.bstly.we.webauthn.model.WebAuthnCredential;
|
||||
import de.bstly.we.webauthn.model.WebAuthnUsage;
|
||||
import de.bstly.we.webauthn.model.WebAuthnUserHandle;
|
||||
import de.bstly.we.webauthn.repository.WebAuthnChallengeRepository;
|
||||
import de.bstly.we.webauthn.repository.WebAuthnCredentialRepository;
|
||||
import de.bstly.we.webauthn.repository.WebAuthnUserHandleRepository;
|
||||
|
||||
@Component
|
||||
public class WebAuthnManager implements UserDataProvider {
|
||||
|
||||
@Autowired
|
||||
private WebAuthnCredentialRepository credentialRepository;
|
||||
@Autowired
|
||||
private WebAuthnChallengeRepository challengeRepository;
|
||||
@Autowired
|
||||
private WebAuthnUserHandleRepository webAuthnUserHandleRepository;
|
||||
@Autowired
|
||||
private UserManager userManager;
|
||||
@Autowired
|
||||
private RelyingParty relyingParty;
|
||||
|
||||
private ObjectMapper objectMapper = new ObjectMapper().configure(SerializationFeature.FAIL_ON_EMPTY_BEANS,
|
||||
false).setDefaultPropertyInclusion(Include.NON_ABSENT).registerModule(new Jdk8Module());
|
||||
|
||||
public List<WebAuthnCredential> getAllCredentials(Long userId) {
|
||||
return credentialRepository.findByTarget(userId);
|
||||
}
|
||||
|
||||
public WebAuthnCredential updateNickname(Long credentialId, Long userId, String nickname) {
|
||||
Optional<WebAuthnCredential> credential = credentialRepository.findById(credentialId);
|
||||
if (credential.isPresent() && credential.get().getTarget().equals(userId)) {
|
||||
credential.get().setNickname(nickname);
|
||||
return credentialRepository.save(credential.get());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public WebAuthnCredential updateUsage(Long credentialId, Long userId, String usage) {
|
||||
Optional<WebAuthnCredential> credential = credentialRepository.findById(credentialId);
|
||||
if (credential.isPresent() && credential.get().getTarget().equals(userId)) {
|
||||
try {
|
||||
WebAuthnUsage usageEnum = WebAuthnUsage.valueOf(usage);
|
||||
credential.get().setUsage(usageEnum);
|
||||
return credentialRepository.save(credential.get());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Invalid usage value
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean deleteCredential(Long credentialId, Long userId) {
|
||||
Optional<WebAuthnCredential> credential = credentialRepository.findById(credentialId);
|
||||
if (credential.isPresent() && credential.get().getTarget().equals(userId)) {
|
||||
credentialRepository.delete(credential.get());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public PublicKeyCredentialCreationOptions startRegistration(Long userId) {
|
||||
User user = userManager.get(userId);
|
||||
ByteArray userHandle = getUserHandle(userId);
|
||||
|
||||
UserIdentity userIdentity = UserIdentity.builder()
|
||||
.name(user.getUsername())
|
||||
.displayName(user.getUsername())
|
||||
.id(userHandle)
|
||||
.build();
|
||||
|
||||
StartRegistrationOptions.StartRegistrationOptionsBuilder optionsBuilder = StartRegistrationOptions.builder()
|
||||
.user(userIdentity);
|
||||
|
||||
optionsBuilder
|
||||
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
|
||||
.authenticatorAttachment(
|
||||
Optional.of(AuthenticatorAttachment.CROSS_PLATFORM))
|
||||
.residentKey(ResidentKeyRequirement.PREFERRED)
|
||||
.userVerification(UserVerificationRequirement.PREFERRED)
|
||||
.build());
|
||||
|
||||
PublicKeyCredentialCreationOptions request;
|
||||
try {
|
||||
request = relyingParty.startRegistration(optionsBuilder.build());
|
||||
|
||||
WebAuthnChallenge challenge = new WebAuthnChallenge();
|
||||
challenge.setUserId(userId);
|
||||
challenge.setChallenge(request.getChallenge().getBase64Url());
|
||||
challenge.setType("registration");
|
||||
challenge.setCreatedAt(Instant.now());
|
||||
challenge.setExpiresAt(Instant.now().plusSeconds(300)); // 5 minutes
|
||||
challenge.setRequestJson(request.toJson());
|
||||
challengeRepository.save(challenge);
|
||||
|
||||
return request;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start WebAuthn registration", e);
|
||||
}
|
||||
}
|
||||
|
||||
public WebAuthnCredential finishRegistration(Long userId, String credentialJson, String nickname)
|
||||
throws RegistrationFailedException {
|
||||
try {
|
||||
PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkc = PublicKeyCredential
|
||||
.parseRegistrationResponseJson(credentialJson.toString());
|
||||
|
||||
JsonNode response = objectMapper.readTree(credentialJson).get("response");
|
||||
JsonNode clientDataJSON = objectMapper
|
||||
.readTree(ByteArray.fromBase64Url(response.get("clientDataJSON").asText()).getBytes());
|
||||
ByteArray challenge;
|
||||
try {
|
||||
challenge = ByteArray.fromBase64Url(clientDataJSON.get("challenge").asText());
|
||||
} catch (Exception e) {
|
||||
throw new RegistrationFailedException(new IllegalArgumentException("Invalid challenge format"));
|
||||
}
|
||||
|
||||
WebAuthnChallenge storedChallenge = challengeRepository.findByUserIdAndChallengeAndTypeAndExpiresAtAfter(
|
||||
userId, challenge.getBase64Url(), "registration", Instant.now());
|
||||
|
||||
if (storedChallenge == null) {
|
||||
throw new RegistrationFailedException(new IllegalArgumentException("Challenge not found or expired"));
|
||||
}
|
||||
|
||||
PublicKeyCredentialCreationOptions originalRequest = PublicKeyCredentialCreationOptions
|
||||
.fromJson(storedChallenge.getRequestJson());
|
||||
|
||||
RegistrationResult result = relyingParty.finishRegistration(FinishRegistrationOptions.builder()
|
||||
.request(originalRequest)
|
||||
.response(pkc)
|
||||
.build());
|
||||
|
||||
WebAuthnCredential credential = new WebAuthnCredential();
|
||||
credential.setTarget(userId);
|
||||
credential.setCredentialIdBytes(result.getKeyId().getId());
|
||||
credential.setPublicKeyBytes(result.getPublicKeyCose());
|
||||
credential.setUserHandleBytes(getUserHandle(userId));
|
||||
credential.setSignatureCount(result.getSignatureCount());
|
||||
credential.setNickname(nickname);
|
||||
credential.setCreatedAt(Instant.now());
|
||||
credential.setEnabled(true);
|
||||
|
||||
credentialRepository.save(credential);
|
||||
challengeRepository.delete(storedChallenge);
|
||||
|
||||
return credential;
|
||||
} catch (Exception e) {
|
||||
throw new RegistrationFailedException(new IllegalArgumentException(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
public String createRequest(Long userId, WebAuthnUsage usage) {
|
||||
try {
|
||||
User user = userManager.get(userId);
|
||||
List<WebAuthnCredential> credentials = credentialRepository.findByTarget(userId);
|
||||
credentials = credentials.stream()
|
||||
.filter(c -> c.getUsage() == usage && c.isEnabled())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (credentials.isEmpty()) {
|
||||
throw new RuntimeException("No credentials found for user");
|
||||
}
|
||||
|
||||
StartAssertionOptions.StartAssertionOptionsBuilder optionsBuilder = StartAssertionOptions.builder()
|
||||
.username(user.getUsername())
|
||||
.userVerification(UserVerificationRequirement.REQUIRED);
|
||||
|
||||
AssertionRequest request = relyingParty.startAssertion(optionsBuilder.build());
|
||||
|
||||
// Filter allowCredentials to only include credentials of this type
|
||||
List<PublicKeyCredentialDescriptor> allowCredentials = credentials.stream()
|
||||
.map(c -> {
|
||||
try {
|
||||
return PublicKeyCredentialDescriptor.builder()
|
||||
.id(ByteArray.fromBase64Url(c.getCredentialId()))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse credential ID", e);
|
||||
}
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PublicKeyCredentialRequestOptions filteredOptions = request.getPublicKeyCredentialRequestOptions()
|
||||
.toBuilder()
|
||||
.allowCredentials(allowCredentials)
|
||||
.build();
|
||||
|
||||
AssertionRequest filteredRequest = AssertionRequest.builder()
|
||||
.publicKeyCredentialRequestOptions(filteredOptions)
|
||||
.username(request.getUsername())
|
||||
.build();
|
||||
|
||||
WebAuthnChallenge challenge = new WebAuthnChallenge();
|
||||
challenge.setUserId(userId);
|
||||
challenge
|
||||
.setChallenge(filteredRequest.getPublicKeyCredentialRequestOptions().getChallenge().getBase64Url());
|
||||
challenge.setType("authentication");
|
||||
challenge.setCreatedAt(Instant.now());
|
||||
challenge.setExpiresAt(Instant.now().plusSeconds(300)); // 5 minutes
|
||||
challenge.setRequestJson(filteredRequest.toJson());
|
||||
challengeRepository.save(challenge);
|
||||
|
||||
return filteredRequest.toCredentialsGetJson();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate WebAuthn challenge", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebAuthn login request that does not depend on the username.
|
||||
*
|
||||
* This avoids username enumeration and allows resolving the user from the
|
||||
* returned credentialId during the finish step.
|
||||
*/
|
||||
public String createLoginRequest() {
|
||||
try {
|
||||
StartAssertionOptions.StartAssertionOptionsBuilder optionsBuilder = StartAssertionOptions.builder()
|
||||
.userVerification(UserVerificationRequirement.PREFERRED);
|
||||
|
||||
AssertionRequest request = relyingParty.startAssertion(optionsBuilder.build());
|
||||
|
||||
WebAuthnChallenge challenge = new WebAuthnChallenge();
|
||||
challenge.setUserId(-1L);
|
||||
challenge.setChallenge(request.getPublicKeyCredentialRequestOptions().getChallenge().getBase64Url());
|
||||
challenge.setType("login");
|
||||
challenge.setCreatedAt(Instant.now());
|
||||
challenge.setExpiresAt(Instant.now().plusSeconds(300)); // 5 minutes
|
||||
challenge.setRequestJson(request.toJson());
|
||||
challengeRepository.save(challenge);
|
||||
|
||||
return request.toCredentialsGetJson();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate WebAuthn login challenge", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a WebAuthn login assertion where the user is resolved from the
|
||||
* credentialId contained in the assertion.
|
||||
*
|
||||
* @return the authenticated userId if successful, otherwise empty.
|
||||
*/
|
||||
public Optional<Long> validateLoginRequest(String assertionJson) {
|
||||
try {
|
||||
JsonNode assertionNode = objectMapper.readTree(assertionJson);
|
||||
PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> pkc = PublicKeyCredential
|
||||
.parseAssertionResponseJson(assertionJson);
|
||||
|
||||
ByteArray credentialId = pkc.getId();
|
||||
WebAuthnCredential credential = credentialRepository.findByCredentialId(credentialId.getBase64Url());
|
||||
if (credential == null || !credential.isEnabled() || credential.getUsage() != WebAuthnUsage.LOGIN) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String clientDataBase64 = assertionNode.get("response").get("clientDataJSON").asText();
|
||||
ByteArray clientDataBytes = ByteArray.fromBase64Url(clientDataBase64);
|
||||
JsonNode clientData = objectMapper.readTree(clientDataBytes.getBytes());
|
||||
String challengeBase64 = clientData.get("challenge").asText();
|
||||
|
||||
WebAuthnChallenge storedChallenge = challengeRepository
|
||||
.findByUserIdAndChallengeAndTypeAndExpiresAtAfter(-1L, challengeBase64,
|
||||
"login", Instant.now());
|
||||
if (storedChallenge == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
AssertionRequest originalRequest = AssertionRequest.fromJson(storedChallenge.getRequestJson());
|
||||
AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder()
|
||||
.request(originalRequest)
|
||||
.response(pkc)
|
||||
.build());
|
||||
|
||||
if (!result.isSuccess()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
credential.setSignatureCount(result.getSignatureCount());
|
||||
credential.setLastUsedAt(Instant.now());
|
||||
credentialRepository.save(credential);
|
||||
challengeRepository.delete(storedChallenge);
|
||||
return Optional.ofNullable(credential.getTarget());
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateRequest(String assertionJson, WebAuthnUsage usage) {
|
||||
try {
|
||||
JsonNode assertionNode = objectMapper.readTree(assertionJson);
|
||||
|
||||
PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> pkc = PublicKeyCredential
|
||||
.parseAssertionResponseJson(assertionJson);
|
||||
|
||||
ByteArray credentialId = pkc.getId();
|
||||
WebAuthnCredential credential = credentialRepository.findByCredentialId(credentialId.getBase64Url());
|
||||
|
||||
if (credential == null || !credential.isEnabled()
|
||||
|| (credential.getUsage() != usage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Long userId = credential.getTarget();
|
||||
|
||||
String clientDataBase64 = assertionNode.get("response").get("clientDataJSON").asText();
|
||||
ByteArray clientDataBytes = ByteArray.fromBase64Url(clientDataBase64);
|
||||
JsonNode clientData = objectMapper.readTree(clientDataBytes.getBytes());
|
||||
String challengeBase64 = clientData.get("challenge").asText();
|
||||
|
||||
WebAuthnChallenge storedChallenge = challengeRepository
|
||||
.findByUserIdAndChallengeAndTypeAndExpiresAtAfter(userId, challengeBase64,
|
||||
"authentication", Instant.now());
|
||||
|
||||
if (storedChallenge == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AssertionRequest filteredRequest = AssertionRequest.fromJson(storedChallenge.getRequestJson());
|
||||
|
||||
AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder()
|
||||
.request(filteredRequest)
|
||||
.response(pkc).build());
|
||||
|
||||
if (result.isSuccess()) {
|
||||
credential.setSignatureCount(result.getSignatureCount());
|
||||
credential.setLastUsedAt(Instant.now());
|
||||
credentialRepository.save(credential);
|
||||
challengeRepository.delete(storedChallenge);
|
||||
return true;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate WebAuthn challenge", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public ByteArray getUserHandle(Long userId) {
|
||||
WebAuthnUserHandle webAuthnUserHandle = webAuthnUserHandleRepository.findByTarget(userId);
|
||||
|
||||
if (webAuthnUserHandle == null) {
|
||||
ByteArray userHandle = new ByteArray(new SecureRandom().generateSeed(32));
|
||||
webAuthnUserHandle = new WebAuthnUserHandle(userId, userHandle.getBase64Url());
|
||||
webAuthnUserHandleRepository.save(webAuthnUserHandle);
|
||||
}
|
||||
|
||||
try {
|
||||
return ByteArray.fromBase64Url(webAuthnUserHandle.getUserHandle());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse user handle", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "webauthn-credentials";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserData> getUserData(Long userId) {
|
||||
List<UserData> result = Lists.newArrayList();
|
||||
Iterator<WebAuthnCredential> items = credentialRepository.findByTarget(userId).iterator();
|
||||
while (items.hasNext()) {
|
||||
result.add(items.next());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void purgeUserData(Long userId) {
|
||||
credentialRepository.deleteAll(credentialRepository.findByTarget(userId));
|
||||
}
|
||||
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package de.bstly.we.webauthn.businesslogic;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import de.bstly.we.model.UserData;
|
||||
import de.bstly.we.security.businesslogic.SecondFactorRequestProvider;
|
||||
import de.bstly.we.webauthn.model.QWebAuthnCredential;
|
||||
import de.bstly.we.webauthn.model.WebAuthnCredential;
|
||||
import de.bstly.we.webauthn.model.WebAuthnUsage;
|
||||
import de.bstly.we.webauthn.repository.WebAuthnCredentialRepository;
|
||||
|
||||
@Component
|
||||
public class WebAuthnSecondFactorRequestProvider implements SecondFactorRequestProvider<WebAuthnCredential> {
|
||||
|
||||
@Autowired
|
||||
private WebAuthnCredentialRepository credentialRepository;
|
||||
@Autowired
|
||||
private WebAuthnManager webAuthnManager;
|
||||
|
||||
protected QWebAuthnCredential qCredential = QWebAuthnCredential.webAuthnCredential;
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "webauthn";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(String provider) {
|
||||
return getId().equals(provider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled(Long userId) {
|
||||
return credentialRepository.exists(qCredential.target.eq(userId)
|
||||
.and(qCredential.enabled.isTrue())
|
||||
.and(qCredential.usage.eq(WebAuthnUsage.TWO_FA)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object request(Long userId) {
|
||||
return webAuthnManager.createRequest(userId, WebAuthnUsage.TWO_FA);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validate(Long userId, String assertionJson) {
|
||||
return webAuthnManager.validateRequest(assertionJson, WebAuthnUsage.TWO_FA);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebAuthnCredential get(Long userId) {
|
||||
List<WebAuthnCredential> credentials = credentialRepository.findByTarget(userId);
|
||||
// Filter for 2FA usage
|
||||
return credentials.stream()
|
||||
.filter(c -> c.getUsage() == WebAuthnUsage.TWO_FA)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebAuthnCredential create(Long userId) {
|
||||
// not used, handled by WebAuthnManager
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enable(Long userId, String credentialJson) {
|
||||
// not used, handled by WebAuthnManager
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(Long userId) {
|
||||
// handled by WebAuthnManager
|
||||
}
|
||||
|
||||
/*
|
||||
* @see de.bstly.we.businesslogic.UserDataProvider#getUserData(java.lang.Long)
|
||||
*/
|
||||
@Override
|
||||
public List<UserData> getUserData(Long userId) {
|
||||
// handled by WebAuthnManager
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void purgeUserData(Long userId) {
|
||||
// handled by WebAuthnManager
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.bstly.we.webauthn.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
|
||||
|
||||
import de.bstly.we.controller.BaseController;
|
||||
import de.bstly.we.controller.support.EntityResponseStatusException;
|
||||
import de.bstly.we.webauthn.businesslogic.WebAuthnManager;
|
||||
import de.bstly.we.webauthn.controller.model.RegistrationFinishRequest;
|
||||
import de.bstly.we.webauthn.model.WebAuthnCredential;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth/webauthn")
|
||||
public class WebAuthnController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private WebAuthnManager webAuthnManager;
|
||||
|
||||
@PreAuthorize("authentication.authenticated")
|
||||
@GetMapping
|
||||
public List<WebAuthnCredential> getCredentials() {
|
||||
Long userId = getCurrentUserId();
|
||||
return webAuthnManager.getAllCredentials(userId);
|
||||
}
|
||||
|
||||
@PreAuthorize("authentication.authenticated")
|
||||
@DeleteMapping("/{id}")
|
||||
public void deleteCredential(@PathVariable Long id) {
|
||||
Long userId = getCurrentUserId();
|
||||
if (!webAuthnManager.deleteCredential(id, userId)) {
|
||||
throw new EntityResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("authentication.authenticated")
|
||||
@PatchMapping("/{id}/nickname")
|
||||
public WebAuthnCredential updateNickname(@PathVariable Long id, @RequestBody String nickname) {
|
||||
Long userId = getCurrentUserId();
|
||||
WebAuthnCredential credential = webAuthnManager.updateNickname(id, userId, nickname);
|
||||
if (credential == null) {
|
||||
throw new EntityResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
@PreAuthorize("authentication.authenticated")
|
||||
@PatchMapping("/{id}/usage")
|
||||
public WebAuthnCredential updateUsage(@PathVariable Long id, @RequestBody String usage) {
|
||||
Long userId = getCurrentUserId();
|
||||
WebAuthnCredential credential = webAuthnManager.updateUsage(id, userId, usage);
|
||||
if (credential == null) {
|
||||
throw new EntityResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
@PreAuthorize("authentication.authenticated")
|
||||
@PostMapping("/register/start")
|
||||
public PublicKeyCredentialCreationOptions startSecurityKeyRegistration() {
|
||||
Long userId = getCurrentUserId();
|
||||
return webAuthnManager.startRegistration(userId);
|
||||
}
|
||||
|
||||
@PreAuthorize("authentication.authenticated")
|
||||
@PostMapping("/register/finish")
|
||||
public WebAuthnCredential finishRegistration(@RequestBody RegistrationFinishRequest request) {
|
||||
Long userId = getCurrentUserId();
|
||||
try {
|
||||
return webAuthnManager.finishRegistration(userId, request.getCredential(), request.getNickname());
|
||||
} catch (Exception e) {
|
||||
throw new EntityResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.bstly.we.webauthn.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import de.bstly.we.webauthn.businesslogic.WebAuthnManager;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth/webauthn/login/start")
|
||||
public class WebAuthnLoginController {
|
||||
|
||||
@Autowired
|
||||
private WebAuthnManager webAuthnManager;
|
||||
|
||||
@PostMapping()
|
||||
public String startLogin() {
|
||||
return webAuthnManager.createLoginRequest();
|
||||
}
|
||||
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package de.bstly.we.webauthn.controller.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RegistrationFinishRequest {
|
||||
|
||||
private String credential;
|
||||
private String nickname;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.bstly.we.webauthn.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "webauthn_challenges")
|
||||
@Getter
|
||||
@Setter
|
||||
public class WebAuthnChallenge {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "challenge", nullable = false, length = 512)
|
||||
private String challenge;
|
||||
|
||||
@Column(name = "type", nullable = false)
|
||||
private String type;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Lob
|
||||
@Column(name = "request_json", nullable = false, length = 100000)
|
||||
private String requestJson;
|
||||
|
||||
public boolean isExpired() {
|
||||
return Instant.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.bstly.we.webauthn.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.yubico.webauthn.data.ByteArray;
|
||||
|
||||
import de.bstly.we.model.SecondFactor;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "webauthn_credentials")
|
||||
@Getter
|
||||
@Setter
|
||||
public class WebAuthnCredential implements SecondFactor {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "target", nullable = false)
|
||||
private Long target;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled = true;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "credential_usage", nullable = false)
|
||||
private WebAuthnUsage usage = WebAuthnUsage.NONE;
|
||||
|
||||
@Column(name = "credential_id", nullable = false, unique = true, length = 1024)
|
||||
private String credentialId;
|
||||
|
||||
@Column(name = "public_key", nullable = false, length = 1024)
|
||||
private String publicKey;
|
||||
|
||||
@Column(name = "user_handle", nullable = false, length = 64)
|
||||
private String userHandle;
|
||||
|
||||
@Column(name = "signature_count", nullable = false)
|
||||
private long signatureCount;
|
||||
|
||||
@Column(name = "nickname")
|
||||
private String nickname;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private Instant lastUsedAt;
|
||||
|
||||
public ByteArray getCredentialIdBytes() {
|
||||
try {
|
||||
return ByteArray.fromBase64Url(credentialId);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to decode credential ID", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void setCredentialIdBytes(ByteArray credentialId) {
|
||||
this.credentialId = credentialId.getBase64Url();
|
||||
}
|
||||
|
||||
public ByteArray getPublicKeyBytes() {
|
||||
try {
|
||||
return ByteArray.fromBase64Url(publicKey);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to decode public key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void setPublicKeyBytes(ByteArray publicKey) {
|
||||
this.publicKey = publicKey.getBase64Url();
|
||||
}
|
||||
|
||||
public ByteArray getUserHandleBytes() {
|
||||
try {
|
||||
return ByteArray.fromBase64Url(userHandle);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to decode user handle", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void setUserHandleBytes(ByteArray userHandle) {
|
||||
this.userHandle = userHandle.getBase64Url();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.bstly.we.webauthn.model;
|
||||
|
||||
public enum WebAuthnUsage {
|
||||
NONE, TWO_FA, LOGIN
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.bstly.we.webauthn.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "webauthn_userhandle_mapping")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WebAuthnUserHandle {
|
||||
|
||||
@Id
|
||||
@Column(name = "target")
|
||||
private Long target;
|
||||
|
||||
@Column(name = "user_handle", nullable = false, length = 64)
|
||||
private String userHandle;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package de.bstly.we.webauthn.repository;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import de.bstly.we.webauthn.model.WebAuthnChallenge;
|
||||
|
||||
@Repository
|
||||
public interface WebAuthnChallengeRepository
|
||||
extends JpaRepository<WebAuthnChallenge, Long>, QuerydslPredicateExecutor<WebAuthnChallenge> {
|
||||
|
||||
WebAuthnChallenge findByUserIdAndChallengeAndTypeAndExpiresAtAfter(Long userId, String challenge, String type,
|
||||
Instant now);
|
||||
|
||||
void deleteByExpiresAtBefore(Instant now);
|
||||
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package de.bstly.we.webauthn.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import de.bstly.we.webauthn.model.WebAuthnCredential;
|
||||
|
||||
@Repository
|
||||
public interface WebAuthnCredentialRepository
|
||||
extends JpaRepository<WebAuthnCredential, Long>, QuerydslPredicateExecutor<WebAuthnCredential> {
|
||||
|
||||
List<WebAuthnCredential> findByTarget(Long userId);
|
||||
|
||||
WebAuthnCredential findByCredentialId(String credentialId);
|
||||
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package de.bstly.we.webauthn.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import de.bstly.we.webauthn.model.WebAuthnUserHandle;
|
||||
|
||||
@Repository
|
||||
public interface WebAuthnUserHandleRepository
|
||||
extends JpaRepository<WebAuthnUserHandle, Long>, QuerydslPredicateExecutor<WebAuthnUserHandle> {
|
||||
|
||||
WebAuthnUserHandle findByTarget(Long userId);
|
||||
|
||||
WebAuthnUserHandle findByUserHandle(String userHandle);
|
||||
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package de.bstly.we.webauthn.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import de.bstly.we.businesslogic.UserManager;
|
||||
import de.bstly.we.model.User;
|
||||
import de.bstly.we.security.LocalUserDetailsService;
|
||||
import de.bstly.we.security.businesslogic.AdditionalAuthenticationProvider;
|
||||
import de.bstly.we.webauthn.businesslogic.WebAuthnManager;
|
||||
|
||||
@Component
|
||||
public class WebAuthnAuthenticationProvider implements AdditionalAuthenticationProvider<WebAuthnAuthenticationToken> {
|
||||
|
||||
@Autowired
|
||||
private UserManager userManager;
|
||||
@Autowired
|
||||
private LocalUserDetailsService userDetailsService;
|
||||
@Autowired
|
||||
private WebAuthnManager webAuthnManager;
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
if (!(authentication instanceof WebAuthnAuthenticationToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
WebAuthnAuthenticationToken token = (WebAuthnAuthenticationToken) authentication;
|
||||
String assertionJson = token.getAssertionJson();
|
||||
|
||||
Long userId = webAuthnManager.validateLoginRequest(assertionJson).orElse(null);
|
||||
if (userId == null) {
|
||||
throw new BadCredentialsException("User not authenticated");
|
||||
}
|
||||
|
||||
User user = userManager.get(userId);
|
||||
if (user == null || user.isDisabled() || user.isLocked()) {
|
||||
throw new BadCredentialsException("User not authenticated");
|
||||
}
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());
|
||||
List<GrantedAuthority> authorities = List.copyOf(userDetails.getAuthorities());
|
||||
return new UsernamePasswordAuthenticationToken(userDetails, assertionJson, authorities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return WebAuthnAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.bstly.we.webauthn.security;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
|
||||
public class WebAuthnAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Object principal;
|
||||
private final String assertionJson;
|
||||
|
||||
public WebAuthnAuthenticationToken(Object principal, String assertionJson) {
|
||||
super(Lists.newArrayList());
|
||||
this.principal = principal;
|
||||
this.assertionJson = assertionJson;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
public WebAuthnAuthenticationToken(Object principal, String assertionJson,
|
||||
Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = principal;
|
||||
this.assertionJson = assertionJson;
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return assertionJson;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
|
||||
public String getAssertionJson() {
|
||||
return assertionJson;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
|
||||
if (isAuthenticated) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
|
||||
}
|
||||
super.setAuthenticated(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void eraseCredentials() {
|
||||
super.eraseCredentials();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.bstly.we.webauthn.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import de.bstly.we.security.handler.FormAuthenticationSuccessHandler;
|
||||
import de.bstly.we.webauthn.security.filter.WebAuthnAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
public class WebAuthnSecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private FormAuthenticationSuccessHandler formAuthenticationSuccessHandler;
|
||||
@Autowired
|
||||
private AuthenticationManager authenticationManager;
|
||||
|
||||
@Value("${loginUrl:/login}")
|
||||
private String loginUrl;
|
||||
|
||||
@Bean
|
||||
public WebAuthnAuthenticationFilter webAuthnAuthenticationFilter() throws Exception {
|
||||
WebAuthnAuthenticationFilter filter = new WebAuthnAuthenticationFilter("/auth/webauthn/login");
|
||||
filter.setAuthenticationManager(authenticationManager);
|
||||
filter.setAuthenticationSuccessHandler(formAuthenticationSuccessHandler);
|
||||
filter.setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler(loginUrl + "?error"));
|
||||
return filter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain webAuthnFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/auth/webauthn/login")
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.securityContext((securityContext) -> securityContext.requireExplicitSave(false))
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
|
||||
.addFilterBefore(webAuthnAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package de.bstly.we.webauthn.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||
import org.springframework.security.authentication.AuthenticationServiceException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import de.bstly.we.webauthn.security.WebAuthnAuthenticationToken;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
public class WebAuthnAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
|
||||
|
||||
public static final String SPRING_SECURITY_FORM_WEBAUTHN_USERID = "userId";
|
||||
public static final String SPRING_SECURITY_FORM_WEBAUTHN_JSON = "assertionJson";
|
||||
|
||||
public WebAuthnAuthenticationFilter(String defaultFilterProcessesUrl) {
|
||||
super(defaultFilterProcessesUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
|
||||
throws AuthenticationException, IOException, ServletException {
|
||||
|
||||
if (!request.getMethod().equals("POST")) {
|
||||
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
|
||||
}
|
||||
Long userId = null;
|
||||
String userIdParam = obtainUserId(request);
|
||||
if (StringUtils.hasText(userIdParam)) {
|
||||
try {
|
||||
userId = Long.valueOf(userIdParam);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new AuthenticationCredentialsNotFoundException("Bad request");
|
||||
}
|
||||
}
|
||||
String assertionJson = optainJson(request);
|
||||
|
||||
if (!StringUtils.hasText(assertionJson)) {
|
||||
throw new AuthenticationCredentialsNotFoundException("Bad request");
|
||||
}
|
||||
|
||||
WebAuthnAuthenticationToken authRequest = new WebAuthnAuthenticationToken(userId, assertionJson);
|
||||
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
|
||||
|
||||
return this.getAuthenticationManager().authenticate(authRequest);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException failed) throws IOException, ServletException {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Authentication request failed: " + failed.toString(), failed);
|
||||
logger.debug("Updated SecurityContextHolder to contain null Authentication");
|
||||
logger.debug("Delegating to authentication failure handler " + getFailureHandler());
|
||||
}
|
||||
|
||||
getRememberMeServices().loginFail(request, response);
|
||||
getFailureHandler().onAuthenticationFailure(request, response, failed);
|
||||
}
|
||||
|
||||
protected String obtainUserId(HttpServletRequest request) {
|
||||
return request.getParameter(SPRING_SECURITY_FORM_WEBAUTHN_USERID);
|
||||
}
|
||||
|
||||
protected String optainJson(HttpServletRequest request) {
|
||||
return request.getParameter(SPRING_SECURITY_FORM_WEBAUTHN_JSON);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user