整合Nacos和Druid(password使用密文)出现新建连接被拒绝情况

问题描述

Jmeter进行服务压测时出现,或因一段时间未操作数据库断开连接后再次请求建立连接时,服务器拒绝新的连接情况。

Caused by: com.mysql.cj.exceptions.CJException: Access denied for user 'appblog'@'192.168.1.10' (using password: YES)

原因分析

初一看就是密码错误,数据库配置如下:

spring:
  datasource:
    appblog:
      type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://192.168.1.10:3306/appblog?useUnicode=true&autoReconnect=true&characterEncoding=utf-8
      username: appblog
      password: Up58k0xJr7C2kcVFTGrnxRlyPBsj7DPeKjMYUAHxWQfjighJLheMrDIlp7Xj8r5Ad1I8Q+qh5WwnCv5kFyWlTQ==
      filters: config,myfilter
      connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=3000;config.decrypt=true;config.decrypt.key=MFwwDQYJKoZIhvcNa5EBkQADSwAwSAJ6ALYHjdmAVAm79Ao3MbruNxsWM76Ifz+qaN8sOZesMKvYCJdpxLFLtmo6bNkpYkzk+OAYhXf7U8r0+dZngOy0RnMCAwEAAQ==

情况分析:

1、项目启动是正常的,启动后可以访问数据库,说明数据库连接池配置没问题
2、但为什么在压测或者隔夜之后,就出现数据库连接异常,数据库拒绝连接
3、根据异常提示,是数据库密码错误,但是奇怪为什么项目开始启动正常,压测或隔夜后,密码就错了
4、开启远程调试,先处理压测情况

切入点:Nacos刷新日志

Nacos刷新后,会打印如下日志:

2020-12-22 13:15:04.700 [ok-cloud-mall-service][INFO] [com.alibaba.druid.pool.DruidAbstractDataSource] [setPassword] [1146] : password changed

提示密码已更新,定位到源码com.alibaba.druid.pool.DruidAbstractDataSource

public abstract class DruidAbstractDataSource ... {

    ...

    public void setPassword(String password) {
        if (!StringUtils.equals(this.password, password)) {
            if (this.inited) {
                LOG.info("password changed");
            }

            this.password = password;
        }
    }

由此可知,Nacos刷新会回调DruidDataSourcesetPassword方法,并且密码发生改变,问题已经确定,就是密码错误

切入点:config过滤器

因配置文件中Druid配置的filtersconfig,定位到源码com.alibaba.druid.filter.config.ConfigFilter

init方法是数据库连接池初始化的位置,能看到对密码进行了解密操作

public class ConfigFilter extends FilterAdapter {
    private static Log LOG = LogFactory.getLog(ConfigFilter.class);
    public static final String CONFIG_FILE = "config.file";
    public static final String CONFIG_DECRYPT = "config.decrypt";
    public static final String CONFIG_KEY = "config.decrypt.key";
    public static final String SYS_PROP_CONFIG_FILE = "druid.config.file";
    public static final String SYS_PROP_CONFIG_DECRYPT = "druid.config.decrypt";
    public static final String SYS_PROP_CONFIG_KEY = "druid.config.decrypt.key";

    public ConfigFilter() {
    }

