Skip to content

Commit

Permalink
webauthn: add webdriver test
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
Kehrlann authored and rwinch committed Dec 11, 2024
1 parent cb4c7e5 commit 99cc65d
Show file tree
Hide file tree
Showing 2 changed files with 350 additions and 0 deletions.
2 changes: 2 additions & 0 deletions config/spring-security-config.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <ul>
* <li>Step 1: Log in with username / password</li>
* <li>Step 2: Register a credential from the virtual authenticator</li>
* <li>Step 3: Log out</li>
* <li>Step 4: Log in with the authenticator</li>
* </ul>
*/
@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<WebElement> 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.
* <p>
* 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.
* <p>
* @param userIsVerified whether the authenticator simulates user verification.
* Setting it to false will make the ceremonies fail.
* @see <a href=
* "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/</a>
*/
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<AbstractAssert<?, ?>> 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);
}

}

}

0 comments on commit 99cc65d

Please sign in to comment.