From baf815f39088f4a0cb3e66f7ac537b64b49add2d Mon Sep 17 00:00:00 2001 From: Ben Efrati Date: Thu, 3 Apr 2025 22:39:14 +0300 Subject: [PATCH] Allow Custom PublicKeyCredentialRequestOptionsRepository in WebAuthnConfigurer Closes gh-16874 --- .../web/configurers/WebAuthnConfigurer.java | 35 ++++- .../configurers/WebAuthnConfigurerTests.java | 137 ++++++++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) 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 104a0be328e..87ec7230637 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 @@ -34,6 +34,7 @@ import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity; import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter; +import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsRepository; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationProvider; import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository; @@ -67,6 +68,8 @@ public class WebAuthnConfigurer> private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository; + private HttpMessageConverter converter; /** @@ -144,11 +147,21 @@ public WebAuthnConfigurer creationOptionsRepository( return this; } + /** + * Sets PublicKeyCredentialRequestOptionsRepository. + * @param requestOptionsRepository the requestOptionsRepository + * @return the {@link WebAuthnConfigurer} for further customization + */ + public WebAuthnConfigurer requestOptionsRepository( + PublicKeyCredentialRequestOptionsRepository requestOptionsRepository) { + this.requestOptionsRepository = requestOptionsRepository; + return this; + } + @Override public void configure(H http) throws Exception { - UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> { - throw new IllegalStateException("Missing UserDetailsService Bean"); - }); + UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class) + .orElseThrow(() -> new IllegalStateException("Missing UserDetailsService Bean")); PublicKeyCredentialUserEntityRepository userEntities = getSharedOrBean(http, PublicKeyCredentialUserEntityRepository.class) .orElse(userEntityRepository()); @@ -156,6 +169,7 @@ public void configure(H http) throws Exception { .orElse(userCredentialRepository()); WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials); PublicKeyCredentialCreationOptionsRepository creationOptionsRepository = creationOptionsRepository(); + PublicKeyCredentialRequestOptionsRepository requestOptionsRepository = requestOptionsRepository(); WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter(); webAuthnAuthnFilter.setAuthenticationManager( new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService))); @@ -163,10 +177,15 @@ public void configure(H http) throws Exception { rpOperations); PublicKeyCredentialCreationOptionsFilter creationOptionsFilter = new PublicKeyCredentialCreationOptionsFilter( rpOperations); + PublicKeyCredentialRequestOptionsFilter credentialRequestOptionsFilter = new PublicKeyCredentialRequestOptionsFilter(rpOperations); if (creationOptionsRepository != null) { webAuthnRegistrationFilter.setCreationOptionsRepository(creationOptionsRepository); creationOptionsFilter.setCreationOptionsRepository(creationOptionsRepository); } + if (requestOptionsRepository != null) { + credentialRequestOptionsFilter.setRequestOptionsRepository(requestOptionsRepository); + webAuthnAuthnFilter.setRequestOptionsRepository(requestOptionsRepository); + } if (this.converter != null) { webAuthnRegistrationFilter.setConverter(this.converter); creationOptionsFilter.setConverter(this.converter); @@ -174,7 +193,7 @@ public void configure(H http) throws Exception { http.addFilterBefore(webAuthnAuthnFilter, BasicAuthenticationFilter.class); http.addFilterAfter(webAuthnRegistrationFilter, AuthorizationFilter.class); http.addFilterBefore(creationOptionsFilter, AuthorizationFilter.class); - http.addFilterBefore(new PublicKeyCredentialRequestOptionsFilter(rpOperations), AuthorizationFilter.class); + http.addFilterBefore(credentialRequestOptionsFilter, AuthorizationFilter.class); DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http .getSharedObject(DefaultLoginPageGeneratingFilter.class); @@ -208,6 +227,14 @@ private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository() return context.getBeanProvider(PublicKeyCredentialCreationOptionsRepository.class).getIfUnique(); } + private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository() { + if (this.requestOptionsRepository != null) { + return this.requestOptionsRepository; + } + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + return context.getBeanProvider(PublicKeyCredentialRequestOptionsRepository.class).getIfUnique(); + } + private Optional getSharedOrBean(H http, Class type) { C shared = http.getSharedObject(type); return Optional.ofNullable(shared).or(() -> getBeanOrNull(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 index 201fbc4553c..8165ae710a1 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 @@ -33,15 +33,22 @@ 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.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextImpl; +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.security.web.authentication.ui.DefaultResourcesFilter; import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialRequestOptions; +import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.authentication.HttpSessionPublicKeyCredentialRequestOptionsRepository; import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations; import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository; import org.springframework.test.web.servlet.MockMvc; @@ -67,6 +74,21 @@ public class WebAuthnConfigurerTests { public final SpringTestContext spring = new SpringTestContext(this); + private static final String WEBAUTHN_LOGIN_BODY = """ + { + "id": "dYF7EGnRFFIXkpXi9XU2wg", + "rawId": "dYF7EGnRFFIXkpXi9XU2wg", + "response": { + "authenticatorData": "y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRFVsRzRDbU9naWhKMG1vdXZFcE9HdUk0ZVJ6MGRRWmxUQmFtbjdHQ1FTNCIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0", + "signature": "MEYCIQCW2BcUkRCAXDmGxwMi78jknenZ7_amWrUJEYoTkweldAIhAMD0EMp1rw2GfwhdrsFIeDsL7tfOXVPwOtfqJntjAo4z", + "userHandle": "Q3_0Xd64_HW0BlKRAJnVagJTpLKLgARCj8zjugpRnVo" + }, + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + } + """; + @Autowired MockMvc mvc; @@ -182,6 +204,53 @@ public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepositoryBe .andExpect(request().sessionAttribute(attrName, options)); } + @Test + public void webauthnWhenConfiguredPublicKeyCredentialRequestOptionsRepositoryBeanPresent() throws Exception { + PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigCredentialRequestOptionsRepositoryFromBean.rpOperations = rpOperations; + given(rpOperations.createCredentialRequestOptions(any())).willReturn(options); + String attrName = "attrName"; + HttpSessionPublicKeyCredentialRequestOptionsRepository requestOptionsRepository = new HttpSessionPublicKeyCredentialRequestOptionsRepository(); + requestOptionsRepository.setAttrName(attrName); + ConfigCredentialRequestOptionsRepositoryFromBean.requestOptionsRepository = requestOptionsRepository; + this.spring.register(ConfigCredentialRequestOptionsRepositoryFromBean.class).autowire(); + this.mvc.perform(post("/webauthn/authenticate/options")) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute(attrName, options)); + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + given(rpOperations.authenticate(any())).willReturn(userEntity); + this.mvc.perform(post("/login/webauthn") + .content(WEBAUTHN_LOGIN_BODY) + .sessionAttr(attrName, options)) + .andExpect(status().isOk()); + } + + @Test + public void webauthnWhenConfiguredPublicKeyCredentialRequestOptionsRepository() throws Exception { + PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions + .create() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigCredentialRequestOptionsRepository.rpOperations = rpOperations; + given(rpOperations.createCredentialRequestOptions(any())).willReturn(options); + String attrName = "attrName"; + HttpSessionPublicKeyCredentialRequestOptionsRepository requestOptionsRepository = new HttpSessionPublicKeyCredentialRequestOptionsRepository(); + requestOptionsRepository.setAttrName(attrName); + ConfigCredentialRequestOptionsRepository.requestOptionsRepository = requestOptionsRepository; + this.spring.register(ConfigCredentialRequestOptionsRepository.class).autowire(); + this.mvc.perform(post("/webauthn/authenticate/options")) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute(attrName, options)); + PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build(); + given(rpOperations.authenticate(any())).willReturn(userEntity); + this.mvc.perform(post("/login/webauthn") + .content(WEBAUTHN_LOGIN_BODY) + .sessionAttr(attrName, options)) + .andExpect(status().isOk()); + } + @Test public void webauthnWhenConfiguredMessageConverter() throws Exception { TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); @@ -264,6 +333,74 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { } + @Configuration + @EnableWebSecurity + static class ConfigCredentialRequestOptionsRepositoryFromBean { + + private static HttpSessionPublicKeyCredentialRequestOptionsRepository requestOptionsRepository; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigCredentialRequestOptionsRepositoryFromBean.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager(); + userDetailsService.createUser(User.builder().username("user") + .password("{noop}password") + .authorities(AuthorityUtils.createAuthorityList("ROLE_USER")) + .build()); + return userDetailsService; + } + + @Bean + HttpSessionPublicKeyCredentialRequestOptionsRepository credentialRequestOptionsRepository() { + return ConfigCredentialRequestOptionsRepositoryFromBean.requestOptionsRepository; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable).webAuthn(Customizer.withDefaults()).build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ConfigCredentialRequestOptionsRepository { + + private static HttpSessionPublicKeyCredentialRequestOptionsRepository requestOptionsRepository; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigCredentialRequestOptionsRepository.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager(); + userDetailsService.createUser(User.builder().username("user") + .password("{noop}password") + .authorities(AuthorityUtils.createAuthorityList("ROLE_USER")) + .build()); + return userDetailsService; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable) + .webAuthn((c) -> c.requestOptionsRepository(requestOptionsRepository)) + .build(); + } + + } + + @Configuration @EnableWebSecurity static class ConfigMessageConverter {