    public void init(DataSourceProxy dataSourceProxy) {
        if (!(dataSourceProxy instanceof DruidDataSource)) {
            LOG.error("ConfigLoader only support DruidDataSource");
        }

        DruidDataSource dataSource = (DruidDataSource) dataSourceProxy;
        Properties connectionProperties = dataSource.getConnectProperties();

        Properties configFileProperties = loadPropertyFromConfigFile(connectionProperties);

        // 判断是否需要解密,如果需要就进行解密行动
        boolean decrypt = isDecrypt(connectionProperties, configFileProperties);

        if (configFileProperties == null) {
            if (decrypt) {
                //密码解码操作
                decrypt(dataSource, null);
            }
            return;
        }

        if (decrypt) {
            decrypt(dataSource, configFileProperties);
        }

        try {
            DruidDataSourceFactory.config(dataSource, configFileProperties);
        } catch (SQLException e) {
            throw new IllegalArgumentException("Config DataSource error.", e);
        }
    }

往下走,随后看到passwordPlainText为密码的明文(解密后),并且解密后的明文密码,设置到了password成员变量中

public void decrypt(DruidDataSource dataSource, Properties info) {

    try {
        String encryptedPassword = null;
        if (info != null) {
            encryptedPassword = info.getProperty(DruidDataSourceFactory.PROP_PASSWORD);
        }

        if (encryptedPassword == null || encryptedPassword.length() == 0) {
            encryptedPassword = dataSource.getConnectProperties().getProperty(DruidDataSourceFactory.PROP_PASSWORD);
        }

        if (encryptedPassword == null || encryptedPassword.length() == 0) {
            encryptedPassword = dataSource.getPassword();
        }

        PublicKey publicKey = getPublicKey(dataSource.getConnectProperties(), info);

        String passwordPlainText = ConfigTools.decrypt(publicKey, encryptedPassword);

        if (info != null) {
            info.setProperty(DruidDataSourceFactory.PROP_PASSWORD, passwordPlainText);
        } else {
            dataSource.setPassword(passwordPlainText);
        }
    } catch (Exception e) {
        throw new IllegalArgumentException("Failed to decrypt.", e);
    }
}
public abstract class DruidAbstractDataSource ... {

    public void setPassword(String password) {
        if (!StringUtils.equals(this.password, password)) {
            if (this.inited) {
                LOG.info("password changed");
            }

            this.password = password;
        }
    }

又回到Nacos刷新后日志打印的地方,即password changed,我们可以猜测,解密只在Druid初始化时进行,初始化时使用解密后的明文连接数据库,Nacos刷新后新的连接则直接使用未解密的密文连接数据库,导致连接失败

场景梳理

1、开始正常解密,并使用明文,成功创建连接池
2、所以项目启动后一切正常
3、随后Nacos配置刷新,密码被重新set成了密文
4、那么在压测(当前连接数不够,需要重新创建)、或者长时间不调用(连接释放断开,需要重连)之后,需要重新创建连接,这时候就拿的是密文去请求访问数据库,自然密码不对

项目启动时,Nacos中的配置加载了一次,数据库连接池初始化成功,而后因为人为手动刷新Nacos配置或是某种原因导致Nacos配置多次读取。因为在源码中看到,初始化连接池的时候会对密文进行解密。而第二次并未进行解密,只是将密文set进了password。debugger源码发现,也只有在初始化连接池的init()方法之才会进行解密。

第二次加载配置,此时DataSource已经完成了初始化,并不会再次触发初始化,所以密文并未被解密。

特别注意,如果Nacos中有配置refreshable-dataids,会在项目启动完毕后,再加载一次配置。而就是此次加载,数据库连接池已经初始化成功,并不再进行二次初始化,所以密文并未被解密,直接set进了password。导致在连接不够或者重连时,使用的密码错误。

复现步骤

1、应用重启后修改Nacos配置

2、一直等待到出现日志

2020-12-21 11:48:30.392 [ok-cloud-mall-service][ ERROR] [118336] [nio-8701-exec-3] [bfdcee1a80aac39a] [bfdcee1a80aac39a] [true] --- [com.alibaba.druid.pool.DruidAbstractDataSource] [testConnectionInternal] [1588] : discard long time none received connection. , jdbcUrl : jdbc:mysql://192.168.1.10:3306/appblog?useUnicode=true&autoReconnect=true&characterEncoding=utf-8, jdbcUrl : jdbc:mysql://192.168.1.10:3306/appblog?useUnicode=true&autoReconnect=true&characterEncoding=utf-8, lastPacketReceivedIdleMillis : 89618

3、立即就会报错(或者重新请求建立数据库连接,如登录等)

解决方法

重写DataSource的setPassword方法并注入

参考:https://github.com/alibaba/druid/blob/master/druid-spring-boot-starter/src/main/java/com/alibaba/druid/spring/boot/autoconfigure/DruidDataSourceWrapper.java

/**
 * Druid 数据库密码只在数据库 第一次初始化时解密
 * Nacos 刷新时会将“没有解密的密文”重新赋值予DataSource
 * 数据库在重建连接时报错,无法连接
 */
@Slf4j
@Component
public class MyDruidDataSource extends DruidDataSource implements InitializingBean {
    @Autowired
    private DataSourceProperties basicProperties;

