Java SpringBoot

配置脱敏

实现配置的脱敏使用Java的一个加解密工具Jasypt,它提供了单密钥对称加密和非对称加密两种脱敏方式。
单密钥对称加密:一个密钥加盐,可以同时用作内容的加密和解密依据;
非对称加密:使用公钥和私钥两个密钥,才可以对内容加密和解密;
以上两种加密方式使用都非常简单,以SpringBoot集成单密钥对称加密方式做示例。
首先引入jasypt-spring-boot-starter jar

  1. <!--配置文件加密-->
  2. <dependency>
  3. <groupId>com.github.ulisesbocchio</groupId>
  4. <artifactId>jasypt-spring-boot-starter</artifactId>
  5. <version>2.1.0</version>
  6. </dependency>

配置文件加入秘钥配置项jasypt.encryptor.password,并将需要脱敏的value值替换成预先经过加密的内容ENC(mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l)
这个格式是可以随意定义的,比如想要abc[mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l]格式,只要配置前缀和后缀即可。

  1. jasypt:
  2. encryptor:
  3. property:
  4. prefix: "abc["
  5. suffix: "]"

ENC(XXX)格式主要为了便于识别该值是否需要解密,如不按照该格式配置,在加载配置项的时候jasypt将保持原值,不进行解密。

  1. spring:
  2. datasource:
  3. url: jdbc:mysql://1.2.3.4:3306/xiaofu?useSSL=false&useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&ze oDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai
  4. username: xiaofu
  5. password: ENC(mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l)
  6. # 秘钥
  7. jasypt:
  8. encryptor:
  9. password: 123456

秘钥是个安全性要求比较高的属性,所以一般不建议直接放在项目内,可以通过启动时-D参数注入,或者放在配置中心,避免泄露。

  1. java -jar -Djasypt.encryptor.password=1123 springboot-jasypt-2.3.3.RELEASE.jar

预先生成的加密值,可以通过代码内调用API生成

  1. @Autowired
  2. private StringEncryptor stringEncryptor;
  3. public void encrypt(String content) {
  4. String encryptStr = stringEncryptor.encrypt(content);
  5. System.out.println("加密后的内容:" + encryptStr);
  6. }

或者通过如下Java命令生成,几个参数D:\maven_lib\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar为jasypt核心jar包,input待加密文本,password秘钥,algorithm为使用的加密算法。

  1. java -cp D:\maven_lib\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI input="root" password=xiaofu algorithm=PBEWithMD5AndDES

2021-09-07-12-10-17-752706.png
操作后如果还能正常启动,说明配置文件脱敏就没问题了。

敏感字段脱敏

生产环境用户的隐私数据,比如手机号、身份证或者一些账号配置等信息,入库时都要进行不落地脱敏,也就是在进入系统时就要实时的脱敏处理。
用户数据进入系统,脱敏处理后持久化到数据库,用户查询数据时还要进行反向解密。这种场景一般需要全局处理,那么用AOP切面来实现在适合不过了。
2021-09-07-12-10-17-900663.png
首先自定义两个注解@EncryptField@EncryptMethod分别用在字段属性和方法上,实现思路很简单,只要方法上应用到@EncryptMethod注解,则检查入参字段是否标注@EncryptField注解,有则将对应字段内容加密。

  1. @Documented
  2. @Target({ElementType.FIELD,ElementType.PARAMETER})
  3. @Retention(RetentionPolicy.RUNTIME)
  4. public @interface EncryptField {
  5. String[] value() default "";
  6. }
  7. @Documented
  8. @Target({ElementType.METHOD})
  9. @Retention(RetentionPolicy.RUNTIME)
  10. public @interface EncryptMethod {
  11. String type() default ENCRYPT;
  12. }

