Vaadin24迁移后OAuth2+Keycloak认证出现内存泄漏问题排查
Vaadin 14→24迁移后Keycloak OAuth2认证的内存泄漏问题
问题背景
- 迁移场景:将基于Spring Boot、Vaadin Flow、Java 11的Vaadin 14应用升级至Vaadin 24(Java 17)
- 沿用认证方案:Keycloak+OAuth2单点登录,流程为:用户访问
www.app.com→基于证书生成JWK→重定向至Keycloak完成角色映射与登录/拦截 - 异常表现:迁移前运行正常,迁移后首个用户认证时触发内存泄漏,伴随SSL会话建立失败错误
错误日志
class=o.a.t.u.n.j.JSSESupport level=DEBUG thread=https-jsse-nio-8443-exec-8 trace= login=- - Error trying to obtain a certificate from the client javax.net.ssl.SSLPeerUnverifiedException: peer not authenticated at java.base/sun.security.ssl.SSLSessionImpl.getPeerCertificates(SSLSessionImpl.java:1034) at org.apache.tomcat.util.net.jsse.JSSESupport.getPeerCertificateChain(JSSESupport.java:107) at org.apache.coyote.AbstractProcessor.populateSslRequestAttributes(AbstractProcessor.java:815) at org.apache.coyote.AbstractProcessor.action(AbstractProcessor.java:489) at org.apache.coyote.Request.action(Request.java:523) at org.apache.catalina.connector.Request.getAttribute(Request.java:851) at org.apache.catalina.connector.Request.getAttributeNames(Request.java:925) at org.apache.catalina.connector.RequestFacade.getAttributeNames(RequestFacade.java:257) at org.springframework.web.servlet.handler.HandlerMappingIntrospector$AttributesPreservingRequest.initAttributes(HandlerMappingIntrospector.java:489) at org.springframework.web.servlet.handler.HandlerMappingIntrospector$AttributesPreservingRequest.<init>(HandlerMappingIntrospector.java:483) at org.springframework.web.servlet.handler.HandlerMappingIntrospector.setCache(HandlerMappingIntrospector.java:216) at org.springframework.web.servlet.handler.HandlerMappingIntrospector.lambda$createCacheFilter$3(HandlerMappingIntrospector.java:193) at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) at org.springframework.security.config.annotation.web.configuration.WebMvcSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebMvcSecurityConfiguration.java:230) class=o.a.t.u.n.N.handshake level=DEBUG thread=https-jsse-nio-8443-exec-6 trace=3de145a2ae27eb0c login=- - Handshake failed for client connection from IP address [0:0:0:0:0:0:0:1] and port [59728] java.io.IOException: An established connection was aborted by the software in your host machine at java.base/sun.nio.ch.SocketDispatcher.read0(Native Method) at java.base/sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:46) at java.base/sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:330) at java.base/sun.nio.ch.IOUtil.read(IOUtil.java:296) at java.base/sun.nio.ch.IOUtil.read(IOUtil.java:259) at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:417) at org.apache.tomcat.util.net.SecureNioChannel.handshakeUnwrap(SecureNioChannel.java:467) at org.apache.tomcat.util.net.SecureNioChannel.handshake(SecureNioChannel.java:214) at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1716) at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190) at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) at java.base/java.lang.Thread.run(Thread.java:842) 2024/07/08 14:49:53,547 class=o.a.t.u.n.NioEndpoint level=ERROR thread=https-jsse-nio-8443-exec-6 trace=3de145a2ae27eb0c login=- - Failed to close channel java.io.IOException: Invalid close state, will not send network data. at org.apache.tomcat.util.net.SecureNioChannel.close(SecureNioChannel.java:544) at org.apache.tomcat.util.net.SecureNioChannel.close(SecureNioChannel.java:559) at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.doClose(NioEndpoint.java:1212) at org.apache.tomcat.util.net.SocketWrapperBase.close(SocketWrapperBase.java:425) at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1748) at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190) at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) at java.base/java.lang.Thread.run(Thread.java:842)
内存泄漏现象
- 上述错误会重复出现3-4次,每次生成一个1GB大小且无法被GC回收的字节数组(经JProfiler确认)
- 应用启动堆内存约200MB,首个用户登录后直接飙升至4.4GB
- 后续用户登录仍会触发部分SSL错误,但内存不再持续泄漏
当前安全配置代码
@Configuration @EnableWebSecurity public class KeycloakSecurityConfig extends VaadinWebSecurity { private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> getAuthorizationCodeToken() { DefaultAuthorizationCodeTokenResponseClient authorizationCodeTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); OAuth2AuthorizationCodeGrantRequestEntityConverter oAuth2AuthorizationCodeGrantRequestEntityConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter(); oAuth2AuthorizationCodeGrantRequestEntityConverter.addParametersConverter(new NimbusJwtClientAuthenticationParametersConverter((cc) -> { try { Key key = keyStore.getKey(alias, keyPassword); X509Certificate xcert = (X509Certificate) temp.getCertificate(keyId); RSAKey rsaJWK = RSAKey.parse(xcert); JWK jwk = new RSAKey.Builder(rsaJWK) .keyID(keyId) .privateKey((RSAPrivateKey) key) .keyUse(KeyUse.SIGNATURE) .keyStore(temp) .build(); return jwk; } catch (Exception e) { log.error(e); } return null; })); authorizationCodeTokenResponseClient.setRequestEntityConverter(oAuth2AuthorizationCodeGrantRequestEntityConverter); return authorizationCodeTokenResponseClient; } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeHttpRequests(req -> req.requestMatchers(AntPathRequestMatcher.antMatcher("/status")).permitAll()); super.configure(http); http.headers(header -> header.addHeaderWriter(new XFrameOptionsHeaderWriter(XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))); http .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .anonymous(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .oauth2Login(login -> login .tokenEndpoint(endpoint -> endpoint.accessTokenResponseClient(getAuthorizationCodeToken())) .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig .userAuthoritiesMapper(userAuthoritiesMapper()) .oidcUserService(userRequest -> { return mapUser(userRequest); }))); } }
问题排查与修复建议
1. JWK生成逻辑的资源泄漏
- 核心问题:
RSAKey.Builder.keyStore(temp)将整个KeyStore对象绑定到JWK实例中,KeyStore本身持有大量未释放的资源(如证书文件内存映射、密钥缓存),导致这些资源无法被GC回收;同时每次认证请求都会重新读取KeyStore、生成JWK,重复创建大量重量级对象。 - 修复:移除
.keyStore(temp)配置,JWK仅需密钥、证书的核心信息(公钥、私钥、keyID、keyUse)即可;将JWK生成逻辑缓存,避免重复初始化。
2. 认证客户端的重复创建
- 核心问题:
getAuthorizationCodeToken()在每次configure调用时都会创建新的DefaultAuthorizationCodeTokenResponseClient和转换器实例,而VaadinWebSecurity的配置可能被多次触发,导致大量实例堆积占用内存。 - 修复:将
OAuth2AccessTokenResponseClient定义为Spring单例Bean,由容器统一管理。
3. 错误处理逻辑缺陷
- 核心问题:匿名转换器中捕获Exception后仅打日志返回null,会导致认证流程失败触发重试,加重内存占用和SSL握手错误。
- 修复:抛出明确的运行时异常,终止无效的认证流程,避免无意义的重试。
修改后的示例代码
@Configuration @EnableWebSecurity public class KeycloakSecurityConfig extends VaadinWebSecurity { private final KeyStore temp; private final String alias; private final char[] keyPassword; private final String keyId; private JWK cachedJwk; // 通过构造注入配置参数 public KeycloakSecurityConfig(KeyStore temp, String alias, char[] keyPassword, String keyId) { this.temp = temp; this.alias = alias; this.keyPassword = keyPassword; this.keyId = keyId; this.cachedJwk = generateJwk(); } // 预生成并缓存JWK,避免重复初始化 private JWK generateJwk() { try { Key key = temp.getKey(alias, keyPassword); X509Certificate xcert = (X509Certificate) temp.getCertificate(keyId); RSAKey rsaJWK = RSAKey.parse(xcert); return new RSAKey.Builder(rsaJWK) .keyID(keyId) .privateKey((RSAPrivateKey) key) .keyUse(KeyUse.SIGNATURE) .build(); } catch (Exception e) { throw new RuntimeException("Failed to generate authentication JWK", e); } } // 定义为单例Bean @Bean public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> authorizationCodeTokenResponseClient() { DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient(); OAuth2AuthorizationCodeGrantRequestEntityConverter converter = new OAuth2AuthorizationCodeGrantRequestEntityConverter(); converter.addParametersConverter(new NimbusJwtClientAuthenticationParametersConverter((cc) -> cachedJwk)); client.setRequestEntityConverter(converter); return client; } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeHttpRequests(req -> req.requestMatchers(AntPathRequestMatcher.antMatcher("/status")).permitAll()); super.configure(http); http.headers(header -> header.addHeaderWriter(new XFrameOptionsHeaderWriter(XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))); http .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .anonymous(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .oauth2Login(login -> login .tokenEndpoint(endpoint -> endpoint.accessTokenResponseClient(authorizationCodeTokenResponseClient())) .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig .userAuthoritiesMapper(userAuthoritiesMapper()) .oidcUserService(this::mapUser))); } // 实现角色映射与用户转换方法 private GrantedAuthoritiesMapper userAuthoritiesMapper() { // 自定义角色映射逻辑 return authorities -> authorities; } private OidcUser mapUser(OAuth2UserRequest userRequest) { // 自定义用户转换逻辑 return new DefaultOidcUser(userAuthoritiesMapper().mapAuthorities(userRequest.getOAuth2User().getAuthorities()), userRequest.getIdToken()); } }
内容的提问来源于stack exchange,提问作者Mari
相关产品推荐
相关产品推荐