    @Override
    public void afterPropertiesSet() throws Exception {
        //if not found prefix 'spring.datasource.druid' jdbc properties ,'spring.datasource' prefix jdbc properties will be used.
        if (super.getUsername() == null) {
            super.setUsername(basicProperties.determineUsername());
        }
        if (super.getPassword() == null) {
            super.setPassword(basicProperties.determinePassword());
        }
        if (super.getUrl() == null) {
            super.setUrl(basicProperties.determineUrl());
        }
        if (super.getDriverClassName() == null) {
            super.setDriverClassName(basicProperties.getDriverClassName());
        }
    }

    @Autowired(required = false)
    public void autoAddFilters(List<Filter> filters) {
        super.filters.addAll(filters);
    }

    @Override
    public void setMaxEvictableIdleTimeMillis(long maxEvictableIdleTimeMillis) {
        try {
            super.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);
        } catch (IllegalArgumentException ignore) {
            super.maxEvictableIdleTimeMillis = maxEvictableIdleTimeMillis;
        }
    }

    @Override
    public void setPassword(String password) {
        if (!this.inited) {
            super.setPassword(password);
        } else if (filters != null) {
            try {
                super.setPassword(ConfigTools.decrypt(getPublicKey(this.getConnectProperties()), password));
            } catch (Exception e) {
                log.warn("DataSource password decrypt error.", e);
                super.setPassword(password);
            }
        }
    }

    public PublicKey getPublicKey(Properties connectionProperties) {
        String key = connectionProperties.getProperty("config.decrypt.key");

        if (StringUtils.isEmpty(key)) {
            key = System.getProperty("druid.config.decrypt.key");
        }

        return ConfigTools.getPublicKey(key);
    }
}
@Slf4j
@Component
public class MyDruidFilter extends FilterAdapter {

    @Autowired
    private MyDruidDataSource myDruidDataSource;

    @Override
    public void init(DataSourceProxy dataSourceProxy) {
        if (!(dataSourceProxy instanceof DruidDataSource)) {
            log.error("ConfigLoader only support DruidDataSource");
        }
        DruidDataSource dataSource = (DruidDataSource) dataSourceProxy;
        log.info("db configuration: url=" + dataSource.getUrl());

        Properties properties = dataSource.getConnectProperties();
        try {
            // 将信息配置进Druid
            DruidDataSourceFactory.config(myDruidDataSource, properties);
        } catch (Exception e) {
            log.error("DataSource config error.", e);
        }
    }
}

或者

@Configuration
public class MyDruidDataSourceConfig {

    @Autowired
    private MyDruidDataSource myDruidDataSource;

    @Bean("dataSource")
    public DataSource druidDataSource() {
        return myDruidDataSource;
    }
}

版权声明:
作者:Joe.Ye
链接:https://www.appblog.cn/index.php/2023/04/01/new-connection-rejected-due-to-integration-of-nacos-and-druid-password-using-ciphertext/
来源:APP全栈技术分享
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
打赏
海报
整合Nacos和Druid(password使用密文)出现新建连接被拒绝情况
问题描述 Jmeter进行服务压测时出现,或因一段时间未操作数据库断开连接后再次请求建立连接时,服务器拒绝新的连接情况。 Caused by: com.mysql.cj.exceptions……
<<上一篇
下一篇>>
文章目录
关闭
目 录