切面的实现也比较简单,对入参加密,返回结果解密。

  1. @Slf4j
  2. @Aspect
  3. @Component
  4. public class EncryptHandler {
  5. @Autowired
  6. private StringEncryptor stringEncryptor;
  7. @Pointcut("@annotation(com.xiaofu.annotation.EncryptMethod)")
  8. public void pointCut() {
  9. }
  10. @Around("pointCut()")
  11. public Object around(ProceedingJoinPoint joinPoint) {
  12. /**
  13. * 加密
  14. */
  15. encrypt(joinPoint);
  16. /**
  17. * 解密
  18. */
  19. Object decrypt = decrypt(joinPoint);
  20. return decrypt;
  21. }
  22. public void encrypt(ProceedingJoinPoint joinPoint) {
  23. try {
  24. Object[] objects = joinPoint.getArgs();
  25. if (objects.length != 0) {
  26. for (Object o : objects) {
  27. if (o instanceof String) {
  28. encryptValue(o);
  29. } else {
  30. handler(o, ENCRYPT);
  31. }
  32. //TODO 其余类型自己看实际情况加
  33. }
  34. }
  35. } catch (IllegalAccessException e) {
  36. e.printStackTrace();
  37. }
  38. }
  39. public Object decrypt(ProceedingJoinPoint joinPoint) {
  40. Object result = null;
  41. try {
  42. Object obj = joinPoint.proceed();
  43. if (obj != null) {
  44. if (obj instanceof String) {
  45. decryptValue(obj);
  46. } else {
  47. result = handler(obj, DECRYPT);
  48. }
  49. //TODO 其余类型自己看实际情况加
  50. }
  51. } catch (Throwable e) {
  52. e.printStackTrace();
  53. }
  54. return result;
  55. }
  56. // ......
  57. }

紧接着测试一下切面注解的效果,对字段mobileaddress加上注解@EncryptField做脱敏处理。

  1. @EncryptMethod
  2. @PostMapping(value = "test")
  3. @ResponseBody
  4. public Object testEncrypt(@RequestBody UserVo user, @EncryptField String name) {
  5. return insertUser(user, name);
  6. }
  7. private UserVo insertUser(UserVo user, String name) {
  8. System.out.println("加密后的数据:user" + JSON.toJSONString(user));
  9. return user;
  10. }
  11. @Data
  12. public class UserVo implements Serializable {
  13. private Long userId;
  14. @EncryptField
  15. private String mobile;
  16. @EncryptField
  17. private String address;
  18. private String age;
  19. }

请求这个接口,看到参数被成功加密,而返回给用户的数据依然是脱敏前的数据,符合预期,那到这简单的脱敏实现就完事了。
2021-09-07-12-10-18-052669.png
SpringBoot配置文件、隐私数据脱敏实践 - 图4

加密的原理实现

Jasypt工具虽然简单好用,但作为程序员不能仅满足于熟练使用,底层实现原理还是有必要了解下的,这对后续调试bug、二次开发扩展功能很重要。
个人认为Jasypt配置文件脱敏的原理很简单,无非就是在具体使用配置信息之前,先拦截获取配置的操作,将对应的加密配置解密后再使用。
具体是不是如此简单看下源码的实现,既然是以SpringBoot方式集成,那么就先从jasypt-spring-boot-starter源码开始入手。
starter代码很少,主要的工作就是通过SPI机制注册服务和@Import注解来注入需前置处理的类JasyptSpringBootAutoConfiguration
image.png
在前置加载类EnableEncryptablePropertiesConfiguration中注册了一个核心处理类EnableEncryptablePropertiesBeanFactoryPostProcessor
2021-09-07-12-10-18-635664.png
它的构造器有两个参数,ConfigurableEnvironment用来获取所有配属信息,EncryptablePropertySourceConverter对配置信息做解析处理。
顺藤摸瓜发现具体负责解密的处理类EncryptablePropertySourceWrapper,它通过对Spring属性管理类PropertySource<T>做拓展,重写了getProperty(String name)方法,在获取配置时,凡是指定格式如ENC(x) 包裹的值全部解密处理。
2021-09-07-12-10-18-851468.png
既然知道了原理那么后续二次开发,比如:切换加密算法或者实现自己的脱敏工具就容易的多了。

PBE算法

