Skip to content

Commit c8deaae

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 c8deaae

File tree

2 files changed

+337
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)