Skip to content

Commit cc28da8

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 30c9860 commit cc28da8

File tree

2 files changed

+334
-0
lines changed

2 files changed

+334
-0
lines changed

config/spring-security-config.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ 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
125127

126128
testRuntimeOnly 'org.hsqldb:hsqldb'
127129
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
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.configurers;
18+
19+
import java.time.Duration;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.function.Supplier;
23+
24+
import org.assertj.core.api.AbstractAssert;
25+
import org.assertj.core.api.AbstractStringAssert;
26+
import org.eclipse.jetty.server.Server;
27+
import org.eclipse.jetty.server.ServerConnector;
28+
import org.eclipse.jetty.servlet.FilterHolder;
29+
import org.eclipse.jetty.servlet.ServletContextHandler;
30+
import org.junit.jupiter.api.AfterAll;
31+
import org.junit.jupiter.api.AfterEach;
32+
import org.junit.jupiter.api.BeforeAll;
33+
import org.junit.jupiter.api.BeforeEach;
34+
import org.junit.jupiter.api.Test;
35+
import org.openqa.selenium.By;
36+
import org.openqa.selenium.WebElement;
37+
import org.openqa.selenium.chrome.ChromeDriverService;
38+
import org.openqa.selenium.chrome.ChromeOptions;
39+
import org.openqa.selenium.chromium.HasCdp;
40+
import org.openqa.selenium.devtools.HasDevTools;
41+
import org.openqa.selenium.remote.Augmenter;
42+
import org.openqa.selenium.remote.RemoteWebDriver;
43+
import org.openqa.selenium.support.ui.FluentWait;
44+
45+
import org.springframework.context.annotation.Bean;
46+
import org.springframework.context.annotation.Configuration;
47+
import org.springframework.core.env.Environment;
48+
import org.springframework.mock.env.MockPropertySource;
49+
import org.springframework.security.config.Customizer;
50+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
51+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
52+
import org.springframework.security.core.userdetails.User;
53+
import org.springframework.security.core.userdetails.UserDetailsService;
54+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
55+
import org.springframework.security.web.FilterChainProxy;
56+
import org.springframework.security.web.SecurityFilterChain;
57+
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
58+
import org.springframework.web.filter.DelegatingFilterProxy;
59+
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
60+
61+
import static org.assertj.core.api.Assertions.assertThat;
62+
63+
/**
64+
* Webdriver-based tests for the WebAuthnConfigurer. This uses a full browser because
65+
* these features require Javascript and browser APIs to be available.
66+
*
67+
* @author Daniel Garnier-Moiroux
68+
*/
69+
class WebAuthnWebDriverTests {
70+
71+
private String baseUrl;
72+
73+
private static ChromeDriverService driverService;
74+
75+
private Server server;
76+
77+
private RemoteWebDriver driver;
78+
79+
private static final String USERNAME = "user";
80+
81+
private static final String PASSWORD = "password";
82+
83+
@BeforeAll
84+
static void startChromeDriverService() throws Exception {
85+
driverService = new ChromeDriverService.Builder().usingAnyFreePort().build();
86+
driverService.start();
87+
}
88+
89+
@AfterAll
90+
static void stopChromeDriverService() {
91+
driverService.stop();
92+
}
93+
94+
@BeforeEach
95+
void startServer() throws Exception {
96+
// Create the server on port 8080
97+
this.server = new Server(0);
98+
99+
// Set up the ServletContextHandler
100+
ServletContextHandler contextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
101+
contextHandler.setContextPath("/");
102+
this.server.setHandler(contextHandler);
103+
this.server.start();
104+
int serverPort = ((ServerConnector) this.server.getConnectors()[0]).getLocalPort();
105+
this.baseUrl = "http://localhost:" + serverPort;
106+
107+
// Set up Spring application context
108+
AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
109+
applicationContext.register(WebAuthnConfiguration.class);
110+
applicationContext.setServletContext(contextHandler.getServletContext());
111+
112+
// Add the server port
113+
MockPropertySource propertySource = new MockPropertySource().withProperty("server.port", serverPort);
114+
applicationContext.getEnvironment().getPropertySources().addFirst(propertySource);
115+
116+
// Register the filter chain
117+
DelegatingFilterProxy filterProxy = new DelegatingFilterProxy("securityFilterChain", applicationContext);
118+
FilterHolder filterHolder = new FilterHolder(filterProxy);
119+
contextHandler.addFilter(filterHolder, "/*", null);
120+
}
121+
122+
@AfterEach
123+
void stopServer() throws Exception {
124+
this.server.stop();
125+
}
126+
127+
@BeforeEach
128+
void setupDriver() {
129+
ChromeOptions options = new ChromeOptions();
130+
options.addArguments("--headless=new");
131+
RemoteWebDriver baseDriver = new RemoteWebDriver(driverService.getUrl(), options);
132+
// Enable dev tools
133+
this.driver = (RemoteWebDriver) new Augmenter().augment(baseDriver);
134+
this.driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(1));
135+
}
136+
137+
@AfterEach
138+
void cleanupDriver() {
139+
this.driver.quit();
140+
}
141+
142+
@Test
143+
void loginWhenNoValidAuthenticatorCredentialsThenRejects() {
144+
createVirtualAuthenticator(true);
145+
this.driver.get(this.baseUrl);
146+
this.driver.findElement(signinWithPasskeyButton()).click();
147+
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error"));
148+
}
149+
150+
@Test
151+
void registerWhenNoLabelThenRejects() {
152+
login();
153+
154+
this.driver.get(this.baseUrl + "/webauthn/register");
155+
156+
this.driver.findElement(registerPasskeyButton()).click();
157+
assertHasAlertStartingWith("error", "Error: Passkey Label is required");
158+
}
159+
160+
@Test
161+
void registerWhenAuthenticatorNoUserVerificationThenRejects() {
162+
createVirtualAuthenticator(false);
163+
login();
164+
this.driver.get(this.baseUrl + "/webauthn/register");
165+
this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator");
166+
this.driver.findElement(registerPasskeyButton()).click();
167+
168+
await(() -> assertHasAlertStartingWith("error",
169+
"Registration failed. Call to navigator.credentials.create failed:"));
170+
}
171+
172+
/**
173+
* Test in 4 steps to verify the end-to-end flow of registering an authenticator and
174+
* using it to register.
175+
* <ul>
176+
* <li>Step 1: Log in with username / password</li>
177+
* <li>Step 2: Register a credential from the virtual authenticator</li>
178+
* <li>Step 3: Log out</li>
179+
* <li>Step 4: Log in with the authenticator</li>
180+
* </ul>
181+
*/
182+
@Test
183+
void loginWhenAuthenticatorRegisteredThenSuccess() {
184+
// Setup
185+
createVirtualAuthenticator(true);
186+
187+
// Step 1: log in with username / password
188+
login();
189+
190+
// Step 2: register a credential from the virtual authenticator
191+
this.driver.get(this.baseUrl + "/webauthn/register");
192+
this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator");
193+
this.driver.findElement(registerPasskeyButton()).click();
194+
195+
await(() -> assertHasAlertStartingWith("success", "Success!"));
196+
197+
List<WebElement> passkeyRows = this.driver.findElements(passkeyTableRows());
198+
assertThat(passkeyRows).hasSize(1)
199+
.first()
200+
.extracting((row) -> row.findElement(firstCell()))
201+
.extracting(WebElement::getText)
202+
.isEqualTo("Virtual authenticator");
203+
204+
// Step 3: log out
205+
logout();
206+
207+
// Step 4: log in with the virtual authenticator
208+
this.driver.get(this.baseUrl + "/webauthn/register");
209+
this.driver.findElement(signinWithPasskeyButton()).click();
210+
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue"));
211+
}
212+
213+
/**
214+
* Add a virtual authenticator.
215+
* <p>
216+
* Note that Selenium docs for {@link HasCdp} strongly encourage to use
217+
* {@link HasDevTools} instead. However, devtools require more dependencies and
218+
* boilerplate, notably to sync the Devtools-CDP version with the current browser
219+
* version, whereas CDP runs out of the box.
220+
* <p>
221+
* @param userIsVerified whether the authenticator simulates user verification.
222+
* Setting it to false will make the ceremonies fail.
223+
* @see <a href=
224+
* "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/</a>
225+
*/
226+
private void createVirtualAuthenticator(boolean userIsVerified) {
227+
HasCdp cdpDriver = (HasCdp) this.driver;
228+
cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false));
229+
// this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions());
230+
//@formatter:off
231+
cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
232+
Map.of(
233+
"options",
234+
Map.of(
235+
"protocol", "ctap2",
236+
"transport", "usb",
237+
"hasUserVerification", true,
238+
"hasResidentKey", true,
239+
"isUserVerified", userIsVerified,
240+
"automaticPresenceSimulation", true
241+
)
242+
));
243+
//@formatter:on
244+
}
245+
246+
private void login() {
247+
this.driver.get(this.baseUrl);
248+
this.driver.findElement(new By.ById("username")).sendKeys(USERNAME);
249+
this.driver.findElement(new By.ById(PASSWORD)).sendKeys(PASSWORD);
250+
this.driver.findElement(new By.ByCssSelector("form > button[type=\"submit\"]")).click();
251+
}
252+
253+
private void logout() {
254+
this.driver.get(this.baseUrl + "/logout");
255+
this.driver.findElement(new By.ByCssSelector("button")).click();
256+
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout"));
257+
}
258+
259+
private AbstractStringAssert<?> assertHasAlertStartingWith(String alertType, String alertMessage) {
260+
WebElement alert = this.driver.findElement(new By.ById(alertType));
261+
assertThat(alert.isDisplayed())
262+
.withFailMessage(
263+
() -> alertType + " alert was not displayed. Full page source:\n\n" + this.driver.getPageSource())
264+
.isTrue();
265+
266+
return assertThat(alert.getText()).startsWith(alertMessage);
267+
}
268+
269+
/**
270+
* Await until the assertion passes. If the assertion fails, it will display the
271+
* assertion error in stdout.
272+
*/
273+
private void await(Supplier<AbstractAssert<?, ?>> assertion) {
274+
new FluentWait<>(this.driver).withTimeout(Duration.ofSeconds(2))
275+
.pollingEvery(Duration.ofMillis(100))
276+
.ignoring(AssertionError.class)
277+
.until((d) -> {
278+
assertion.get();
279+
return true;
280+
});
281+
}
282+
283+
private static By.ById passkeyLabel() {
284+
return new By.ById("label");
285+
}
286+
287+
private static By.ById registerPasskeyButton() {
288+
return new By.ById("register");
289+
}
290+
291+
private static By.ById signinWithPasskeyButton() {
292+
return new By.ById("passkey-signin");
293+
}
294+
295+
private static By.ByCssSelector passkeyTableRows() {
296+
return new By.ByCssSelector("table > tbody > tr");
297+
}
298+
299+
private static By.ByCssSelector firstCell() {
300+
return new By.ByCssSelector("td:first-child");
301+
}
302+
303+
/**
304+
* The configuration for WebAuthN tests. It accesses the Server's current port, so we
305+
* can configurer WebAuthnConfigurer#allowedOrigin
306+
*/
307+
@Configuration
308+
@EnableWebMvc
309+
@EnableWebSecurity
310+
static class WebAuthnConfiguration {
311+
312+
@Bean
313+
UserDetailsService userDetailsService() {
314+
return new InMemoryUserDetailsManager(
315+
User.withDefaultPasswordEncoder().username(USERNAME).password(PASSWORD).build());
316+
}
317+
318+
@Bean
319+
FilterChainProxy securityFilterChain(HttpSecurity http, Environment environment) throws Exception {
320+
SecurityFilterChain securityFilterChain = http
321+
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
322+
.formLogin(Customizer.withDefaults())
323+
.webAuthn((passkeys) -> passkeys.rpId("localhost")
324+
.rpName("Spring Security WebAuthN tests")
325+
.allowedOrigins("http://localhost:" + environment.getProperty("server.port")))
326+
.build();
327+
return new FilterChainProxy(securityFilterChain);
328+
}
329+
330+
}
331+
332+
}

0 commit comments

Comments
 (0)