diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index 5861b883823..d659fdd7b60 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -122,6 +122,8 @@ dependencies { exclude group: "org.slf4j", module: "jcl-over-slf4j" } testImplementation libs.org.instancio.instancio.junit + testImplementation libs.org.eclipse.jetty.jetty.server + testImplementation libs.org.eclipse.jetty.jetty.servlet testRuntimeOnly 'org.hsqldb:hsqldb' } 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 new file mode 100644 index 00000000000..d133ee23fd6 --- /dev/null +++ b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java @@ -0,0 +1,348 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.config.annotation.configurers; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.AbstractStringAssert; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriverService; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.chromium.HasCdp; +import org.openqa.selenium.devtools.HasDevTools; +import org.openqa.selenium.remote.Augmenter; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.support.ui.FluentWait; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockPropertySource; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.filter.DelegatingFilterProxy; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Webdriver-based tests for the WebAuthnConfigurer. This uses a full browser because + * these features require Javascript and browser APIs to be available. + * + * @author Daniel Garnier-Moiroux + */ +class WebAuthnWebDriverTests { + + private String baseUrl; + + private static ChromeDriverService driverService; + + private Server server; + + private RemoteWebDriver driver; + + private static final String USERNAME = "user"; + + private static final String PASSWORD = "password"; + + @BeforeAll + static void startChromeDriverService() throws Exception { + driverService = new ChromeDriverService.Builder().usingAnyFreePort().build(); + driverService.start(); + } + + @AfterAll + static void stopChromeDriverService() { + driverService.stop(); + } + + @BeforeEach + void startServer() throws Exception { + // Create the server on port 8080 + this.server = new Server(0); + + // Set up the ServletContextHandler + ServletContextHandler contextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS); + contextHandler.setContextPath("/"); + this.server.setHandler(contextHandler); + this.server.start(); + int serverPort = ((ServerConnector) this.server.getConnectors()[0]).getLocalPort(); + this.baseUrl = "http://localhost:" + serverPort; + + // Set up Spring application context + AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(); + applicationContext.register(WebAuthnConfiguration.class); + applicationContext.setServletContext(contextHandler.getServletContext()); + + // Add the server port + MockPropertySource propertySource = new MockPropertySource().withProperty("server.port", serverPort); + applicationContext.getEnvironment().getPropertySources().addFirst(propertySource); + + // Register the filter chain + DelegatingFilterProxy filterProxy = new DelegatingFilterProxy("securityFilterChain", applicationContext); + FilterHolder filterHolder = new FilterHolder(filterProxy); + contextHandler.addFilter(filterHolder, "/*", null); + } + + @AfterEach + void stopServer() throws Exception { + this.server.stop(); + } + + @BeforeEach + void setupDriver() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--headless=new"); + RemoteWebDriver baseDriver = new RemoteWebDriver(driverService.getUrl(), options); + // Enable dev tools + this.driver = (RemoteWebDriver) new Augmenter().augment(baseDriver); + this.driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(1)); + } + + @AfterEach + void cleanupDriver() { + this.driver.quit(); + } + + @Test + void loginWhenNoValidAuthenticatorCredentialsThenRejects() { + createVirtualAuthenticator(true); + this.driver.get(this.baseUrl); + this.driver.findElement(signinWithPasskeyButton()).click(); + await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error")); + } + + @Test + void registerWhenNoLabelThenRejects() { + login(); + + this.driver.get(this.baseUrl + "/webauthn/register"); + + this.driver.findElement(registerPasskeyButton()).click(); + assertHasAlertStartingWith("error", "Error: Passkey Label is required"); + } + + @Test + void registerWhenAuthenticatorNoUserVerificationThenRejects() { + createVirtualAuthenticator(false); + login(); + this.driver.get(this.baseUrl + "/webauthn/register"); + this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator"); + this.driver.findElement(registerPasskeyButton()).click(); + + await(() -> assertHasAlertStartingWith("error", + "Registration failed. Call to navigator.credentials.create failed:")); + } + + /** + * Test in 4 steps to verify the end-to-end flow of registering an authenticator and + * using it to register. + *
+ * Note that Selenium docs for {@link HasCdp} strongly encourage to use + * {@link HasDevTools} instead. However, devtools require more dependencies and + * boilerplate, notably to sync the Devtools-CDP version with the current browser + * version, whereas CDP runs out of the box. + *
+ * @param userIsVerified whether the authenticator simulates user verification.
+ * Setting it to false will make the ceremonies fail.
+ * @see https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/
+ */
+ private void createVirtualAuthenticator(boolean userIsVerified) {
+ HasCdp cdpDriver = (HasCdp) this.driver;
+ cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false));
+ // this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions());
+ //@formatter:off
+ cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
+ Map.of(
+ "options",
+ Map.of(
+ "protocol", "ctap2",
+ "transport", "usb",
+ "hasUserVerification", true,
+ "hasResidentKey", true,
+ "isUserVerified", userIsVerified,
+ "automaticPresenceSimulation", true
+ )
+ ));
+ //@formatter:on
+ }
+
+ private void login() {
+ this.driver.get(this.baseUrl);
+ this.driver.findElement(usernameField()).sendKeys(USERNAME);
+ this.driver.findElement(passwordField()).sendKeys(PASSWORD);
+ this.driver.findElement(signinWithUsernamePasswordButton()).click();
+ }
+
+ private void logout() {
+ this.driver.get(this.baseUrl + "/logout");
+ this.driver.findElement(logoutButton()).click();
+ await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout"));
+ }
+
+ private AbstractStringAssert> assertHasAlertStartingWith(String alertType, String alertMessage) {
+ WebElement alert = this.driver.findElement(new By.ById(alertType));
+ assertThat(alert.isDisplayed())
+ .withFailMessage(
+ () -> alertType + " alert was not displayed. Full page source:\n\n" + this.driver.getPageSource())
+ .isTrue();
+
+ return assertThat(alert.getText()).startsWith(alertMessage);
+ }
+
+ /**
+ * Await until the assertion passes. If the assertion fails, it will display the
+ * assertion error in stdout.
+ */
+ private void await(Supplier