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 79164a082c..104a0be328 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 @@ -44,6 +44,7 @@ import org.springframework.security.web.webauthn.management.WebAuthnRelyingParty import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations; import org.springframework.security.web.webauthn.registration.DefaultWebAuthnRegistrationPageGeneratingFilter; import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsFilter; +import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository; import org.springframework.security.web.webauthn.registration.WebAuthnRegistrationFilter; /** @@ -64,6 +65,8 @@ public class WebAuthnConfigurer> private boolean disableDefaultRegistrationPage = false; + private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + private HttpMessageConverter converter; /** @@ -130,6 +133,17 @@ public class WebAuthnConfigurer> return this; } + /** + * Sets PublicKeyCredentialCreationOptionsRepository + * @param creationOptionsRepository the creationOptionsRepository + * @return the {@link WebAuthnConfigurer} for further customization + */ + public WebAuthnConfigurer creationOptionsRepository( + PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) { + this.creationOptionsRepository = creationOptionsRepository; + return this; + } + @Override public void configure(H http) throws Exception { UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> { @@ -141,6 +155,7 @@ public class WebAuthnConfigurer> UserCredentialRepository userCredentials = getSharedOrBean(http, UserCredentialRepository.class) .orElse(userCredentialRepository()); WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials); + PublicKeyCredentialCreationOptionsRepository creationOptionsRepository = creationOptionsRepository(); WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter(); webAuthnAuthnFilter.setAuthenticationManager( new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService))); @@ -148,6 +163,10 @@ public class WebAuthnConfigurer> rpOperations); PublicKeyCredentialCreationOptionsFilter creationOptionsFilter = new PublicKeyCredentialCreationOptionsFilter( rpOperations); + if (creationOptionsRepository != null) { + webAuthnRegistrationFilter.setCreationOptionsRepository(creationOptionsRepository); + creationOptionsFilter.setCreationOptionsRepository(creationOptionsRepository); + } if (this.converter != null) { webAuthnRegistrationFilter.setConverter(this.converter); creationOptionsFilter.setConverter(this.converter); @@ -181,6 +200,14 @@ public class WebAuthnConfigurer> } } + private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository() { + if (this.creationOptionsRepository != null) { + return this.creationOptionsRepository; + } + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + return context.getBeanProvider(PublicKeyCredentialCreationOptionsRepository.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/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt index 41518ed191..c48827c92d 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt @@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.web import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer +import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository /** * A Kotlin DSL to configure [HttpSecurity] webauthn using idiomatic Kotlin code. @@ -35,6 +36,7 @@ class WebAuthnDsl { var rpId: String? = null var allowedOrigins: Set? = null var disableDefaultRegistrationPage: Boolean? = false + var creationOptionsRepository: PublicKeyCredentialCreationOptionsRepository? = null internal fun get(): (WebAuthnConfigurer) -> Unit { return { webAuthn -> @@ -42,6 +44,7 @@ class WebAuthnDsl { rpId?.also { webAuthn.rpId(rpId) } allowedOrigins?.also { webAuthn.allowedOrigins(allowedOrigins) } disableDefaultRegistrationPage?.also { webAuthn.disableDefaultRegistrationPage(disableDefaultRegistrationPage!!) } + creationOptionsRepository?.also { webAuthn.creationOptionsRepository(creationOptionsRepository) } } } } 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 f51b20a094..201fbc4553 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 @@ -43,6 +43,7 @@ import org.springframework.security.web.authentication.ui.DefaultResourcesFilter import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions; import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions; import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations; +import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; @@ -55,6 +56,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -140,6 +142,46 @@ public class WebAuthnConfigurerTests { this.mvc.perform(get("/login/webauthn.js")).andExpect(status().isNotFound()); } + @Test + public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepository() throws Exception { + TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.setContext(new SecurityContextImpl(user)); + PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions + .createPublicKeyCredentialCreationOptions() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigCredentialCreationOptionsRepository.rpOperations = rpOperations; + given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options); + String attrName = "attrName"; + HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository = new HttpSessionPublicKeyCredentialCreationOptionsRepository(); + creationOptionsRepository.setAttrName(attrName); + ConfigCredentialCreationOptionsRepository.creationOptionsRepository = creationOptionsRepository; + this.spring.register(ConfigCredentialCreationOptionsRepository.class).autowire(); + this.mvc.perform(post("/webauthn/register/options")) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute(attrName, options)); + } + + @Test + public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepositoryBeanPresent() throws Exception { + TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.setContext(new SecurityContextImpl(user)); + PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions + .createPublicKeyCredentialCreationOptions() + .build(); + WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class); + ConfigCredentialCreationOptionsRepositoryFromBean.rpOperations = rpOperations; + given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options); + String attrName = "attrName"; + HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository = new HttpSessionPublicKeyCredentialCreationOptionsRepository(); + creationOptionsRepository.setAttrName(attrName); + ConfigCredentialCreationOptionsRepositoryFromBean.creationOptionsRepository = creationOptionsRepository; + this.spring.register(ConfigCredentialCreationOptionsRepositoryFromBean.class).autowire(); + this.mvc.perform(post("/webauthn/register/options")) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute(attrName, options)); + } + @Test public void webauthnWhenConfiguredMessageConverter() throws Exception { TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER"); @@ -165,6 +207,63 @@ public class WebAuthnConfigurerTests { .andExpect(content().string(expectedBody)); } + @Configuration + @EnableWebSecurity + static class ConfigCredentialCreationOptionsRepository { + + private static HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigCredentialCreationOptionsRepository.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable) + .webAuthn((c) -> c.creationOptionsRepository(creationOptionsRepository)) + .build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ConfigCredentialCreationOptionsRepositoryFromBean { + + private static HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository; + + private static WebAuthnRelyingPartyOperations rpOperations; + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return ConfigCredentialCreationOptionsRepositoryFromBean.rpOperations; + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository() { + return ConfigCredentialCreationOptionsRepositoryFromBean.creationOptionsRepository; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable).webAuthn(Customizer.withDefaults()).build(); + } + + } + @Configuration @EnableWebSecurity static class ConfigMessageConverter { diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt index 8bdee169f8..feb580e4b9 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt @@ -30,6 +30,7 @@ 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.SecurityFilterChain +import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post @@ -58,6 +59,16 @@ class WebAuthnDslTests { } } + @Test + fun `explicit PublicKeyCredentialCreationOptionsRepository`() { + this.spring.register(ExplicitPublicKeyCredentialCreationOptionsRepositoryConfig::class.java).autowire() + + this.mockMvc.post("/test1") + .andExpect { + status { isForbidden() } + } + } + @Test fun `webauthn and formLogin configured with default registration page`() { spring.register(DefaultWebauthnConfig::class.java).autowire() @@ -128,6 +139,33 @@ class WebAuthnDslTests { } } + @Configuration + @EnableWebSecurity + open class ExplicitPublicKeyCredentialCreationOptionsRepositoryConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + webAuthn { + rpName = "Spring Security Relying Party" + rpId = "example.com" + allowedOrigins = setOf("https://example.com") + creationOptionsRepository = HttpSessionPublicKeyCredentialCreationOptionsRepository() + } + } + return http.build() + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("rod") + .password("password") + .roles("USER") + .build() + return InMemoryUserDetailsManager(userDetails) + } + } + @Configuration @EnableWebSecurity open class WebauthnConfig { diff --git a/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc b/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc index 9b0cd52356..4e3f58607d 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc @@ -60,6 +60,7 @@ Java:: ---- @Bean SecurityFilterChain filterChain(HttpSecurity http) { + // ... http // ... .formLogin(withDefaults()) @@ -67,6 +68,8 @@ SecurityFilterChain filterChain(HttpSecurity http) { .rpName("Spring Security Relying Party") .rpId("example.com") .allowedOrigins("https://example.com") + // optional properties + .creationOptionsRepository(new CustomPublicKeyCredentialCreationOptionsRepository()) ); return http.build(); } @@ -89,11 +92,14 @@ Kotlin:: ---- @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { + // ... http { webAuthn { rpName = "Spring Security Relying Party" rpId = "example.com" allowedOrigins = setOf("https://example.com") + // optional properties + creationOptionsRepository = CustomPublicKeyCredentialCreationOptionsRepository() } } } @@ -110,6 +116,36 @@ open fun userDetailsService(): UserDetailsService { ---- ====== +[[passkeys-configuration-pkccor]] +=== Custom PublicKeyCredentialCreationOptionsRepository + +The `PublicKeyCredentialCreationOptionsRepository` is used to persist the `PublicKeyCredentialCreationOptions` between requests. +The default is to persist it the `HttpSession`, but at times users may need to customize this behavior. +This can be done by setting the optional property `creationOptionsRepository` demonstrated in xref:./passkeys.adoc#passkeys-configuration[Configuration] or by exposing a `PublicKeyCredentialCreationOptionsRepository` Bean: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +CustomPublicKeyCredentialCreationOptionsRepository creationOptionsRepository() { + return new CustomPublicKeyCredentialCreationOptionsRepository(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun creationOptionsRepository(): CustomPublicKeyCredentialCreationOptionsRepository { + return CustomPublicKeyCredentialCreationOptionsRepository() +} +---- +====== + [[passkeys-register]] == Register a New Credential diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 0fc13497b5..a07394496f 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -14,3 +14,7 @@ Note that this may affect reports that operate on this key name. == OAuth * https://github.com/spring-projects/spring-security/pull/16386[gh-16386] - Enable PKCE for confidential clients using `ClientRegistration.clientSettings.requireProofKey=true` for xref:servlet/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[servlet] and xref:reactive/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[reactive] applications + +== WebAuthn + +* https://github.com/spring-projects/spring-security/pull/16396[gh-16396] - Added the ability to configure a custom xref:servlet/authentication/passkeys.adoc#passkeys-configuration-pkccor[`PublicKeyCredentialCreationOptionsRepository`] diff --git a/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java b/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java index 965408621f..0863925c8c 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java @@ -105,6 +105,17 @@ public class PublicKeyCredentialCreationOptionsFilter extends OncePerRequestFilt this.converter.write(options, MediaType.APPLICATION_JSON, new ServletServerHttpResponse(response)); } + /** + * Sets the {@link PublicKeyCredentialCreationOptionsRepository} to use. The default + * is {@link HttpSessionPublicKeyCredentialCreationOptionsRepository}. + * @param creationOptionsRepository the + * {@link PublicKeyCredentialCreationOptionsRepository} to use. Cannot be null. + */ + public void setCreationOptionsRepository(PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) { + Assert.notNull(creationOptionsRepository, "creationOptionsRepository cannot be null"); + this.repository = creationOptionsRepository; + } + /** * Set the {@link HttpMessageConverter} to read the * {@link WebAuthnRegistrationFilter.WebAuthnRegistrationRequest} and write the