再来聊一下Jasypt中用的加密算法,其实它是在JDK的JCE.jar包基础上做了封装,本质上还是用的JDK提供的算法,默认使用的是PBE算法PBEWITHMD5ANDDES,看到这个算法命名很有意思,段个句看看,PBE、WITH、MD5、AND、DES 好像有点故事,继续看。
image.png
PBE算法(Password Based Encryption,基于口令(密码)的加密)是一种基于口令的加密算法,其特点在于口令是由用户自己掌握,在加上随机数多重加密等方法保证数据的安全性。
PBE算法本质上并没有真正构建新的加密、解密算法,而是对已知的算法做了包装。比如:常用的消息摘要算法MD5和SHA算法,对称加密算法DES、RC2等,而PBE算法就是将这些算法进行合理组合,这也呼应上前边算法的名字。
2021-09-07-12-10-19-214287.png
既然PBE算法使用较为常用的对称加密算法,那就会涉及密钥的问题。但它本身又没有钥的概念,只有口令密码,密钥则是口令经过加密算法计算得来的。
口令本身并不会很长,所以不能用来替代密钥,只用口令很容易通过穷举攻击方式破译,这时候就得加点盐了。
盐通常会是一些随机信息,比如随机数、时间戳,将盐附加在口令上,通过算法计算加大破译的难度。

源码里的猫腻

简单了解PBE算法,回过头看看Jasypt源码是如何实现加解密的。
在加密的时候首先实例化秘钥工厂SecretKeyFactory,生成八位盐值,默认使用的jasypt.encryptor.RandomSaltGenerator生成器。

  1. public byte[] encrypt(byte[] message) {
  2. // 根据指定算法,初始化秘钥工厂
  3. final SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm1);
  4. // 盐值生成器,只选八位
  5. byte[] salt = saltGenerator.generateSalt(8);
  6. //
  7. final PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, iterations);
  8. // 盐值、口令生成秘钥
  9. SecretKey key = factory.generateSecret(keySpec);
  10. // 构建加密器
  11. final Cipher cipherEncrypt = Cipher.getInstance(algorithm1);
  12. cipherEncrypt.init(Cipher.ENCRYPT_MODE, key);
  13. // 密文头部(盐值)
  14. byte[] params = cipherEncrypt.getParameters().getEncoded();
  15. // 调用底层实现加密
  16. byte[] encryptedMessage = cipherEncrypt.doFinal(message);
  17. // 组装最终密文内容并分配内存(盐值+密文)
  18. return ByteBuffer
  19. .allocate(1 + params.length + encryptedMessage.length)
  20. .put((byte) params.length)
  21. .put(params)
  22. .put(encryptedMessage)
  23. .array();
  24. }

由于默认使用的是随机盐值生成器,导致相同内容每次加密后的内容都是不同的。
那么解密时该怎么对应上呢?
看上边的源码发现,最终的加密文本是由两部分组成的,params消息头里边包含口令和随机生成的盐值,encryptedMessage密文。
2021-09-07-12-10-19-438298.png
加密
而在解密时会根据密文encryptedMessage的内容拆解出params内容解析出盐值和口令,在调用JDK底层算法解密出实际内容。

  1. @Override
  2. @SneakyThrows
  3. public byte[] decrypt(byte[] encryptedMessage) {
  4. // 获取密文头部内容
  5. int paramsLength = Byte.toUnsignedInt(encryptedMessage[0]);
  6. // 获取密文内容
  7. int messageLength = encryptedMessage.length - paramsLength - 1;
  8. byte[] params = new byte[paramsLength];
  9. byte[] message = new byte[messageLength];
  10. System.arraycopy(encryptedMessage, 1, params, 0, paramsLength);
  11. System.arraycopy(encryptedMessage, paramsLength + 1, message, 0, messageLength);
  12. // 初始化秘钥工厂
  13. final SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm1);
  14. final PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
  15. SecretKey key = factory.generateSecret(keySpec);
  16. // 构建头部盐值口令参数
  17. AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance(algorithm1);
  18. algorithmParameters.init(params);
  19. // 构建加密器,调用底层算法
  20. final Cipher cipherDecrypt = Cipher.getInstance(algorithm1);
  21. cipherDecrypt.init(
  22. Cipher.DECRYPT_MODE,
  23. key,
  24. algorithmParameters
  25. );
  26. return cipherDecrypt.doFinal(message);
  27. }

2021-09-07-12-10-19-701344.png