Justin Cranford
Justin Cranford

Reputation: 732

How to use Spring Security 6.4.0 Passkeys with RedisHttpSessionRepository?

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:

  1. Click Sign in with username/password succeeds. User session is persisted in Redis.
  2. Click 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.

  1. Server generates PublicKeyCredentialRequestOptions containing a challenge. It is persisted before returned.
  2. Server receives 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

Answers (1)

Justin Cranford
Justin Cranford

Reputation: 732

Answering my own question since I got Jackson JSON serialization working for the register & authentication objects.

Problems:

  1. Request data classes in 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.
  2. Switching to JSON serialization had a bunch of issues.

I had to add 3 customizations to the ObjectMapper used by my RedisSerializer bean:

  1. registerModules(SecurityJackson2Modules.getModules(this.getClass().getClassLoader())) gave me a mixin for SimpleGrantedAuthority
  2. registerModule(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.
  3. 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

Related Questions