mirror of
https://github.com/spring-projects/spring-security.git
synced 2025-06-24 13:02:13 +00:00
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.
This commit is contained in:
parent
cb4c7e5886
commit
99cc65d74c
@ -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'
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user