springboot 和 springcloud 配置文件和配置中心密文解密实现原理


前言

    在使用 springboot 或者 springcloud 开发的时候,通常为了保证系统的安全性,配置文件中的密码等铭感信息都会进行加密处理,然后在系统启动的时候对密文进行解密处理。


提示:本篇文章属于原创,请勿抄袭。

一、配置文件密文解密

    在使用 springboot 或者 springcloud 的时候,通常会在 application.yaml 配置文件中配置数据库的连接信息。例如:

mysql:
  driver: com.mysql.jdbc.Driver
  url: jdbc:mysql://localhost:3306/test?characterEncoding=utf8
  username: root
  password: 4545222   
  #一般为了信息安全,密码都会配置成密文的,比如:password: PASSWORD[ 加密后的密文 ]

    而在实际的项目中,关于密码这一类的铭感信息都是经过加密处理的。例如:

mysql:
  driver: com.mysql.jdbc.Driver
  url: jdbc:mysql://localhost:3306/test?characterEncoding=utf8
  username: root
  # BR23C92223KKDNUIQMPLS0009 为经过加密处理的密码
  password: PASSWORD[BR23C92223KKDNUIQMPLS0009]

    经过加密的密文密码在 springboot 项目启动的时候会被解密成明文,而熟悉 springboot 或是 spring 源码的同学都知道,不管是 springboot 还是 spring 它们的配置文件在项目启动后都会被加载到 Environment 对象中,而在 springboot 中,在系统的 Environment 对象创建完成并初始化好了之后,会发布一个事件:ApplicationEnvironmentPreparedEvent 。

    清楚了以上这两点,那么我们实现配置文件密文解密成对应的明文也就有了思路,我们只需要定义一个监听器监听 ApplicationEnvironmentPreparedEvent 事件,当系统的 Environment 对象创建和初始化完成后,会发布这个事件,然后我们的监听器就能监听到这个事件,最后我们在监听器中找出所有经过加密的配置项,然后进行解密,最终再把解密后的明文放入 Environment 对象中。这样我们就实现了对配置文件中经过加密的配置项解密的功能。

代码如下:

package cn.yjh.listener;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.boot.env.OriginTrackedMapPropertySource;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;

import cn.yjh.util.EncryptUtil;

/**
 * @author YouJinhua
 * @since 2021/9/13 10:21
 */
public class EnvironmentPreparedListener implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
    @Override
    public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
        ConfigurableEnvironment env = event.getEnvironment();
        MutablePropertySources pss = env.getPropertySources();
        List<PropertySource> list = new ArrayList<>();
        for(PropertySource ps : pss){
            Map<String,Object>  map = new HashMap<>();
            if(ps instanceof OriginTrackedMapPropertySource){
                OriginTrackedMapPropertySource propertySource = new OriginTrackedMapPropertySource(ps.getName(),map);
                Map<String,Object> src = (Map<String,Object>)ps.getSource();
                src.forEach((k,v)->{
                    String strValue = String.valueOf(v);
                    if(strValue.startsWith("PASSWORD[") && strValue.endsWith("]")) {
                        // 此处进行截取出对应的密文 BR23C92223KKDNUIQMPLS0009 ,然后调用对应的解密算法进行解密操作
                        v = EncryptUtil.decrypt("work0", strValue.substring(9, strValue.length()-1));
                    }
                    map.put(k,v);
                });
                list.add(propertySource);
            }
        }
        /** 
            此处是删除原来的 OriginTrackedMapPropertySource 对象,
            把解密后新生成的放入到 Environment,为什么不直接修改原来的
            OriginTrackedMapPropertySource 对象,此处不做过多解释
            不懂的可以去看看它对应的源码,也算是留一个悬念,也是希望大家
            能够没事多看一看源码。
        */
        list.forEach(ps->{
            pss.remove(ps.getName());
            pss.addLast(ps);
        });
    }
}

    接下来就是如何让我们的监听器生效了,了解 springboot 自动装配原理的同学,大家都知道接下来要做什么了,首先在我们的 resources 目录下新建一个 META-INF 目录,然后在这个目录下新建 spring.factories 文件,在文件中加这么一句话:
org.springframework.context.ApplicationListener=cn.yjh.listener.EnvironmentPreparedListener

代码如下:

# Application Listeners
org.springframework.context.ApplicationListener=cn.yjh.listener.EnvironmentPreparedListener

    这样我们的配置文件密文解密功能就实现了。

二、配置中心密文解密( 以 springcloud + nacos 为例 )

    springcloud + nacos 配置中心的环境搭建,这里就不做过多的说明了,还不会的小伙伴,可以看看其他的博客

    其实不光是我们的配置文件需要加密,从配置中心拉取的配置也是需要加密的。那么从配置中心拉取下来的配置项我们如何进行解密呢?其实具体的实现思路和配置文件的方式差不多。网上也有对应成熟的开源 jar 包(jasypt-spring-boot-starter)可以实现这个功能,这里我不讲那种实现方式了,尽管哪种方式使用起来也挺简单方便的,不会的小伙伴可以看看其他博客或者官方文档。

    我这里讲的实现方式是不需要导入任何的jar包的,因为springcloud自己本身都有这方面的实现,只是很少人知道,官方文档讲得也比较的难懂。其实当你搭建完springcloud的项目后,你去查看它的jar包依赖,你会发现默认已经导入了一个jar包:

