You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 12:24:59