diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index 5861b883823..69d5f8ab165 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -122,6 +122,9 @@ 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 + testImplementation libs.org.awaitility.awaitility testRuntimeOnly 'org.hsqldb:hsqldb' } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnWebDriverTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnWebDriverTests.java new file mode 100644 index 00000000000..f07ccf9f3b6 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnWebDriverTests.java @@ -0,0 +1,340 @@ +/* + * 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.web.configurers; + +import java.time.Duration; +import java.util.EnumSet; +import java.util.Map; + +import jakarta.servlet.DispatcherType; +import org.awaitility.Awaitility; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextClosedEvent; +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.test.context.junit.jupiter.SpringExtension; + +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. + *
+ * The tests are ordered to ensure that no credential is registered with Spring Security + * before the last "end-to-end" test. It does not impact the tests for now, but should + * avoid test pollution in the future. + * + * @author Daniel Garnier-Moiroux + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@ExtendWith(SpringExtension.class) +class WebAuthnWebDriverTests { + + private static String baseUrl; + + private static ChromeDriverService driverService; + + 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(); + } + + @BeforeAll + static void setupBaseUrl(@Autowired Server server) throws Exception { + baseUrl = "http://localhost:" + ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + @AfterAll + static void stopServer(@Autowired Server server) throws Exception { + // Close the server early and don't wait for the full context to be closed, as it + // may take some time to get evicted from the ContextCache. + server.stop(); + } + + @BeforeEach + void setupDriver() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--headless=new"); + var 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 + @Order(1) + void loginWhenNoValidAuthenticatorCredentialsThenRejects() { + createVirtualAuthenticator(true); + this.driver.get(baseUrl); + this.driver.findElement(new By.ById("passkey-signin")).click(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error")); + } + + @Test + @Order(2) + void registerWhenNoLabelThenRejects() { + login(); + + this.driver.get(baseUrl + "/webauthn/register"); + + this.driver.findElement(new By.ById("register")).click(); + WebElement errorPopup = this.driver.findElement(new By.ById("error")); + + assertThat(errorPopup.isDisplayed()).isTrue(); + assertThat(errorPopup.getText()).isEqualTo("Error: Passkey Label is required"); + } + + @Test + @Order(3) + void registerWhenAuthenticatorNoUserVerificationThenRejects() { + createVirtualAuthenticator(false); + login(); + this.driver.get(baseUrl + "/webauthn/register"); + this.driver.findElement(new By.ById("label")).sendKeys("Virtual authenticator"); + this.driver.findElement(new By.ById("register")).click(); + + Awaitility.await() + .atMost(Duration.ofSeconds(2)) + .pollInterval(Duration.ofMillis(100)) + .untilAsserted(() -> assertHasAlert("error", + "Registration failed. Call to navigator.credentials.create failed: The operation either timed out or was not allowed.")); + } + + /** + * 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) {
+ var cdpDriver = (HasCdp) this.driver;
+ cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false));
+ // this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions());
+ //@formatter:off
+ var commandResult = cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
+ Map.of(
+ "options",
+ Map.of(
+ "protocol", "ctap2",
+ "transport", "usb",
+ "hasUserVerification", true,
+ "hasResidentKey", true,
+ "isUserVerified", userIsVerified,
+ "automaticPresenceSimulation", true
+ )
+ ));
+ //@formatter:on
+ }
+
+ /**
+ * The configuration for WebAuthN tests. This configuration embeds a {@link Server},
+ * because the WebAuthN configurer needs to know the port on which the server is
+ * running to configure {@link WebAuthnConfigurer#allowedOrigins(String...)}. This
+ * requires starting the server before configuring the Security Filter chain.
+ */
+ @Configuration
+ @EnableWebSecurity
+ static class WebAuthnConfiguration {
+
+ @Bean
+ UserDetailsService userDetailsService() {
+ return new InMemoryUserDetailsManager(
+ User.withDefaultPasswordEncoder().username(USERNAME).password(PASSWORD).build());
+ }
+
+ @Bean
+ SecurityFilterChain securityFilterChain(HttpSecurity http, Server server) throws Exception {
+ return http.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
+ .formLogin(Customizer.withDefaults())
+ .webAuthn((passkeys) -> passkeys.rpId("localhost")
+ .rpName("Spring Security WebAuthN tests")
+ .allowedOrigins("http://localhost:" + getServerPort(server)))
+ .build();
+ }
+
+ @Bean
+ Server server() throws Exception {
+ ServletContextHandler servlet = new ServletContextHandler(ServletContextHandler.SESSIONS);
+ Server server = new Server(0);
+ server.setHandler(servlet);
+ server.start();
+ return server;
+ }
+
+ /**
+ * Ensure the server is stopped whenever the application context closes.
+ * @param server -
+ * @return -
+ */
+ @Bean
+ ApplicationListener