upgrade to Spring Boot 4, add webauthn support, some cleanup

This commit is contained in:
2025-12-18 20:55:43 +01:00
parent c27e68caf0
commit b4b2552e7e
326 changed files with 2768 additions and 1075 deletions
+35
View File
@@ -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();
}
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
}