-
Notifications
You must be signed in to change notification settings - Fork 5.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- 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.
- Loading branch information
Showing
2 changed files
with
350 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
348 changes: 348 additions & 0 deletions
348
...va/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
|
||
} | ||
|
||
} |