Skip to content

Commit

Permalink
Set PublicKeyCredentialCreationOptionsRepository by DSL or Bean
Browse files Browse the repository at this point in the history
Closes gh-16396
  • Loading branch information
rwinch committed Jan 18, 2025
2 parents 4dc1dcb + 718c90d commit 683f1f4
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
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;

/**
Expand All @@ -64,6 +65,8 @@ public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>

private boolean disableDefaultRegistrationPage = false;

private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository;

private HttpMessageConverter<Object> converter;

/**
Expand Down Expand Up @@ -130,6 +133,17 @@ public WebAuthnConfigurer<H> messageConverter(HttpMessageConverter<Object> conve
return this;
}

/**
* Sets PublicKeyCredentialCreationOptionsRepository
* @param creationOptionsRepository the creationOptionsRepository
* @return the {@link WebAuthnConfigurer} for further customization
*/
public WebAuthnConfigurer<H> creationOptionsRepository(
PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) {
this.creationOptionsRepository = creationOptionsRepository;
return this;
}

@Override
public void configure(H http) throws Exception {
UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> {
Expand All @@ -141,13 +155,18 @@ public void configure(H http) throws Exception {
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)));
WebAuthnRegistrationFilter webAuthnRegistrationFilter = new WebAuthnRegistrationFilter(userCredentials,
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);
Expand Down Expand Up @@ -181,6 +200,14 @@ public void configure(H http) throws Exception {
}
}

private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository() {
if (this.creationOptionsRepository != null) {
return this.creationOptionsRepository;
}
ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class);
return context.getBeanProvider(PublicKeyCredentialCreationOptionsRepository.class).getIfUnique();
}

private <C> Optional<C> getSharedOrBean(H http, Class<C> type) {
C shared = http.getSharedObject(type);
return Optional.ofNullable(shared).or(() -> getBeanOrNull(type));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -35,13 +36,15 @@ class WebAuthnDsl {
var rpId: String? = null
var allowedOrigins: Set<String>? = null
var disableDefaultRegistrationPage: Boolean? = false
var creationOptionsRepository: PublicKeyCredentialCreationOptionsRepository? = null

internal fun get(): (WebAuthnConfigurer<HttpSecurity>) -> Unit {
return { webAuthn ->
rpName?.also { webAuthn.rpName(rpName) }
rpId?.also { webAuthn.rpId(rpId) }
allowedOrigins?.also { webAuthn.allowedOrigins(allowedOrigins) }
disableDefaultRegistrationPage?.also { webAuthn.disableDefaultRegistrationPage(disableDefaultRegistrationPage!!) }
creationOptionsRepository?.also { webAuthn.creationOptionsRepository(creationOptionsRepository) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
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;
Expand All @@ -55,6 +56,7 @@
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;

/**
Expand Down Expand Up @@ -140,6 +142,46 @@ public void webauthnWhenConfiguredAndNoDefaultRegistrationPageThenDoesNotServeJa
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");
Expand All @@ -165,6 +207,63 @@ public void webauthnWhenConfiguredMessageConverter() throws Exception {
.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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ Java::
----
@Bean
SecurityFilterChain filterChain(HttpSecurity http) {
// ...
http
// ...
.formLogin(withDefaults())
.webAuthn((webAuthn) -> webAuthn
.rpName("Spring Security Relying Party")
.rpId("example.com")
.allowedOrigins("https://example.com")
// optional properties
.creationOptionsRepository(new CustomPublicKeyCredentialCreationOptionsRepository())
);
return http.build();
}
Expand All @@ -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()
}
}
}
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/modules/ROOT/pages/whats-new.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`]
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
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
Expand Down

0 comments on commit 683f1f4

Please sign in to comment.