在这里插入图片描述

    这是一个接口,是我们实现解密的关键点,因为当我们的 Environment 对象的数据发生变化时候都会通过事件回调的机制去调用这个接口的实现类的decrypt()解密方法,我们先来看一段springcloud的源码,再来分析我们的实现思路,先看:EncryptionBootstrapConfiguration 的关键源码:

// 这个注解说明是一个配置类
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ TextEncryptor.class })
@EnableConfigurationProperties({ KeyProperties.class })
public class EncryptionBootstrapConfiguration {

	@Autowired(required = false)
	// 这个地方会从IOC容器中获取上面我们提到的那个接口的实现类,由于是required = false,所以不一定获取得到,因为可能容器中没有这个对象
	private TextEncryptor encryptor;

	@Autowired
	private KeyProperties key;
	
	// 这里 spring IOC 容器添加一个 EnvironmentDecryptApplicationInitializer  组件
	@Bean
	public EnvironmentDecryptApplicationInitializer environmentDecryptApplicationListener() {		
		// 这里判断上面注入的 TextEncryptor  对象是否为空
		if (this.encryptor == null) {
			//为null,就创建一个默认的
			this.encryptor = new FailsafeTextEncryptor();
		}
		// 否则使用上面注入的那个 TextEncryptor  
		EnvironmentDecryptApplicationInitializer listener = new EnvironmentDecryptApplicationInitializer(
				this.encryptor);
		listener.setFailOnError(this.key.isFailOnError());
		return listener;
	}
	/**
		省略其他代码,只看关键的
	*/

}

    再看这个 EnvironmentDecryptApplicationInitializer 类的源码:

public class EnvironmentDecryptApplicationInitializer implements
		ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {

	/** 
	    这里的 {cipher} 相当于我们 springboot配置文件解密 的 PASSWORD[]
		  springcloud的配置格式是: '{cipher}BR23C92223KKDNUIQMPLS0009'
		  而我们的配置格式是:				PASSWORD[BR23C92223KKDNUIQMPLS0009]
		 	注意: '' 必须要加,不然yaml解析器,解析不了,会报错。
	*/
	public static final String ENCRYPTED_PROPERTY_PREFIX = "{cipher}";

	// 解密的对象
	private TextEncryptor encryptor;

	// 构造函数,传入解密对象,前一个配置类传入的
	public EnvironmentDecryptApplicationInitializer(TextEncryptor encryptor) {
		// 进行属性赋值
		this.encryptor = encryptor;
	}
	
	// 这个方法,我们看关键点
	private void merge(PropertySource<?> source, Map<String, Object> properties) {
		if (source instanceof CompositePropertySource) {

			List<PropertySource<?>> sources = new ArrayList<>(
					((CompositePropertySource) source).getPropertySources());
			Collections.reverse(sources);

			for (PropertySource<?> nested : sources) {
				merge(nested, properties);
			}

		}
		else if (source instanceof EnumerablePropertySource) {
			Map<String, Object> otherCollectionProperties = new LinkedHashMap<>();
			boolean sourceHasDecryptedCollection = false;

			EnumerablePropertySource<?> enumerable = (EnumerablePropertySource<?>) source;
			for (String key : enumerable.getPropertyNames()) {
				Object property = source.getProperty(key);
				if (property != null) {
					String value = property.toString();
					// 这里决定了我们,要使用 {cipher} 开头,表面我们是一个加密项
					if (value.startsWith(ENCRYPTED_PROPERTY_PREFIX)) {
						// 如何是加密项,放入properties对象中存起来,方便后面解密
						properties.put(key, value);
						if (COLLECTION_PROPERTY.matcher(key).matches()) {
							sourceHasDecryptedCollection = true;
						}
					}
					else if (COLLECTION_PROPERTY.matcher(key).matches()) {
						// put non-encrypted properties so merging of index properties
						// happens correctly
						otherCollectionProperties.put(key, value);
					}
					else {
						// override previously encrypted with non-encrypted property
						properties.remove(key);
					}
				}
			}
			// copy all indexed properties even if not encrypted
			if (sourceHasDecryptedCollection && !otherCollectionProperties.isEmpty()) {
				properties.putAll(otherCollectionProperties);
			}

		}
	}

	private void decrypt(Map<String, Object> properties) {
		properties.replaceAll((key, value) -> {
			String valueString = value.toString();
			if (!valueString.startsWith(ENCRYPTED_PROPERTY_PREFIX)) {
				return value;
			}
			return decrypt(key, valueString);
		});
	}
	
	// 这里是真正调用解密方法进行解密了
	private String decrypt(String key, String original) {
		String value = original.substring(ENCRYPTED_PROPERTY_PREFIX.length());
		try {
			// 这里的 encryptor 对象就是构造函数传入的 TextEncryptor 
			value = this.encryptor.decrypt(value);
			if (logger.isDebugEnabled()) {
				logger.debug("Decrypted: key=" + key);
			}
			return value;
		}
		catch (Exception e) {
			String message = "Cannot decrypt: key=" + key;
			if (logger.isDebugEnabled()) {
				logger.warn(message, e);
			}
			else {
				logger.warn(message);
			}
			if (this.failOnError) {
				throw new IllegalStateException(message, e);
			}
			return "";
		}
	}
}

    以上两个类的源码,我这里省略了很多,想仔细查看的自己可以去看看这两个类,我这里关键的地方都已经做了注释。

    这里给大家梳理一下流程:

  1. @Configuration标注EncryptionBootstrapConfiguration 类,说明是个配置类
  2. 既然是配置类那么必然是要导入组件到spring中
  3. @Autowired 注入TextEncryptor ,默认IOC容器中是没有的这个对象的,所以注入失败,值为null
  4. TextEncryptor 值为null,就会创建一个默认的 this.encryptor = new FailsafeTextEncryptor();
  5. @Bean 导入EnvironmentDecryptApplicationInitializer 这个组件,构造函数传入 TextEncryptor
  6. 接下来就是找到对应的加密配置项 if (value.startsWith(ENCRYPTED_PROPERTY_PREFIX))
  7. 然后调用 TextEncryptor接口实现对象的decrypt()方法执行解密操作。

    通过上面的分析我们知道解密的关键点就是TextEncryptor,如果我们在加载EncryptionBootstrapConfiguration 配置类之前,给IOC容器中加入一个我们自己实现的解密算法,那么等到注入TextEncryptor 的时候,就不会为空了,也就不会创建默认的FailsafeTextEncryptor对象,那么在解密的时候不就执行我们自己的解密算法了吗?

    现在的问题就是要解决:在何时加入,如何加入这个自己实现的解密算法到IOC容器中,这个时候又想到了spring、springboot、springcloud的各种扩展点了,熟悉这些扩展点的都知道

    ApplicationPreparedEvent 事件,在 BeanFactory 创建完成后,但是还并没有执行refresh()方法的时候,就会发布这个事件,因为我们知道解析配置类是属于refresh()中的一步,所以这样的思路是可行的。

