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 74031314325..1a955e523da 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 @@ -16,7 +16,6 @@ package org.springframework.security.config.annotation.web.configurers; -import java.lang.reflect.Constructor; import java.util.HashSet; import java.util.Map; import java.util.Optional; @@ -24,9 +23,6 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.core.userdetails.UserDetailsService; @@ -35,8 +31,6 @@ import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity; import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; @@ -51,8 +45,6 @@ import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsFilter; import org.springframework.security.web.webauthn.registration.WebAuthnRegistrationFilter; -import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; - /** * Configures WebAuthn for Spring Security applications * @@ -69,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 @@ -110,6 +104,18 @@ public WebAuthnConfigurer allowedOrigins(Set allowedOrigins) { 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(() -> { @@ -127,30 +133,30 @@ public void configure(H http) throws Exception { 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) { - ClassPathResource webauthn = new ClassPathResource( - "org/springframework/security/spring-security-webauthn.js"); - AntPathRequestMatcher matcher = antMatcher(HttpMethod.GET, "/login/webauthn.js"); - - Constructor constructor = DefaultResourcesFilter.class - .getDeclaredConstructor(RequestMatcher.class, ClassPathResource.class, MediaType.class); - constructor.setAccessible(true); - DefaultResourcesFilter resourcesFilter = constructor.newInstance(matcher, webauthn, - MediaType.parseMediaType("text/javascript")); - http.addFilter(resourcesFilter); - DefaultLoginPageGeneratingFilter loginGeneratingFilter = http - .getSharedObject(DefaultLoginPageGeneratingFilter.class); - loginGeneratingFilter.setPasskeysEnabled(true); - loginGeneratingFilter.setResolveHeaders((request) -> { + 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()); + } } private Optional getSharedOrBean(H http, Class type) { 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 new file mode 100644 index 00000000000..a90c43f3122 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java @@ -0,0 +1,197 @@ +/* + * 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.web.configurers; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.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; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Daniel Garnier-Moiroux + */ +@ExtendWith(SpringTestContextExtension.class) +public class WebAuthnConfigurerTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mvc; + + @Test + public void webauthnWhenConfiguredConfiguredThenServesJavascript() throws Exception { + this.spring.register(DefaultWebauthnConfiguration.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 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 { + + @Bean + UserDetailsService userDetailsService() { + 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(); + } + + } + + @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(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java index 9cee728a05a..c2c80f19bd4 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java @@ -94,4 +94,21 @@ public static DefaultResourcesFilter css() { new MediaType("text", "css", StandardCharsets.UTF_8)); } + /** + * Create an instance of {@link DefaultResourcesFilter} serving Spring Security's + * default webauthn javascript. + *

+ * The created {@link DefaultResourcesFilter} matches requests + * {@code HTTP GET /login/webauthn.js}, and returns the default webauthn javascript at + * {@code org/springframework/security/spring-security-webauthn.js} with content-type + * {@code text/javascript;charset=UTF-8}. This file is generated in the + * {@code spring-security-javascript} project. + * @return - + */ + public static DefaultResourcesFilter webauthn() { + return new DefaultResourcesFilter(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/login/webauthn.js"), + new ClassPathResource("org/springframework/security/spring-security-webauthn.js"), + new MediaType("text", "javascript", StandardCharsets.UTF_8)); + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java index fb6d75c3a14..e7d0eb2b230 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java @@ -16,6 +16,7 @@ package org.springframework.security.web.authentication.ui; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; @@ -33,27 +34,64 @@ */ public class DefaultResourcesFilterTests { - private final DefaultResourcesFilter filter = DefaultResourcesFilter.css(); + @Nested + class CssFilter { - private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object()).addFilters(this.filter).build(); + private final DefaultResourcesFilter cssFilter = DefaultResourcesFilter.css(); - @Test - public void doFilterThenRender() throws Exception { - this.mockMvc.perform(get("/default-ui.css")) - .andExpect(status().isOk()) - .andExpect(content().contentType("text/css;charset=UTF-8")) - .andExpect(content().string(containsString("body {"))); - } + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object()) + .addFilters(this.cssFilter) + .build(); + + @Test + void doFilterThenRender() throws Exception { + this.mockMvc.perform(get("/default-ui.css")) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/css;charset=UTF-8")) + .andExpect(content().string(containsString("body {"))); + } + + @Test + void doFilterWhenPathDoesNotMatchThenCallsThrough() throws Exception { + this.mockMvc.perform(get("/does-not-match")).andExpect(status().isNotFound()); + } + + @Test + void toStringPrintsPathAndResource() { + assertThat(this.cssFilter.toString()).isEqualTo( + "DefaultResourcesFilter [matcher=Ant [pattern='/default-ui.css', GET], resource=org/springframework/security/default-ui.css]"); + } - @Test - public void doFilterWhenPathDoesNotMatchThenCallsThrough() throws Exception { - this.mockMvc.perform(get("/does-not-match")).andExpect(status().isNotFound()); } - @Test - void toStringPrintsPathAndResource() { - assertThat(this.filter.toString()).isEqualTo( - "DefaultResourcesFilter [matcher=Ant [pattern='/default-ui.css', GET], resource=org/springframework/security/default-ui.css]"); + @Nested + class WebAuthnFilter { + + private final DefaultResourcesFilter webauthnFilter = DefaultResourcesFilter.webauthn(); + + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object()) + .addFilters(this.webauthnFilter) + .build(); + + @Test + void doFilterThenRender() throws Exception { + this.mockMvc.perform(get("/login/webauthn.js")) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/javascript;charset=UTF-8")) + .andExpect(content().string(containsString("async function authenticate("))); + } + + @Test + void doFilterWhenPathDoesNotMatchThenCallsThrough() throws Exception { + this.mockMvc.perform(get("/does-not-match")).andExpect(status().isNotFound()); + } + + @Test + void toStringPrintsPathAndResource() { + assertThat(this.webauthnFilter.toString()).isEqualTo( + "DefaultResourcesFilter [matcher=Ant [pattern='/login/webauthn.js', GET], resource=org/springframework/security/spring-security-webauthn.js]"); + } + } }