如何配置 Spring Boot 3.4.0 将 PublicKeyCredentialRequestOptions.java 存储在 RedisSessionRepository 中?
我正在使用 Spring Boot 3.4.0、Spring Security 6.4.0 和 @EnableRedisHttpSession。当默认 /login 启动身份验证仪式时,我在服务器日志中收到此异常(注意最后的完整堆栈跟踪):
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]
在默认生成的/登录页面上:
Sign in
成功。用户会话保存在 Redis 中。Sign in with a Passkey
失败并返回 HTTP 500。Redis 无法序列化 PublicKeyCredentialRequestOptions
。我对WebAuthn注册或身份验证的理解是它们执行两个请求/响应到达。
PublicKeyCredentialRequestOptions
。返回前已保留。PublicKeyCredential
,并查找持久化的PublicKeyCredentialRequestOptions
以验证PublicKeyCredential
。这是我在默认/登录页面上单击
Sign in with a Passkey
时获得的完整堆栈跟踪。我的配置低于此。
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 会话存储库配置
@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());
}
}
}
安全过滤链配置
@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();
}
}
回答我自己的问题,因为我让 Jackson JSON 序列化适用于注册和身份验证对象。
我必须向我的 RedisSerializer bean 使用的 ObjectMapper 添加 3 个自定义项:
registerModules(SecurityJackson2Modules.getModules(this.getClass().getClassLoader()))
registerModule(new WebauthnJackson2Module())
,因为1没有调用它addMixin
,因为 2 缺少两个请求对象内部部分所需的 12 个 mixins我最初实现了 22 个我自己的 mixin,并且序列化/反序列化工作正常。那是在我发现步骤 1 处理了 1 个所需的 mixin,而步骤 2 处理了 9 个所需的 mixin 之前。之后,我仍然需要原来 22 个 mixins 中的 12 个。
解决这个问题需要进行大量的调试,并深入源代码来找到各个部分。我没有在任何文档中找到它,所以也许这可以让其他人了解如何使其工作。
@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);
}
}