diff --git a/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java index b0a035ef6b2..baa4476750d 100644 --- a/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java +++ b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; import org.openqa.selenium.WebDriverException; @@ -55,6 +56,7 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.util.StringUtils; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.servlet.config.annotation.EnableWebMvc; @@ -67,7 +69,7 @@ * * @author Daniel Garnier-Moiroux */ -@org.junit.jupiter.api.Disabled +@Disabled class WebAuthnWebDriverTests { private String baseUrl; @@ -82,6 +84,8 @@ class WebAuthnWebDriverTests { private static final String PASSWORD = "password"; + private String authenticatorId = null; + @BeforeAll static void startChromeDriverService() throws Exception { driverService = new ChromeDriverService.Builder().usingAnyFreePort().build(); @@ -144,7 +148,7 @@ void cleanupDriver() { @Test void loginWhenNoValidAuthenticatorCredentialsThenRejects() { createVirtualAuthenticator(true); - this.driver.get(this.baseUrl); + this.getAndWait("/", "/login"); this.driver.findElement(signinWithPasskeyButton()).click(); await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error")); } @@ -153,7 +157,7 @@ void loginWhenNoValidAuthenticatorCredentialsThenRejects() { void registerWhenNoLabelThenRejects() { login(); - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register"); this.driver.findElement(registerPasskeyButton()).click(); assertHasAlertStartingWith("error", "Error: Passkey Label is required"); @@ -163,7 +167,7 @@ void registerWhenNoLabelThenRejects() { void registerWhenAuthenticatorNoUserVerificationThenRejects() { createVirtualAuthenticator(false); login(); - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register"); this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator"); this.driver.findElement(registerPasskeyButton()).click(); @@ -178,7 +182,8 @@ void registerWhenAuthenticatorNoUserVerificationThenRejects() { *
  • Step 1: Log in with username / password
  • *
  • Step 2: Register a credential from the virtual authenticator
  • *
  • Step 3: Log out
  • - *
  • Step 4: Log in with the authenticator
  • + *
  • Step 4: Log in with the authenticator (no allowCredentials)
  • + *
  • Step 5: Log in again with the same authenticator (with allowCredentials)
  • * */ @Test @@ -190,7 +195,7 @@ void loginWhenAuthenticatorRegisteredThenSuccess() { login(); // Step 2: register a credential from the virtual authenticator - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register"); this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator"); this.driver.findElement(registerPasskeyButton()).click(); @@ -212,9 +217,58 @@ void loginWhenAuthenticatorRegisteredThenSuccess() { logout(); // Step 4: log in with the virtual authenticator - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register", "/login"); this.driver.findElement(signinWithPasskeyButton()).click(); await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue")); + + // Step 5: authenticate while being already logged in + // This simulates some use-cases with MFA. Since the user is already logged in, + // the "allowCredentials" property is populated + this.getAndWait("/login"); + this.driver.findElement(signinWithPasskeyButton()).click(); + await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/")); + } + + @Test + void registerWhenAuthenticatorAlreadyRegisteredThenRejects() { + createVirtualAuthenticator(true); + login(); + registerAuthenticator("Virtual authenticator"); + + // Cannot re-register the same authenticator because excludeCredentials + // is not empty and contains the given authenticator + this.driver.findElement(passkeyLabel()).sendKeys("Same authenticator"); + this.driver.findElement(registerPasskeyButton()).click(); + + await(() -> assertHasAlertStartingWith("error", "Registration failed")); + } + + @Test + void registerSecondAuthenticatorThenSucceeds() { + createVirtualAuthenticator(true); + login(); + + registerAuthenticator("Virtual authenticator"); + this.getAndWait("/webauthn/register"); + List passkeyRows = this.driver.findElements(passkeyTableRows()); + assertThat(passkeyRows).hasSize(1) + .first() + .extracting((row) -> row.findElement(firstCell())) + .extracting(WebElement::getText) + .isEqualTo("Virtual authenticator"); + + // Create second authenticator and register + removeAuthenticator(); + createVirtualAuthenticator(true); + registerAuthenticator("Second virtual authenticator"); + + this.getAndWait("/webauthn/register"); + + passkeyRows = this.driver.findElements(passkeyTableRows()); + assertThat(passkeyRows).hasSize(2) + .extracting((row) -> row.findElement(firstCell())) + .extracting(WebElement::getText) + .contains("Second virtual authenticator"); } /** @@ -231,11 +285,14 @@ void loginWhenAuthenticatorRegisteredThenSuccess() { * "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/ */ private void createVirtualAuthenticator(boolean userIsVerified) { + if (StringUtils.hasText(this.authenticatorId)) { + throw new IllegalStateException("Authenticator already exists, please remove it before re-creating one"); + } HasCdp cdpDriver = (HasCdp) this.driver; cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false)); // this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions()); //@formatter:off - cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator", + Map cmdResponse = cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator", Map.of( "options", Map.of( @@ -248,21 +305,38 @@ private void createVirtualAuthenticator(boolean userIsVerified) { ) )); //@formatter:on + this.authenticatorId = cmdResponse.get("authenticatorId").toString(); + } + + private void removeAuthenticator() { + HasCdp cdpDriver = (HasCdp) this.driver; + cdpDriver.executeCdpCommand("WebAuthn.removeVirtualAuthenticator", + Map.of("authenticatorId", this.authenticatorId)); + this.authenticatorId = null; } private void login() { - this.driver.get(this.baseUrl); + this.getAndWait("/", "/login"); this.driver.findElement(usernameField()).sendKeys(USERNAME); this.driver.findElement(passwordField()).sendKeys(PASSWORD); this.driver.findElement(signinWithUsernamePasswordButton()).click(); + // Ensure login has completed + await(() -> assertThat(this.driver.getCurrentUrl()).doesNotContain("/login")); } private void logout() { - this.driver.get(this.baseUrl + "/logout"); + this.getAndWait("/logout"); this.driver.findElement(logoutButton()).click(); await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout")); } + private void registerAuthenticator(String passkeyName) { + this.getAndWait("/webauthn/register"); + this.driver.findElement(passkeyLabel()).sendKeys(passkeyName); + this.driver.findElement(registerPasskeyButton()).click(); + await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?success")); + } + private AbstractStringAssert assertHasAlertStartingWith(String alertType, String alertMessage) { WebElement alert = this.driver.findElement(new By.ById(alertType)); assertThat(alert.isDisplayed()) @@ -289,6 +363,15 @@ private void await(Supplier> assertion) { }); } + private void getAndWait(String endpoint) { + this.getAndWait(endpoint, endpoint); + } + + private void getAndWait(String endpoint, String redirectUrl) { + this.driver.get(this.baseUrl + endpoint); + this.await(() -> assertThat(this.driver.getCurrentUrl()).endsWith(redirectUrl)); + } + private static By.ById passkeyLabel() { return new By.ById("label"); } @@ -325,6 +408,10 @@ private static By.ByCssSelector logoutButton() { return new By.ByCssSelector("button"); } + private static By.ByCssSelector deletePasskeyButton() { + return new By.ByCssSelector("table > tbody > tr > button"); + } + /** * The configuration for WebAuthN tests. It accesses the Server's current port, so we * can configurer WebAuthnConfigurer#allowedOrigin diff --git a/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java b/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java index f9db52cd318..4d18768b347 100644 --- a/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java +++ b/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java @@ -95,7 +95,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe private final PublicKeyCredentialRpEntity rp; - private final ObjectConverter objectConverter = new ObjectConverter(); + private ObjectConverter objectConverter = new ObjectConverter(); private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl(); @@ -137,6 +137,15 @@ public void setWebAuthnManager(WebAuthnManager webAuthnManager) { this.webAuthnManager = webAuthnManager; } + /** + * Sets the {@link ObjectConverter} to use. + * @param objectConverter the {@link ObjectConverter} to use. Cannot be null. + */ + void setObjectConverter(ObjectConverter objectConverter) { + Assert.notNull(objectConverter, "objectConverter cannot be null"); + this.objectConverter = objectConverter; + } + /** * Sets a {@link Consumer} used to customize the * {@link PublicKeyCredentialCreationOptionsBuilder} for @@ -390,7 +399,7 @@ public PublicKeyCredentialUserEntity authenticate(RelyingPartyAuthenticationRequ .getUserVerification() == UserVerificationRequirement.REQUIRED; com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest( - request.getPublicKey().getId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(), + request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(), assertionResponse.getClientDataJSON().getBytes(), assertionResponse.getSignature().getBytes()); // CollectedClientData and ExtensionsClientOutputs is registration data, and can diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/management/Webauthn4jRelyingPartyOperationsTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/management/Webauthn4jRelyingPartyOperationsTests.java index 405449b3519..65213c7b6ab 100644 --- a/webauthn/src/test/java/org/springframework/security/web/webauthn/management/Webauthn4jRelyingPartyOperationsTests.java +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/management/Webauthn4jRelyingPartyOperationsTests.java @@ -27,15 +27,20 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.webauthn4j.WebAuthnManager; import com.webauthn4j.converter.AttestationObjectConverter; import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationData; +import com.webauthn4j.data.AuthenticationRequest; import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; import com.webauthn4j.data.attestation.authenticator.AuthenticatorData; import com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput; import org.assertj.core.api.recursive.comparison.RecursiveComparisonConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -44,12 +49,14 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.PasswordEncodedUser; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse; import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse; import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse.AuthenticatorAttestationResponseBuilder; import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria; import org.springframework.security.web.webauthn.api.AuthenticatorTransport; import org.springframework.security.web.webauthn.api.Bytes; import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; import org.springframework.security.web.webauthn.api.PublicKeyCredential; import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions; import org.springframework.security.web.webauthn.api.PublicKeyCredentialDescriptor; @@ -57,9 +64,11 @@ import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions; import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity; import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.TestAuthenticationAssertionResponses; import org.springframework.security.web.webauthn.api.TestAuthenticatorAttestationResponses; import org.springframework.security.web.webauthn.api.TestCredentialRecords; import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialRequestOptions; import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntities; import org.springframework.security.web.webauthn.api.TestPublicKeyCredentials; import org.springframework.security.web.webauthn.api.UserVerificationRequirement; @@ -67,7 +76,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatRuntimeException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @@ -587,6 +598,50 @@ void createCredentialRequestOptionsWhenAuthenticated() { .containsExactly(credentialRecord.getCredentialId()); } + // gh-18158 + @Test + void authenticateThenWa4jRequestCredentialIdIsRawIdBytes() throws Exception { + PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create().build(); + AuthenticatorAssertionResponse response = TestAuthenticationAssertionResponses + .createAuthenticatorAssertionResponse() + .build(); + PublicKeyCredential credentials = TestPublicKeyCredentials + .createPublicKeyCredential(response) + .build(); + RelyingPartyAuthenticationRequest request = new RelyingPartyAuthenticationRequest(options, credentials); + PublicKeyCredential publicKey = request.getPublicKey(); + + ImmutableCredentialRecord credentialRecord = TestCredentialRecords.fullUserCredential().build(); + given(this.userCredentials.findByCredentialId(publicKey.getRawId())).willReturn(credentialRecord); + ObjectMapper json = mock(ObjectMapper.class); + ObjectMapper cbor = mock(ObjectMapper.class); + given(cbor.getFactory()).willReturn(mock(CBORFactory.class)); + AttestationObject attestationObject = mock(AttestationObject.class); + AuthenticatorData wa4jAuthData = mock(AuthenticatorData.class); + given(attestationObject.getAuthenticatorData()).willReturn(wa4jAuthData); + given(wa4jAuthData.getAttestedCredentialData()).willReturn(mock(AttestedCredentialData.class)); + given(cbor.readValue(credentialRecord.getAttestationObject().getBytes(), AttestationObject.class)) + .willReturn(attestationObject); + this.rpOperations.setObjectConverter(new ObjectConverter(json, cbor)); + + WebAuthnManager manager = mock(WebAuthnManager.class); + ArgumentCaptor wa4jRequest = ArgumentCaptor.forClass(AuthenticationRequest.class); + AuthenticationData wa4jData = mock(AuthenticationData.class); + given(wa4jData.getAuthenticatorData()).willReturn(mock(AuthenticatorData.class)); + given(manager.verify(wa4jRequest.capture(), any())).willReturn(wa4jData); + given(this.userEntities.findById(any())).willReturn(TestPublicKeyCredentialUserEntities.userEntity().build()); + this.rpOperations.setWebAuthnManager(manager); + + this.rpOperations.authenticate(request); + + // this ensures that our next assertion is valid (we want the rawId bytes, not the + // id bytes to be used) + assertThat(publicKey.getRawId().getBytes()).isNotEqualTo(publicKey.getId().getBytes()); + // ensure that the raw id bytes are passed into webauthn4j (not the id bytes which + // are base64 encoded) + assertThat(wa4jRequest.getValue().getCredentialId()).isEqualTo(publicKey.getRawId().getBytes()); + } + private static AuthenticatorAttestationResponse setFlag(byte... flags) throws Exception { AuthenticatorAttestationResponseBuilder authAttResponseBldr = TestAuthenticatorAttestationResponses .createAuthenticatorAttestationResponse();