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 committed Oct 25, 2024
1 parent df7732d commit 43342f9
Show file tree
Hide file tree
Showing 3 changed files with 344 additions and 0 deletions.
3 changes: 3 additions & 0 deletions config/spring-security-config.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <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>
*
* This test runs last to ensure that no credential is registered when the previous
* tests run.
*/
@Test
@Order(Integer.MAX_VALUE)
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(baseUrl + "/webauthn/register");
this.driver.findElement(new By.ById("label")).sendKeys("Virtual authenticator");
this.driver.findElement(new By.ById("register")).click();

//@formatter:off
Awaitility.await()
.atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertHasAlert("success", "Success!"));
//@formatter:on;

var passkeyRows = this.driver.findElements(new By.ByCssSelector("table > tbody > tr"));
assertThat(passkeyRows).hasSize(1)
.first()
.extracting((row) -> row.findElement(new By.ByCssSelector("td:first-child")))
.extracting(WebElement::getText)
.isEqualTo("Virtual authenticator");

// Step 3: log out
logout();

// Step 4: log in with the virtual authenticator
this.driver.get(baseUrl + "/webauthn/register");
this.driver.findElement(new By.ById("passkey-signin")).click();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue"));
}

private void login() {
this.driver.get(baseUrl);
this.driver.findElement(new By.ById("username")).sendKeys(USERNAME);
this.driver.findElement(new By.ById(PASSWORD)).sendKeys(PASSWORD);
this.driver.findElement(new By.ByCssSelector("form > button[type=\"submit\"]")).click();
}

private void logout() {
this.driver.get(baseUrl + "/logout");
this.driver.findElement(new By.ByCssSelector("button")).click();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout"));
}

private void assertHasAlert(String alertType, String alertMessage) {
var 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();

assertThat(alert.getText()).startsWith(alertMessage);
}

/**
* 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) {
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<ContextClosedEvent> onContextStopped(Server server) {
return (event) -> {
try {
server.stop();
}
catch (Exception ignored) {
}
};
}

@Autowired
void addSecurityFilterChainToServlet(Server server, SecurityFilterChain filterChain) {
FilterChainProxy filterChainProxy = new FilterChainProxy(filterChain);
((ServletContextHandler) server.getHandler()).addFilter(new FilterHolder(filterChainProxy), "/*",
EnumSet.allOf(DispatcherType.class));
}

private static int getServerPort(Server server) {
return ((ServerConnector) server.getConnectors()[0]).getLocalPort();
}

}

}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ org-hidetake-gradle-ssh-plugin = "org.hidetake:gradle-ssh-plugin:2.10.1"
org-jfrog-buildinfo-build-info-extractor-gradle = "org.jfrog.buildinfo:build-info-extractor-gradle:4.33.22"
org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8.0.1969"
org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
org-awaitility-awaitility = "org.awaitility:awaitility:4.2.2"

webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.27.0.RELEASE'

Expand Down

0 comments on commit 43342f9

Please sign in to comment.