From 99cc65d74c81761b1d15f1ea63ab7b8698206ac2 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 21 Oct 2024 15:50:37 +0200 Subject: [PATCH] webauthn: add webdriver test - These tests verify the full end-to-end flow, including the javascript code bundled in the default login and logout pages. They require a full web browser, with support for Virtual Authenticators for automated testing. At this point in time, only Chrome supports virutal authenticators. --- config/spring-security-config.gradle | 2 + .../configurers/WebAuthnWebDriverTests.java | 348 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index 5861b88382..d659fdd7b6 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 0000000000..d133ee23fd --- /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. + * + */ + @Test + void loginWhenAuthenticatorRegisteredThenSuccess() { + // Setup + createVirtualAuthenticator(true); + + // Step 1: log in with username / password + login(); + + // Step 2: register a credential from the virtual authenticator + this.driver.get(this.baseUrl + "/webauthn/register"); + this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator"); + this.driver.findElement(registerPasskeyButton()).click(); + + await(() -> assertHasAlertStartingWith("success", "Success!")); + + List passkeyRows = this.driver.findElements(passkeyTableRows()); + assertThat(passkeyRows).hasSize(1) + .first() + .extracting((row) -> row.findElement(firstCell())) + .extracting(WebElement::getText) + .isEqualTo("Virtual authenticator"); + + // Step 3: log out + logout(); + + // Step 4: log in with the virtual authenticator + this.driver.get(this.baseUrl + "/webauthn/register"); + this.driver.findElement(signinWithPasskeyButton()).click(); + await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue")); + } + + /** + * Add a virtual authenticator. + *

+ * 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> assertion) { + new FluentWait<>(this.driver).withTimeout(Duration.ofSeconds(2)) + .pollingEvery(Duration.ofMillis(100)) + .ignoring(AssertionError.class) + .until((d) -> { + assertion.get(); + return true; + }); + } + + private static By.ById passkeyLabel() { + return new By.ById("label"); + } + + private static By.ById registerPasskeyButton() { + return new By.ById("register"); + } + + private static By.ByCssSelector passkeyTableRows() { + return new By.ByCssSelector("table > tbody > tr"); + } + + private static By.ByCssSelector firstCell() { + return new By.ByCssSelector("td:first-child"); + } + + private static By.ById passwordField() { + return new By.ById(PASSWORD); + } + + private static By.ById usernameField() { + return new By.ById("username"); + } + + private static By.ByCssSelector signinWithUsernamePasswordButton() { + return new By.ByCssSelector("form > button[type=\"submit\"]"); + } + + private static By.ById signinWithPasskeyButton() { + return new By.ById("passkey-signin"); + } + + private static By.ByCssSelector logoutButton() { + return new By.ByCssSelector("button"); + } + + /** + * The configuration for WebAuthN tests. It accesses the Server's current port, so we + * can configurer WebAuthnConfigurer#allowedOrigin + */ + @Configuration + @EnableWebMvc + @EnableWebSecurity + static class WebAuthnConfiguration { + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder().username(USERNAME).password(PASSWORD).build()); + } + + @Bean + FilterChainProxy securityFilterChain(HttpSecurity http, Environment environment) throws Exception { + SecurityFilterChain securityFilterChain = http + .authorizeHttpRequests((auth) -> auth.anyRequest().authenticated()) + .formLogin(Customizer.withDefaults()) + .webAuthn((passkeys) -> passkeys.rpId("localhost") + .rpName("Spring Security WebAuthN tests") + .allowedOrigins("http://localhost:" + environment.getProperty("server.port"))) + .build(); + return new FilterChainProxy(securityFilterChain); + } + + } + +}