实现代码如下:

package cn.yjh.listener;

import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.boot.context.event.ApplicationPreparedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.security.crypto.encrypt.TextEncryptor;

/**
 * @author YouJinhua
 * @since 2021/9/13 9:10
 */
public class RegisterTextEncryptorListener implements ApplicationListener<ApplicationPreparedEvent>, Ordered {

    @Override
    public void onApplicationEvent(ApplicationPreparedEvent event) {
        ConfigurableApplicationContext applicationContext = event.getApplicationContext();
        // 这里回往spring IOC 中添加好几次,是因为父子容器的原因,所以要判断一下
        if(applicationContext instanceof AnnotationConfigApplicationContext){
            ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory();
            // 这里判断是否已经添加过我们自己的解密算法了,没添加才添加,否则跳过
            if(!beanFactory.containsBean("textEncryptor")){
                beanFactory.registerSingleton("textEncryptor",new TextEncryptor(){

                    @Override
                    public String encrypt(String text) {
                        System.out.println("=====================================加密");
                        return "加密"+text;
                    }

                    @Override
                    public String decrypt(String encryptedText) {
                    	  //这里解密就直接输出日志,然后直接解密返回
                        System.out.println("=====================================解密");
                        return EncryptUtil.decrypt("work0", encryptedText);
                    }
                });
            }
        }
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

    接下来,就是让我们的监听器生效了,老规矩,在spring.factories中加上这么一句话:
org.springframework.context.ApplicationListener=cn.yjh.listener.RegisterTextEncryptorListener

    这样就可以了,注意配置中心配置加密项的时候一定要注意格式,否则解析不了会报错,正确格式如下:

mysql:
  driver: com.mysql.jdbc.Driver
  url: jdbc:mysql://localhost:3306/test?characterEncoding=utf8
  username: root
  # BR23C92223KKDNUIQMPLS0009 为经过加密处理的密码,注意一定要加 '' 否则解析yaml会报错  
  password: '{cipher}BR23C92223KKDNUIQMPLS0009'

总结

    springcloud配置中心解密配置项,也是看源码的时候才发现原来springcloud已经支持了这个功能,以前没看过这一块儿的源码的时候,都不知道可以这么实现,以前都是使用:jasypt-spring-boot-starter来实现的,所以说多看源码还是会有所收获的,这篇文章就到这里,不足之处,还望大家能够指出来,大家共同学习进步。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