SpringBoot项目中敏感数据入库时加密

249 阅读5分钟

一. 背景

最近,为了响应国家数据安全的号召,要求应用系统中的数据,在入库时,以最小的代价对敏感数据(电话号码、身份证号等)加密;在以往的系统设计中,并未考虑这一环节。为了减少改造工作量和对业务系统影响最小,本文的实现方式采用注解拦截器来实现。

二. 实现方式

采用SpringBoot + mybatis的方式实现,此处,只介绍对字符串类型的字段进行加密,后续需要对其它类型的字段加密,可以在此基础上进行扩展。

加密算法:我们使用AES加密算法实现。

使用注解@Intercepts 开启拦截器,@Signature 定义拦截器的具体类型,其中type属性指定当前拦截器使用StatementHandlerResultSetHandlerParameterHandlerExecutor的一种;method属性指定使用type指定类型的具体方法;args指定预编译语句。

利用反射机制

  1. 定义敏感数据类注解 SensitiveInfoClazz
  2. 定义敏感数据类属性注解 SensitiveInfoField
  3. 入库时加密拦截器 EncryptInterceptor
  4. 出库时解密拦截器 DecryptInterceptor;

最终的项目层级如下图所示:

项目层级.jpg

三. 实现步骤

  • 在自己的数据库中,执行如下SQL语句,创建一张测试表,表名为:user_info

    CREATE TABLE `user_info` (
      `id` int NOT NULL AUTO_INCREMENT,
      `username` varchar(255) DEFAULT NULL,
      `identity_no` varchar(255) DEFAULT NULL,
      `real_name` varchar(255) DEFAULT NULL,
      `mobile` varchar(255) DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb3;
    
  • 使用IDEA创建一个SpringBoot项目,在pom.xml文件中引入如下配置

    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.3.0</version>
      </dependency>
      <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.30</version>
      </dependency>
      <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
      </dependency>
      <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
      </dependency>
    </dependencies>
    
  • resource目录下,配置mybatis的配置信息

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE configuration
            PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-config.dtd"><configuration>
      <properties>
        <property name="dialect" value="mysql" />
      </properties>
      <settings>
        <!-- 开启驼峰匹配 -->
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <!-- 这个配置使全局的映射器启用或禁用缓存。系统默认值是true,设置只是为了展示出来 -->
        <setting name="cacheEnabled" value="true" />
        <!-- 全局启用或禁用延迟加载。当禁用时,所有关联对象都会即时加载。 系统默认值是true,设置只是为了展示出来 -->
        <setting name="lazyLoadingEnabled" value="true" />
        <!-- 允许或不允许多种结果集从一个单独的语句中返回(需要适合的驱动)。 系统默认值是true,设置只是为了展示出来 -->
        <setting name="multipleResultSetsEnabled" value="true" />
        <!--使用列标签代替列名。不同的驱动在这方便表现不同。参考驱动文档或充分测试两种方法来决定所使用的驱动。 系统默认值是true,设置只是为了展示出来 -->
        <setting name="useColumnLabel" value="true" />
        <!--允许 JDBC 支持生成的键。需要适合的驱动。如果设置为 true 则这个设置强制生成的键被使用,尽管一些驱动拒绝兼容但仍然有效(比如
                Derby)。 系统默认值是false,设置只是为了展示出来 -->
        <setting name="useGeneratedKeys" value="false" />
        <!--配置默认的执行器。SIMPLE 执行器没有什么特别之处。REUSE 执行器重用预处理语句。BATCH 执行器重用语句和批量更新 系统默认值是SIMPLE,设置只是为了展示出来 -->
        <setting name="defaultExecutorType" value="SIMPLE" />
        <!--设置超时时间,它决定驱动等待一个数据库响应的时间。 系统默认值是null,设置只是为了展示出来 -->
        <setting name="defaultStatementTimeout" value="25000" />
      </settings></configuration>
    

    UserInfoMapper.xml

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="cn.com.ethan.dao.IUserInfoMapper">
      <insert id="save" parameterType="cn.com.ethan.entity.UserInfoEntity">
        INSERT INTO
        user_info (username, identity_no, real_name, mobile)
        VALUES
        (#{username}, #{identityNo}, #{realName}, #{mobile});
      </insert>
    ​
      <select id="findUserInfo" parameterType="integer" resultType="cn.com.ethan.entity.UserInfoEntity">
        SELECT
        id, username, identity_no AS identityNo, real_name AS realName, mobile
        FROM
        user_info
        WHERE
        id = #{id}
      </select>
    </mapper>
    
  • 在application.yml文件中配置数据源,秘钥等

    spring:
      datasource:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://127.0.0.1:3306/dev
        username: 数据库账号
        password: 数据库密码
    
    mybatis:
      mapper-locations: classpath:mapper/*.xml
      type-aliases-package: cn.com.ehtan.entity
      config-location: classpath:mybatis/mybatis-config.xml
    
    aes:
      private_key: 1qaz2wsx3edc4rfv
    
  • 创建敏感信息类注解:SensitiveInfoClazz

    @Inherited
    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface SensitiveInfoClazz {
    }
    
  • 创建敏感信息类属性注解SensitiveInfoField

    @Inherited
    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface SensitiveInfoField {
    }
    
  • 加密拦截器EncryptInterceptor,实现Interceptor接口

    @Component
    @Intercepts({@Signature(type = ParameterHandler.class, method = "setParameters", args = PreparedStatement.class)})
    public class EncryptInterceptor implements Interceptor {
    ​
      @Resource
      private EncryptUtilsImpl encryptUtils;
    ​
      @Override
      public Object intercept(final Invocation invocation) throws Throwable {
        final ParameterHandler parameterHandler = (ParameterHandler) invocation.getTarget();
        final Field declaredField = parameterHandler.getClass().getDeclaredField("parameterObject");
        declaredField.setAccessible(true);
        final Object obj = declaredField.get(parameterHandler);
        if (Objects.nonNull(obj)) {
          final Class<?> clazz = obj.getClass();
          final SensitiveInfoClazz sensitiveClazz = AnnotationUtils.findAnnotation(clazz, SensitiveInfoClazz.class);
          if (Objects.nonNull(sensitiveClazz)) {
            final Field[] fields = clazz.getDeclaredFields();
            encryptUtils.encrypt(fields, obj);
          }
        }
        return invocation.proceed();
      }
    ​
      @Override
      public Object plugin(final Object target) {
        return Plugin.wrap(target, this);
      }
    ​
      @Override
      public void setProperties(final Properties properties) {
        Interceptor.super.setProperties(properties);
      }
    }
    
  • 解密拦截器DecryptInterceptor,需要实现Interceptor接口

    @Component
    @Intercepts(@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}))
    public class DecryptInterceptor implements Interceptor {
    ​
      @Resource
      private DecryptUtilsImpl decryptUtils;
    ​
      @Override
      public Object intercept(final Invocation invocation) throws Throwable {
        final Object proceed = invocation.proceed();
        if (Objects.isNull(proceed)) {
          return null;
        }
        if (proceed instanceof ArrayList) {
          final ArrayList resultList = (ArrayList) proceed;
          if (!CollectionUtils.isEmpty(resultList) && isDecrypt(resultList.get(0))) {
            for (final Object obj : resultList) {
              decryptUtils.decrypt(obj);
            }
          }
        } else {
          if (isDecrypt(proceed)) {
            decryptUtils.decrypt(proceed);
          }
        }
        return proceed;
      }
    ​
      @Override
      public Object plugin(final Object target) {
        return Plugin.wrap(target, this);
      }
    ​
      @Override
      public void setProperties(final Properties properties) {
        Interceptor.super.setProperties(properties);
      }
    ​
      private boolean isDecrypt(final Object obj) {
        final Class<?> clazz = obj.getClass();
        final SensitiveInfoClazz sensitiveClazz = AnnotationUtils.findAnnotation(clazz, SensitiveInfoClazz.class);
        return Objects.nonNull(sensitiveClazz);
      }
    }
    
  • 加密工具类接口IEncryptUtils与实现类EncryptUtilsImpl创建

    public interface IEncryptUtils {
    ​
      /**
       * 加密
       * @param fieldList paramsObject中声明的字段
       * @param paramsObject mapper中paramsType的实例
       * @return {@link T}
       * @param <T>
       */
      <T> T encrypt(final Field[] fieldList, final T paramsObject) throws IllegalAccessException;
    ​
    }
    
    @Component
    public class EncryptUtilsImpl implements IEncryptUtils {
    ​
      @Value("${aes.private_key}")
      private String privateKey;
    ​
      /**
       * 加密
       *
       * @param fieldList    paramsObject中声明的字段
       * @param paramsObject mapper中paramsType的实例
       * @return {@link T}
       */
      @Override
      public <T> T encrypt(Field[] fieldList, T paramsObject) throws IllegalAccessException {
        final Base64.Decoder decoder = Base64.getDecoder();
        for (final Field field : fieldList) {
          final SensitiveInfoField sensitiveField = field.getAnnotation(SensitiveInfoField.class);
          if (Objects.nonNull(sensitiveField)) {
            field.setAccessible(true);
            final Object obj = field.get(paramsObject);
            if (obj instanceof String) {
              final String val = (String) obj;
              final String value = AES.encrypt(val, privateKey);
              field.set(paramsObject, value);
            }
          }
        }
        return paramsObject;
      }
    }
    
  • 解密工具接口类IDecryptUtils与实现类DecryptUtilsImpl创建

    public interface IDecryptUtils {
    ​
      /**
       * 解密
       * @param result resultType实例
       * @return {@link T}
       * @param <T>
       */
      <T> T decrypt(final T result) throws IllegalAccessException;
    }
    
    @Component
    public class DecryptUtilsImpl implements IDecryptUtils {
    ​
      @Value("${aes.private_key}")
      private String publicKey;
      /**
       * 解密
       *
       * @param result resultType实例
       * @return {@link T}
       */
      @Override
      public <T> T decrypt(final T result) throws IllegalAccessException {
        final Class<?> clazz = result.getClass();
        final Field[] fields = clazz.getDeclaredFields();
        for (final Field field : fields) {
          final SensitiveInfoField sensitiveField = field.getAnnotation(SensitiveInfoField.class);
          if (Objects.nonNull(sensitiveField)) {
            field.setAccessible(true);
            final Object obj = field.get(result);
            if (obj instanceof String) {
              final String val = (String) obj;
              final String value = AES.decrypt(val, publicKey);
              field.set(result, value);
            }
          }
        }
        return result;
      }
    }
    
  • 创建常规的controllerservice

    @Service("userInfoServiceImpl")
    public class UserInfoServiceImpl implements IUserInfoService {
    ​
      @Resource
      private IUserInfoMapper userInfoMapper;
    ​
      @Override
      public void save(UserInfoEntity userInfoEntity) {
        userInfoMapper.save(userInfoEntity);
      }
    ​
      @Override
      public UserInfoEntity findUserInfo(final Integer id) {
        return userInfoMapper.findUserInfo(id);
      }
    }
    

    UserInfoController.java

    @RestController
    public class UserInfoController {
    ​
      @Resource(name = "userInfoServiceImpl")
      private IUserInfoService userInfoService;
    ​
      @PostMapping(value = "/user/save")
      public String save() {
        final UserInfoEntity userInfoEntity = new UserInfoEntity();
        userInfoEntity.setUsername("Ethan");
        userInfoEntity.setRealName("张三");
        userInfoEntity.setIdentityNo("511324199310033223");
        userInfoEntity.setMobile("18782975188");
        userInfoService.save(userInfoEntity);
        return userInfoEntity.getUsername();
      }
    ​
      @GetMapping(value = "/user/find/{id}")
      public String findUserInfo(@PathVariable final Integer id) {
        final UserInfoEntity userInfo = userInfoService.findUserInfo(id);
        return userInfo.toString();
      }
    ​
    }
    
  • postman中请求接口:http://localhost:8080/user/save,然后在数据库中查询数据,结果如下

加密效果.jpg

  • postman中请求接口:http://localhost:8080/user/find/33,得到如下结果,已经实现了入库加密,出库解密的效果

查询结果.jpg

四. 总结

  • 敏感数据入库加密的方式有很多种,本文只是介绍了一下,自己采取的实现方式,可以很快实现用户需求,但也有不足,入库和出库的加密与解密,会影响接口响应时间。
  • 使用本文的这种方式,需要了解Java的反射机制、明白拦截器等实现方式。