Skip to content

Commit 43342f9

Browse files
committed
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.
1 parent df7732d commit 43342f9

File tree

3 files changed

+344
-0
lines changed

3 files changed

+344
-0
lines changed

config/spring-security-config.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ dependencies {
122122
exclude group: "org.slf4j", module: "jcl-over-slf4j"
123123
}
124124
testImplementation libs.org.instancio.instancio.junit
125+
testImplementation libs.org.eclipse.jetty.jetty.server
126+
testImplementation libs.org.eclipse.jetty.jetty.servlet
127+
testImplementation libs.org.awaitility.awaitility
125128

126129
testRuntimeOnly 'org.hsqldb:hsqldb'
127130
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.annotation.web.configurers;
18+
19+
import java.time.Duration;
20+
import java.util.EnumSet;
21+
import java.util.Map;
22+
23+
import jakarta.servlet.DispatcherType;
24+
import org.awaitility.Awaitility;
25+
import org.eclipse.jetty.server.Server;
26+
import org.eclipse.jetty.server.ServerConnector;
27+
import org.eclipse.jetty.servlet.FilterHolder;
28+
import org.eclipse.jetty.servlet.ServletContextHandler;
29+
import org.junit.jupiter.api.AfterAll;
30+
import org.junit.jupiter.api.AfterEach;
31+
import org.junit.jupiter.api.BeforeAll;
32+
import org.junit.jupiter.api.BeforeEach;
33+
import org.junit.jupiter.api.MethodOrderer;
34+
import org.junit.jupiter.api.Order;
35+
import org.junit.jupiter.api.Test;
36+
import org.junit.jupiter.api.TestMethodOrder;
37+
import org.junit.jupiter.api.extension.ExtendWith;
38+
import org.openqa.selenium.By;
39+
import org.openqa.selenium.WebElement;
40+
import org.openqa.selenium.chrome.ChromeDriverService;
41+
import org.openqa.selenium.chrome.ChromeOptions;
42+
import org.openqa.selenium.chromium.HasCdp;
43+
import org.openqa.selenium.devtools.HasDevTools;
44+
import org.openqa.selenium.remote.Augmenter;
45+
import org.openqa.selenium.remote.RemoteWebDriver;
46+
47+
import org.springframework.beans.factory.annotation.Autowired;
48+
import org.springframework.context.ApplicationListener;
49+
import org.springframework.context.annotation.Bean;
50+
import org.springframework.context.annotation.Configuration;
51+
import org.springframework.context.event.ContextClosedEvent;
52+
import org.springframework.security.config.Customizer;
53+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
54+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
55+
import org.springframework.security.core.userdetails.User;
56+
import org.springframework.security.core.userdetails.UserDetailsService;
57+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
58+
import org.springframework.security.web.FilterChainProxy;
59+
import org.springframework.security.web.SecurityFilterChain;
60+
import org.springframework.test.context.junit.jupiter.SpringExtension;
61+
62+
import static org.assertj.core.api.Assertions.assertThat;
63+
64+
/**
65+
* Webdriver-based tests for the WebAuthnConfigurer. This uses a full browser because
66+
* these features require Javascript and browser APIs to be available.
67+
* <p>
68+
* The tests are ordered to ensure that no credential is registered with Spring Security
69+
* before the last "end-to-end" test. It does not impact the tests for now, but should
70+
* avoid test pollution in the future.
71+
*
72+
* @author Daniel Garnier-Moiroux
73+
*/
74+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
75+
@ExtendWith(SpringExtension.class)
76+
class WebAuthnWebDriverTests {
77+
78+
private static String baseUrl;
79+
80+
private static ChromeDriverService driverService;
81+
82+
private RemoteWebDriver driver;
83+
84+
private static final String USERNAME = "user";
85+
86+
private static final String PASSWORD = "password";
87+
88+
@BeforeAll
89+
static void startChromeDriverService() throws Exception {
90+
driverService = new ChromeDriverService.Builder().usingAnyFreePort().build();
91+
driverService.start();
92+
}
93+
94+
@AfterAll
95+
static void stopChromeDriverService() {
96+
driverService.stop();
97+
}
98+
99+
@BeforeAll
100+
static void setupBaseUrl(@Autowired Server server) throws Exception {
101+
baseUrl = "http://localhost:" + ((ServerConnector) server.getConnectors()[0]).getLocalPort();
102+
}
103+
104+
@AfterAll
105+
static void stopServer(@Autowired Server server) throws Exception {
106+
// Close the server early and don't wait for the full context to be closed, as it
107+
// may take some time to get evicted from the ContextCache.
108+
server.stop();
109+
}
110+
111+
@BeforeEach
112+
void setupDriver() {
113+
ChromeOptions options = new ChromeOptions();
114+
options.addArguments("--headless=new");
115+
var baseDriver = new RemoteWebDriver(driverService.getUrl(), options);
116+
// Enable dev tools
117+
this.driver = (RemoteWebDriver) new Augmenter().augment(baseDriver);
118+
this.driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(1));
119+
}
120+
121+
@AfterEach
122+
void cleanupDriver() {
123+
this.driver.quit();
124+
}
125+
126+
@Test
127+
@Order(1)
128+
void loginWhenNoValidAuthenticatorCredentialsThenRejects() {
129+
createVirtualAuthenticator(true);
130+
this.driver.get(baseUrl);
131+
this.driver.findElement(new By.ById("passkey-signin")).click();
132+
Awaitility.await()
133+
.atMost(Duration.ofSeconds(1))
134+
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error"));
135+
}
136+
137+
@Test
138+
@Order(2)
139+
void registerWhenNoLabelThenRejects() {
140+
login();
141+
142+
this.driver.get(baseUrl + "/webauthn/register");
143+
144+
this.driver.findElement(new By.ById("register")).click();
145+
WebElement errorPopup = this.driver.findElement(new By.ById("error"));
146+
147+
assertThat(errorPopup.isDisplayed()).isTrue();
148+
assertThat(errorPopup.getText()).isEqualTo("Error: Passkey Label is required");
149+
}
150+
151+
@Test
152+
@Order(3)
153+
void registerWhenAuthenticatorNoUserVerificationThenRejects() {
154+
createVirtualAuthenticator(false);
155+
login();
156+
this.driver.get(baseUrl + "/webauthn/register");
157+
this.driver.findElement(new By.ById("label")).sendKeys("Virtual authenticator");
158+
this.driver.findElement(new By.ById("register")).click();
159+
160+
Awaitility.await()
161+
.atMost(Duration.ofSeconds(2))
162+
.pollInterval(Duration.ofMillis(100))
163+
.untilAsserted(() -> assertHasAlert("error",
164+
"Registration failed. Call to navigator.credentials.create failed: The operation either timed out or was not allowed."));
165+
}
166+
167+
/**
168+
* Test in 4 steps to verify the end-to-end flow of registering an authenticator and
169+
* using it to register.
170+
* <ul>
171+
* <li>Step 1: Log in with username / password</li>
172+
* <li>Step 2: Register a credential from the virtual authenticator</li>
173+
* <li>Step 3: Log out</li>
174+
* <li>Step 4: Log in with the authenticator</li>
175+
* </ul>
176+
*
177+
* This test runs last to ensure that no credential is registered when the previous
178+
* tests run.
179+
*/
180+
@Test
181+
@Order(Integer.MAX_VALUE)
182+
void loginWhenAuthenticatorRegisteredThenSuccess() {
183+
// Setup
184+
createVirtualAuthenticator(true);
185+
186+
// Step 1: log in with username / password
187+
login();
188+
189+
// Step 2: register a credential from the virtual authenticator
190+
this.driver.get(baseUrl + "/webauthn/register");
191+
this.driver.findElement(new By.ById("label")).sendKeys("Virtual authenticator");
192+
this.driver.findElement(new By.ById("register")).click();
193+
194+
//@formatter:off
195+
Awaitility.await()
196+
.atMost(Duration.ofSeconds(2))
197+
.untilAsserted(() -> assertHasAlert("success", "Success!"));
198+
//@formatter:on;
199+
200+
var passkeyRows = this.driver.findElements(new By.ByCssSelector("table > tbody > tr"));
201+
assertThat(passkeyRows).hasSize(1)
202+
.first()
203+
.extracting((row) -> row.findElement(new By.ByCssSelector("td:first-child")))
204+
.extracting(WebElement::getText)
205+
.isEqualTo("Virtual authenticator");
206+
207+
// Step 3: log out
208+
logout();
209+
210+
// Step 4: log in with the virtual authenticator
211+
this.driver.get(baseUrl + "/webauthn/register");
212+
this.driver.findElement(new By.ById("passkey-signin")).click();
213+
Awaitility.await()
214+
.atMost(Duration.ofSeconds(1))
215+
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue"));
216+
}
217+
218+
private void login() {
219+
this.driver.get(baseUrl);
220+
this.driver.findElement(new By.ById("username")).sendKeys(USERNAME);
221+
this.driver.findElement(new By.ById(PASSWORD)).sendKeys(PASSWORD);
222+
this.driver.findElement(new By.ByCssSelector("form > button[type=\"submit\"]")).click();
223+
}
224+
225+
private void logout() {
226+
this.driver.get(baseUrl + "/logout");
227+
this.driver.findElement(new By.ByCssSelector("button")).click();
228+
Awaitility.await()
229+
.atMost(Duration.ofSeconds(1))
230+
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout"));
231+
}
232+
233+
private void assertHasAlert(String alertType, String alertMessage) {
234+
var alert = this.driver.findElement(new By.ById(alertType));
235+
assertThat(alert.isDisplayed())
236+
.withFailMessage(
237+
() -> alertType + " alert was not displayed. Full page source:\n\n" + this.driver.getPageSource())
238+
.isTrue();
239+
240+
assertThat(alert.getText()).startsWith(alertMessage);
241+
}
242+
243+
/**
244+
* Add a virtual authenticator.
245+
* <p>
246+
* Note that Selenium docs for {@link HasCdp} strongly encourage to use
247+
* {@link HasDevTools} instead. However, devtools require more dependencies and
248+
* boilerplate, notably to sync the Devtools-CDP version with the current browser
249+
* version, whereas CDP runs out of the box.
250+
* <p>
251+
* @param userIsVerified whether the authenticator simulates user verification.
252+
* Setting it to false will make the ceremonies fail.
253+
* @see <a href=
254+
* "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/</a>
255+
*/
256+
private void createVirtualAuthenticator(boolean userIsVerified) {
257+
var cdpDriver = (HasCdp) this.driver;
258+
cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false));
259+
// this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions());
260+
//@formatter:off
261+
var commandResult = cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
262+
Map.of(
263+
"options",
264+
Map.of(
265+
"protocol", "ctap2",
266+
"transport", "usb",
267+
"hasUserVerification", true,
268+
"hasResidentKey", true,
269+
"isUserVerified", userIsVerified,
270+
"automaticPresenceSimulation", true
271+
)
272+
));
273+
//@formatter:on
274+
}
275+
276+
/**
277+
* The configuration for WebAuthN tests. This configuration embeds a {@link Server},
278+
* because the WebAuthN configurer needs to know the port on which the server is
279+
* running to configure {@link WebAuthnConfigurer#allowedOrigins(String...)}. This
280+
* requires starting the server before configuring the Security Filter chain.
281+
*/
282+
@Configuration
283+
@EnableWebSecurity
284+
static class WebAuthnConfiguration {
285+
286+
@Bean
287+
UserDetailsService userDetailsService() {
288+
return new InMemoryUserDetailsManager(
289+
User.withDefaultPasswordEncoder().username(USERNAME).password(PASSWORD).build());
290+
}
291+
292+
@Bean
293+
SecurityFilterChain securityFilterChain(HttpSecurity http, Server server) throws Exception {
294+
return http.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
295+
.formLogin(Customizer.withDefaults())
296+
.webAuthn((passkeys) -> passkeys.rpId("localhost")
297+
.rpName("Spring Security WebAuthN tests")
298+
.allowedOrigins("http://localhost:" + getServerPort(server)))
299+
.build();
300+
}
301+
302+
@Bean
303+
Server server() throws Exception {
304+
ServletContextHandler servlet = new ServletContextHandler(ServletContextHandler.SESSIONS);
305+
Server server = new Server(0);
306+
server.setHandler(servlet);
307+
server.start();
308+
return server;
309+
}
310+
311+
/**
312+
* Ensure the server is stopped whenever the application context closes.
313+
* @param server -
314+
* @return -
315+
*/
316+
@Bean
317+
ApplicationListener<ContextClosedEvent> onContextStopped(Server server) {
318+
return (event) -> {
319+
try {
320+
server.stop();
321+
}
322+
catch (Exception ignored) {
323+
}
324+
};
325+
}
326+
327+
@Autowired
328+
void addSecurityFilterChainToServlet(Server server, SecurityFilterChain filterChain) {
329+
FilterChainProxy filterChainProxy = new FilterChainProxy(filterChain);
330+
((ServletContextHandler) server.getHandler()).addFilter(new FilterHolder(filterChainProxy), "/*",
331+
EnumSet.allOf(DispatcherType.class));
332+
}
333+
334+
private static int getServerPort(Server server) {
335+
return ((ServerConnector) server.getConnectors()[0]).getLocalPort();
336+
}
337+
338+
}
339+
340+
}

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ org-hidetake-gradle-ssh-plugin = "org.hidetake:gradle-ssh-plugin:2.10.1"
106106
org-jfrog-buildinfo-build-info-extractor-gradle = "org.jfrog.buildinfo:build-info-extractor-gradle:4.33.22"
107107
org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8.0.1969"
108108
org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
109+
org-awaitility-awaitility = "org.awaitility:awaitility:4.2.2"
109110

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

0 commit comments

Comments
 (0)