diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java index 0969cec2f3..1a955e523d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java @@ -61,6 +61,8 @@ public class WebAuthnConfigurer> private Set allowedOrigins = new HashSet<>(); + private boolean disableDefaultRegistrationPage = false; + /** * The Relying Party id. * @param rpId the relying party id @@ -102,6 +104,18 @@ public class WebAuthnConfigurer> return this; } + /** + * Configures whether the default webauthn registration should be disabled. Setting it + * to {@code true} will prevent the configurer from registering the + * {@link DefaultWebAuthnRegistrationPageGeneratingFilter}. + * @param disable disable the default registration page if true, enable it otherwise + * @return the {@link WebAuthnConfigurer} for further customization + */ + public WebAuthnConfigurer disableDefaultRegistrationPage(boolean disable) { + this.disableDefaultRegistrationPage = disable; + return this; + } + @Override public void configure(H http) throws Exception { UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> { @@ -119,17 +133,28 @@ public class WebAuthnConfigurer> http.addFilterBefore(webAuthnAuthnFilter, BasicAuthenticationFilter.class); http.addFilterAfter(new WebAuthnRegistrationFilter(userCredentials, rpOperations), AuthorizationFilter.class); http.addFilterBefore(new PublicKeyCredentialCreationOptionsFilter(rpOperations), AuthorizationFilter.class); - http.addFilterAfter(new DefaultWebAuthnRegistrationPageGeneratingFilter(userEntities, userCredentials), - AuthorizationFilter.class); http.addFilterBefore(new PublicKeyCredentialRequestOptionsFilter(rpOperations), AuthorizationFilter.class); + DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http .getSharedObject(DefaultLoginPageGeneratingFilter.class); - if (loginPageGeneratingFilter != null) { + boolean isLoginPageEnabled = loginPageGeneratingFilter != null && loginPageGeneratingFilter.isEnabled(); + if (isLoginPageEnabled) { loginPageGeneratingFilter.setPasskeysEnabled(true); loginPageGeneratingFilter.setResolveHeaders((request) -> { CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); return Map.of(csrfToken.getHeaderName(), csrfToken.getToken()); }); + } + + if (!this.disableDefaultRegistrationPage) { + http.addFilterAfter(new DefaultWebAuthnRegistrationPageGeneratingFilter(userEntities, userCredentials), + AuthorizationFilter.class); + if (!isLoginPageEnabled) { + http.addFilter(DefaultResourcesFilter.css()); + } + } + + if (isLoginPageEnabled || !this.disableDefaultRegistrationPage) { http.addFilter(DefaultResourcesFilter.webauthn()); } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java index 6db25c4717..a90c43f312 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.web.configurers; +import java.util.List; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,9 +31,12 @@ import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; 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.security.web.authentication.ui.DefaultResourcesFilter; import org.springframework.test.web.servlet.MockMvc; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -50,7 +55,7 @@ public class WebAuthnConfigurerTests { MockMvc mvc; @Test - public void javascriptWhenWebauthnConfiguredThenServesJavascript() throws Exception { + public void webauthnWhenConfiguredConfiguredThenServesJavascript() throws Exception { this.spring.register(DefaultWebauthnConfiguration.class).autowire(); this.mvc.perform(get("/login/webauthn.js")) .andExpect(status().isOk()) @@ -58,6 +63,69 @@ public class WebAuthnConfigurerTests { .andExpect(content().string(containsString("async function authenticate("))); } + @Test + public void webauthnWhenConfiguredConfiguredThenServesCss() throws Exception { + this.spring.register(DefaultWebauthnConfiguration.class).autowire(); + this.mvc.perform(get("/default-ui.css")) + .andExpect(status().isOk()) + .andExpect(header().string("content-type", "text/css;charset=UTF-8")) + .andExpect(content().string(containsString("body {"))); + } + + @Test + public void webauthnWhenNoFormLoginAndDefaultRegistrationPageConfiguredThenServesJavascript() throws Exception { + this.spring.register(NoFormLoginAndDefaultRegistrationPageConfiguration.class).autowire(); + this.mvc.perform(get("/login/webauthn.js")) + .andExpect(status().isOk()) + .andExpect(header().string("content-type", "text/javascript;charset=UTF-8")) + .andExpect(content().string(containsString("async function authenticate("))); + } + + @Test + public void webauthnWhenNoFormLoginAndDefaultRegistrationPageConfiguredThenServesCss() throws Exception { + this.spring.register(NoFormLoginAndDefaultRegistrationPageConfiguration.class).autowire(); + this.mvc.perform(get("/default-ui.css")) + .andExpect(status().isOk()) + .andExpect(header().string("content-type", "text/css;charset=UTF-8")) + .andExpect(content().string(containsString("body {"))); + } + + @Test + public void webauthnWhenFormLoginAndDefaultRegistrationPageConfiguredThenNoDuplicateFilters() { + this.spring.register(DefaultWebauthnConfiguration.class).autowire(); + FilterChainProxy filterChain = this.spring.getContext().getBean(FilterChainProxy.class); + + List defaultResourcesFilters = filterChain.getFilterChains() + .get(0) + .getFilters() + .stream() + .filter(DefaultResourcesFilter.class::isInstance) + .map(DefaultResourcesFilter.class::cast) + .toList(); + + assertThat(defaultResourcesFilters).map(DefaultResourcesFilter::toString) + .filteredOn((filterDescription) -> filterDescription.contains("login/webauthn.js")) + .hasSize(1); + assertThat(defaultResourcesFilters).map(DefaultResourcesFilter::toString) + .filteredOn((filterDescription) -> filterDescription.contains("default-ui.css")) + .hasSize(1); + } + + @Test + public void webauthnWhenConfiguredAndFormLoginThenDoesServesJavascript() throws Exception { + this.spring.register(FormLoginAndNoDefaultRegistrationPageConfiguration.class).autowire(); + this.mvc.perform(get("/login/webauthn.js")) + .andExpect(status().isOk()) + .andExpect(header().string("content-type", "text/javascript;charset=UTF-8")) + .andExpect(content().string(containsString("async function authenticate("))); + } + + @Test + public void webauthnWhenConfiguredAndNoDefaultRegistrationPageThenDoesNotServeJavascript() throws Exception { + this.spring.register(NoDefaultRegistrationPageConfiguration.class).autowire(); + this.mvc.perform(get("/login/webauthn.js")).andExpect(status().isNotFound()); + } + @Configuration @EnableWebSecurity static class DefaultWebauthnConfiguration { @@ -67,6 +135,22 @@ public class WebAuthnConfigurerTests { return new InMemoryUserDetailsManager(); } + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.formLogin(Customizer.withDefaults()).webAuthn(Customizer.withDefaults()).build(); + } + + } + + @Configuration + @EnableWebSecurity + static class NoFormLoginAndDefaultRegistrationPageConfiguration { + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http.webAuthn(Customizer.withDefaults()).build(); @@ -74,4 +158,40 @@ public class WebAuthnConfigurerTests { } + @Configuration + @EnableWebSecurity + static class FormLoginAndNoDefaultRegistrationPageConfiguration { + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.formLogin(Customizer.withDefaults()) + .webAuthn((webauthn) -> webauthn.disableDefaultRegistrationPage(true)) + .build(); + } + + } + + @Configuration + @EnableWebSecurity + static class NoDefaultRegistrationPageConfiguration { + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.formLogin((login) -> login.loginPage("/custom-login-page")) + .webAuthn((webauthn) -> webauthn.disableDefaultRegistrationPage(true)) + .build(); + } + + } + }