Skip to content

Commit 4d3fdda

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 f689257 commit 4d3fdda

File tree

3 files changed

+334
-0
lines changed

3 files changed

+334
-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
}
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
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+
* @author Daniel Garnier-Moiroux
66+
*/
67+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
68+
@ExtendWith(SpringExtension.class)
69+
class WebAuthnConfigurerTests {
70+
71+
private static String baseUrl;
72+
73+
private static ChromeDriverService driverService;
74+
75+
private RemoteWebDriver driver;
76+
77+
private static final String USERNAME = "user";
78+
79+
private static final String PASSWORD = "password";
80+
81+
@BeforeAll
82+
static void startChromeDriverService() throws Exception {
83+
driverService = new ChromeDriverService.Builder().usingAnyFreePort().build();
84+
driverService.start();
85+
}
86+
87+
@AfterAll
88+
static void stopChromeDriverService() {
89+
driverService.stop();
90+
}
91+
92+
@BeforeAll
93+
static void setupBaseUrl(@Autowired Server server) throws Exception {
94+
baseUrl = "http://localhost:" + ((ServerConnector) server.getConnectors()[0]).getLocalPort();
95+
}
96+
97+
@AfterAll
98+
static void stopServer(@Autowired Server server) throws Exception {
99+
// Close the server early and don't wait for the full context to be closed, as it
100+
// may take some time to get evicted from the ContextCache.
101+
server.stop();
102+
}
103+
104+
@BeforeEach
105+
void setupDriver() {
106+
ChromeOptions options = new ChromeOptions();
107+
options.addArguments("--headless=new");
108+
var baseDriver = new RemoteWebDriver(driverService.getUrl(), options);
109+
// Enable dev tools
110+
this.driver = (RemoteWebDriver) new Augmenter().augment(baseDriver);
111+
this.driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(1));
112+
}
113+
114+
@AfterEach
115+
void cleanupDriver() {
116+
this.driver.quit();
117+
}
118+
119+
@Test
120+
@Order(1)
121+
void loginWhenNoValidAuthenticatorCredentialsThenRejects() {
122+
createVirtualAuthenticator(true);
123+
this.driver.get(baseUrl);
124+
this.driver.findElement(new By.ById("passkey-signin")).click();
125+
Awaitility.await()
126+
.atMost(Duration.ofSeconds(1))
127+
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error"));
128+
}
129+
130+
@Test
131+
@Order(2)
132+
void registerWhenNoLabelThenRejects() {
133+
login();
134+
135+
this.driver.get(baseUrl + "/webauthn/register");
136+
137+
this.driver.findElement(new By.ById("register")).click();
138+
WebElement errorPopup = this.driver.findElement(new By.ById("error"));
139+
140+
assertThat(errorPopup.isDisplayed()).isTrue();
141+
assertThat(errorPopup.getText()).isEqualTo("Error: Passkey Label is required");
142+
}
143+
144+
@Test
145+
@Order(3)
146+
void registerWhenAuthenticatorNoUserVerificationThenRejects() {
147+
createVirtualAuthenticator(false);
148+
login();
149+
this.driver.get(baseUrl + "/webauthn/register");
150+
this.driver.findElement(new By.ById("label")).sendKeys("Virtual authenticator");
151+
this.driver.findElement(new By.ById("register")).click();
152+
153+
Awaitility.await()
154+
.atMost(Duration.ofSeconds(2))
155+
.pollInterval(Duration.ofMillis(100))
156+
.untilAsserted(() -> assertHasAlert("error",
157+
"Registration failed. Call to navigator.credentials.create failed: The operation either timed out or was not allowed."));
158+
}
159+
160+
/**
161+
* Test in 4 steps to verify the end-to-end flow of registering an authenticator and
162+
* using it to register.
163+
* <ul>
164+
* <li>Step 1: Log in with username / password</li>
165+
* <li>Step 2: Register a credential from the virtual authenticator</li>
166+
* <li>Step 3: Log out</li>
167+
* <li>Step 4: Log in with the authenticator</li>
168+
* </ul>
169+
*/
170+
@Test
171+
@Order(4)
172+
void loginWhenAuthenticatorRegisteredThenSuccess() {
173+
// Setup
174+
createVirtualAuthenticator(true);
175+
176+
// Step 1: log in with username / password
177+
login();
178+
179+
// Step 2: register a credential from the virtual authenticator
180+
this.driver.get(baseUrl + "/webauthn/register");
181+
this.driver.findElement(new By.ById("label")).sendKeys("Virtual authenticator");
182+
this.driver.findElement(new By.ById("register")).click();
183+
184+
//@formatter:off
185+
Awaitility.await()
186+
.atMost(Duration.ofSeconds(2))
187+
.untilAsserted(() -> assertHasAlert("success", "Success!"));
188+
//@formatter:on;
189+
190+
var passkeyRows = this.driver.findElements(new By.ByCssSelector("table > tbody > tr"));
191+
assertThat(passkeyRows).hasSize(1)
192+
.first()
193+
.extracting((row) -> row.findElement(new By.ByCssSelector("td:first-child")))
194+
.extracting(WebElement::getText)
195+
.isEqualTo("Virtual authenticator");
196+
197+
// Step 3: log out
198+
logout();
199+
200+
// Step 4: log in with the virtual authenticator
201+
this.driver.get(baseUrl + "/webauthn/register");
202+
this.driver.findElement(new By.ById("passkey-signin")).click();
203+
Awaitility.await()
204+
.atMost(Duration.ofSeconds(1))
205+
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue"));
206+
}
207+
208+
private void login() {
209+
this.driver.get(baseUrl);
210+
this.driver.findElement(new By.ById("username")).sendKeys(USERNAME);
211+
this.driver.findElement(new By.ById(PASSWORD)).sendKeys(PASSWORD);
212+
this.driver.findElement(new By.ByCssSelector("form > button[type=\"submit\"]")).click();
213+
}
214+
215+
private void logout() {
216+
this.driver.get(baseUrl + "/logout");
217+
this.driver.findElement(new By.ByCssSelector("button")).click();
218+
Awaitility.await()
219+
.atMost(Duration.ofSeconds(1))
220+
.untilAsserted(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout"));
221+
}
222+
223+
private void assertHasAlert(String alertType, String alertMessage) {
224+
var alert = this.driver.findElement(new By.ById(alertType));
225+
assertThat(alert.isDisplayed())
226+
.withFailMessage(
227+
() -> alertType + " alert was not displayed. Full page source:\n\n" + this.driver.getPageSource())
228+
.isTrue();
229+
230+
assertThat(alert.getText()).startsWith(alertMessage);
231+
}
232+
233+
/**
234+
* Add a virtual authenticator.
235+
* <p>
236+
* Note that Selenium docs for {@link HasCdp} strongly encourage to use
237+
* {@link HasDevTools} instead. However, devtools require more dependencies and
238+
* boilerplate, notably to sync the Devtools-CDP version with the current browser
239+
* version, whereas CDP runs out of the box.
240+
* <p>
241+
* @param userIsVerified whether the authenticator simulates user verification.
242+
* Setting it to false will make the ceremonies fail.
243+
* @see <a href=
244+
* "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/</a>
245+
*/
246+
private void createVirtualAuthenticator(boolean userIsVerified) {
247+
var cdpDriver = (HasCdp) this.driver;
248+
cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false));
249+
// this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions());
250+
//@formatter:off
251+
var commandResult = cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
252+
Map.of(
253+
"options",
254+
Map.of(
255+
"protocol", "ctap2",
256+
"transport", "usb",
257+
"hasUserVerification", true,
258+
"hasResidentKey", true,
259+
"isUserVerified", userIsVerified,
260+
"automaticPresenceSimulation", true
261+
)
262+
));
263+
//@formatter:on
264+
}
265+
266+
/**
267+
* The configuration for WebAuthN tests. This configuration embeds a {@link Server},
268+
* because the WebAuthN configurer needs to know the port on which the server is
269+
* running to configure {@link WebAuthnConfigurer#allowedOrigins(String...)}. This
270+
* requires starting the server before configuring the Security Filter chain.
271+
*/
272+
@Configuration
273+
@EnableWebSecurity
274+
static class WebAuthnConfiguration {
275+
276+
@Bean
277+
UserDetailsService userDetailsService() {
278+
return new InMemoryUserDetailsManager(
279+
User.withDefaultPasswordEncoder().username(USERNAME).password(PASSWORD).build());
280+
}
281+
282+
@Bean
283+
SecurityFilterChain securityFilterChain(HttpSecurity http, Server server) throws Exception {
284+
return http.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
285+
.formLogin(Customizer.withDefaults())
286+
.webAuthn((passkeys) -> passkeys.rpId("localhost")
287+
.rpName("Spring Security WebAuthN tests")
288+
.allowedOrigins("http://localhost:" + getServerPort(server)))
289+
.build();
290+
}
291+
292+
@Bean
293+
Server server() throws Exception {
294+
ServletContextHandler servlet = new ServletContextHandler(ServletContextHandler.SESSIONS);
295+
Server server = new Server(0);
296+
server.setHandler(servlet);
297+
server.start();
298+
return server;
299+
}
300+
301+
/**
302+
* Ensure the server is stopped whenever the application context closes.
303+
* @param server -
304+
* @return -
305+
*/
306+
@Bean
307+
ApplicationListener<ContextClosedEvent> onContextStopped(Server server) {
308+
return (event) -> {
309+
try {
310+
server.stop();
311+
}
312+
catch (Exception ignored) {
313+
}
314+
};
315+
}
316+
317+
@Autowired
318+
void addSecurityFilterChainToServlet(Server server, SecurityFilterChain filterChain) {
319+
FilterChainProxy filterChainProxy = new FilterChainProxy(filterChain);
320+
((ServletContextHandler) server.getHandler()).addFilter(new FilterHolder(filterChainProxy), "/*",
321+
EnumSet.allOf(DispatcherType.class));
322+
}
323+
324+
private static int getServerPort(Server server) {
325+
return ((ServerConnector) server.getConnectors()[0]).getLocalPort();
326+
}
327+
328+
}
329+
330+
}

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)