Reputation: 732
How can I configure Spring Boot 3.4.0 to store PublicKeyCredentialRequestOptions.java in RedisSessionRepository?
I am using Spring Boot 3.4.0, Spring Security 6.4.0, and @EnableRedisHttpSession. I get this exception in server logs (n.b. full stack trace at the end) when default /login starts the authentication ceremony:
o.e.jetty.ee10.servlet.ServletChannel : /webauthn/authenticate/options
org.springframework.data.redis.serializer.SerializationException: Cannot serialize
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:97) ~[spring-data-redis-3.4.0.jar:3.4.0]
On the default generated /login page:
Sign in
with username/password succeeds. User session is persisted in Redis.Sign in with a Passkey
fails with HTTP 500. Redis can't serialize PublicKeyCredentialRequestOptions
.My understanding of WebAuthn registration or authentication is they execute two requests/responses reach.
PublicKeyCredentialRequestOptions
containing a challenge. It is persisted before returned.PublicKeyCredential
, and looks up persisted PublicKeyCredentialRequestOptions
to validate PublicKeyCredential
.Here is the full stack trace I get when clicking Sign in with a Passkey
on the default /login page. My configuration is below that.
2024-12-14T19:42:31.835-05:00 WARN 2484 --- [springs-server-authentication] [tp1933073727-77] [ ] o.e.jetty.ee10.servlet.ServletChannel : /webauthn/authenticate/options
org.springframework.data.redis.serializer.SerializationException: Cannot serialize
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:97) ~[spring-data-redis-3.4.0.jar:3.4.0]
at org.springframework.data.redis.core.AbstractOperations.rawHashValue(AbstractOperations.java:206) ~[spring-data-redis-3.4.0.jar:3.4.0]
at org.springframework.data.redis.core.DefaultHashOperations.putAll(DefaultHashOperations.java:161) ~[spring-data-redis-3.4.0.jar:3.4.0]
at org.springframework.session.data.redis.RedisSessionRepository$RedisSession.saveDelta(RedisSessionRepository.java:328) ~[spring-session-data-redis-3.4.0.jar:3.4.0]
at org.springframework.session.data.redis.RedisSessionRepository$RedisSession.save(RedisSessionRepository.java:306) ~[spring-session-data-redis-3.4.0.jar:3.4.0]
at org.springframework.session.data.redis.RedisSessionRepository.save(RedisSessionRepository.java:132) ~[spring-session-data-redis-3.4.0.jar:3.4.0]
at org.springframework.session.data.redis.RedisSessionRepository.save(RedisSessionRepository.java:45) ~[spring-session-data-redis-3.4.0.jar:3.4.0]
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.commitSession(SessionRepositoryFilter.java:229) ~[spring-session-core-3.4.0.jar:3.4.0]
at org.springframework.session.web.http.SessionRepositoryFilter.doFilterInternal(SessionRepositoryFilter.java:145) ~[spring-session-core-3.4.0.jar:3.4.0]
at org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:82) ~[spring-session-core-3.4.0.jar:3.4.0]
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:362) ~[spring-web-6.2.0.jar:6.2.0]
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:278) ~[spring-web-6.2.0.jar:6.2.0]
at org.eclipse.jetty.ee10.servlet.FilterHolder.doFilter(FilterHolder.java:205) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletHandler$Chain.doFilter(ServletHandler.java:1586) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:114) ~[spring-web-6.2.0.jar:6.2.0]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.2.0.jar:6.2.0]
at org.eclipse.jetty.ee10.servlet.FilterHolder.doFilter(FilterHolder.java:205) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletHandler$Chain.doFilter(ServletHandler.java:1586) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-6.2.0.jar:6.2.0]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.2.0.jar:6.2.0]
at org.eclipse.jetty.ee10.servlet.FilterHolder.doFilter(FilterHolder.java:205) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletHandler$Chain.doFilter(ServletHandler.java:1586) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletHandler$MappedServlet.handle(ServletHandler.java:1547) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletChannel.dispatch(ServletChannel.java:819) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletChannel.handle(ServletChannel.java:436) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.ServletHandler.handle(ServletHandler.java:464) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:575) ~[jetty-security-12.0.15.jar:12.0.15]
at org.eclipse.jetty.ee10.servlet.SessionHandler.handle(SessionHandler.java:717) ~[jetty-ee10-servlet-12.0.15.jar:12.0.15]
at org.eclipse.jetty.server.handler.ContextHandler.handle(ContextHandler.java:1060) ~[jetty-server-12.0.15.jar:12.0.15]
at org.eclipse.jetty.server.Handler$Wrapper.handle(Handler.java:740) ~[jetty-server-12.0.15.jar:12.0.15]
at org.eclipse.jetty.server.handler.EventsHandler.handle(EventsHandler.java:81) ~[jetty-server-12.0.15.jar:12.0.15]
at org.eclipse.jetty.server.Server.handle(Server.java:182) ~[jetty-server-12.0.15.jar:12.0.15]
at org.eclipse.jetty.server.internal.HttpChannelState$HandlerInvoker.run(HttpChannelState.java:662) ~[jetty-server-12.0.15.jar:12.0.15]
at org.eclipse.jetty.server.internal.HttpConnection.onFillable(HttpConnection.java:418) ~[jetty-server-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:322) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:99) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.ssl.SslConnection$SslEndPoint.onFillable(SslConnection.java:575) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.ssl.SslConnection.onFillable(SslConnection.java:390) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.ssl.SslConnection$2.succeeded(SslConnection.java:150) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:99) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.io.SelectableChannelEndPoint$1.run(SelectableChannelEndPoint.java:53) ~[jetty-io-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.runTask(AdaptiveExecutionStrategy.java:478) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.consumeTask(AdaptiveExecutionStrategy.java:441) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.tryProduce(AdaptiveExecutionStrategy.java:293) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.strategy.AdaptiveExecutionStrategy.run(AdaptiveExecutionStrategy.java:201) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.ReservedThreadExecutor$ReservedThread.run(ReservedThreadExecutor.java:311) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:979) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.doRunJob(QueuedThreadPool.java:1209) ~[jetty-util-12.0.15.jar:12.0.15]
at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.run(QueuedThreadPool.java:1164) ~[jetty-util-12.0.15.jar:12.0.15]
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]
Caused by: org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer
at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:64) ~[spring-core-6.2.0.jar:6.2.0]
at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:33) ~[spring-core-6.2.0.jar:6.2.0]
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:95) ~[spring-data-redis-3.4.0.jar:3.4.0]
... 49 common frames omitted
Caused by: java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions]
at org.springframework.core.serializer.DefaultSerializer.serialize(DefaultSerializer.java:43) ~[spring-core-6.2.0.jar:6.2.0]
at org.springframework.core.serializer.Serializer.serializeToByteArray(Serializer.java:56) ~[spring-core-6.2.0.jar:6.2.0]
at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:60) ~[spring-core-6.2.0.jar:6.2.0]
... 51 common frames omitted
Redis Http Session Repository configuration
@Configuration
@Import(RedisConfiguration.ExtraConfiguration.class)
@EnableRedisHttpSession
@Slf4j
public class RedisConfiguration {
@Autowired
private RedisProperties redisProperties;
@Bean(initMethod="start",destroyMethod="stop")
public RedisServer redisServerEmbedded(final Environment environment) throws IOException {
final String host = this.redisProperties.getHost();
final Integer port = this.redisProperties.getPort();
final RedisServer redisServer = new RedisServer(port);
if (!redisServer.isActive()) {
redisServer.start();
log.info("Started embedded redis server, host: {}, port: {}", host, port);
}
return redisServer;
}
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
final String host = this.redisProperties.getHost();
final Integer port = this.redisProperties.getPort();
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(final LettuceConnectionFactory redisConnectionFactory) {
final RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
@Configuration
public static class ExtraConfiguration {
@Autowired
private RedisSessionRepository redisSessionRepository;
@PostConstruct
public void postConstruct() {
log.info("Add CustomSessionIdGenerator to sessionRepository: {}", this.redisSessionRepository.getClass().getCanonicalName());
this.redisSessionRepository.setSessionIdGenerator(new CustomSessionIdGenerator());
}
}
}
Security Filter Chain configuration
@Configuration
@EnableAutoConfiguration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityFilterChainConfiguration {
@Primary
@Bean
public UserDetailsService webauthnUserDetailsService() {
return new InMemoryUserDetailsManager();
}
@Bean
public SecurityFilterChain securityFilterChainUserUi(HttpSecurity http) throws Exception {
http.securityMatcher("/login", "/logout", "/default-ui.css", "/login/webauthn.js", "/login/webauthn", "/webauthn/**", "/secure/**")
.authorizeHttpRequests(authz -> authz
.requestMatchers("/login", "/logout", "/default-ui.css", "/login/webauthn.js", "/login/webauthn", "/webauthn/**").permitAll()
.requestMatchers("/secure/**").authenticated()
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(form -> form
.permitAll()
.defaultSuccessUrl("/secure/home", true)
)
.webAuthn((webAuthn) -> webAuthn
.rpName("Passkeys Relying Party")
.rpId(this.serverAddress)
.allowedOrigins("https://" + this.serverAddress)
)
.logout(logout -> logout
.permitAll()
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(3)
.expiredUrl("/login?expired")
);
return http.build();
}
@Bean
public SecurityFilterChain securityFilterChainResources(HttpSecurity http) throws Exception {
http.securityMatcher("/helloworld", "/static/**", "/public/**", "/templates/**", "/META-INF/resources/**")
.authorizeHttpRequests(authz -> authz
.requestMatchers("/helloworld", "/static/**", "/public/**", "/templates/**", "/META-INF/resources/**").permitAll()
)
.csrf(AbstractHttpConfigurer::disable) // Typically disabled for stateless APIs
.sessionManagement(management -> management
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}
Upvotes: 1
Views: 166
Reputation: 732
Answering my own question since I got Jackson JSON serialization working for the register & authentication objects.
Problems:
org.springframework.security.web.webauthn.api
need to be serialized, but they lack Serialization interface, and they are final
so you can't extend them. Redis defaults to Java built-in serialization, which is incompatible without Serialization interface. I think that is a bug.I had to add 3 customizations to the ObjectMapper used by my RedisSerializer bean:
registerModules(SecurityJackson2Modules.getModules(this.getClass().getClassLoader()))
gave me a mixin for SimpleGrantedAuthorityregisterModule(new WebauthnJackson2Module())
, because https://github.com/spring-projects/spring-security/blob/fd267dfb71bfc8e1ab5bcc8270c12fbaad46fddf/core/src/main/java/org/springframework/security/jackson2/SecurityJackson2Modules.java#L76-L91 doesn't register WebauthnJackson2Module
. I think that might be an omission.addMixin
, because https://github.com/spring-projects/spring-security/blob/fd267dfb71bfc8e1ab5bcc8270c12fbaad46fddf/web/src/main/java/org/springframework/security/web/webauthn/jackson/WebauthnJackson2Module.java#L60 is missing 12 mixins needed for internal parts of the two request objects. I think they might be omissions.I initially implemented 22 of my own mixins and serialization/deserialization worked. That was before I figured out step 1 handled one required mixin, and step 2 handled nine required mixins. After that, I still needed 12 of my original 22 mixins.
Figuring this out was a lot of debugging, and digging through source code to find the pieces. I didn't find it in any documentation, so maybe this can give others an idea of how to make it work.
@Configuration
public class WebauthnJacksonMixinConfiguration {
@Qualifier("springSessionDefaultObjectMapper")
@Autowired
private ObjectMapper springSessionDefaultObjectMapper;
@PostConstruct
public void postConstruct() {
updateObjectMapper(this.springSessionDefaultObjectMapper);
}
public static void updateObjectMapper(final ObjectMapper objectMapper) {
objectMapper.registerModule(new WebauthnJackson2Module());
// objectMapper.addMixIn(Bytes.class, WebauthnBytesMixIn.class);
objectMapper.addMixIn(PublicKeyCredentialCreationOptions.class, WebauthnPublicKeyCredentialCreationOptionsMixIn.class);
objectMapper.addMixIn(ImmutablePublicKeyCredentialUserEntity.class, PublicKeyCredentialUserEntityMixIn.class);
// objectMapper.addMixIn(PublicKeyCredentialUserEntity.class, PublicKeyCredentialUserEntityMixIn.class);
objectMapper.addMixIn(PublicKeyCredentialRpEntity.class, PublicKeyCredentialRpEntityMixIn.class);
objectMapper.addMixIn(PublicKeyCredentialParameters.class, PublicKeyCredentialParametersMixIn.class);
// objectMapper.addMixIn(PublicKeyCredentialType.class, PublicKeyCredentialTypeMixIn.class);
// objectMapper.addMixIn(COSEAlgorithmIdentifier.class, COSEAlgorithmIdentifierMixIn.class);
objectMapper.addMixIn(AuthenticatorSelectionCriteria.class, AuthenticatorSelectionCriteriaMixIn.class);
// objectMapper.addMixIn(AttestationConveyancePreference.class, AttestationConveyancePreferenceMixIn.class);
// objectMapper.addMixIn(AuthenticatorAttachment.class, AuthenticatorAttachmentMixIn.class);
// objectMapper.addMixIn(ResidentKeyRequirement.class, ResidentKeyRequirementMixIn.class);
// objectMapper.addMixIn(UserVerificationRequirement.class, UserVerificationRequirementMixIn.class);
//
objectMapper.addMixIn(PublicKeyCredentialRequestOptions.class, WebauthnPublicKeyCredentialRequestOptionsMixIn.class);
objectMapper.addMixIn(ImmutableAuthenticationExtensionsClientInputs.class, AuthenticationExtensionsClientInputsMixIn.class);
objectMapper.addMixIn(AuthenticationExtensionsClientInputs.class, AuthenticationExtensionsClientInputsMixIn.class);
objectMapper.addMixIn(AuthenticationExtensionsClientInput.class, AuthenticationExtensionsClientInputMixIn.class);
objectMapper.addMixIn(PublicKeyCredentialDescriptor.class, PublicKeyCredentialDescriptorMixIn.class);
// objectMapper.addMixIn(AuthenticatorTransport.class, AuthenticatorTransportMixIn.class);
objectMapper.addMixIn(CredProtectAuthenticationExtensionsClientInput.class, CredProtectAuthenticationExtensionsClientInputMixIn.class);
objectMapper.addMixIn(CredProtect.class, CredProtectMixIn.class);
}
}
Upvotes: 1