手把手教你ShardingSphere和Mybatis拦截器实现特殊字段动态切换加密

时间:2022-07-28
本文章向大家介绍手把手教你ShardingSphere和Mybatis拦截器实现特殊字段动态切换加密,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

编辑:业余草

来源:juejin.im/post/6864406731045535752

背景

在国家对个人隐私越来越看重的现在,很多用户的重要数据都需要加密存储,比如手机号、真实姓名、联系地址等等,但是可能由于系统建设时间久,在建设初期没有考虑这么全面,数据在数据库中都是明文存储。那么如何将数据由明文存储平滑的切换为密文存储呢?

方案选型

如果项目是新上线,那么可以直接在开发阶段就处理数据加密,没有历史包袱和存量数据需要清洗的问题,实现起来比较简单。以下主要针对一些已上线的业务,需要将明文存储修改为加密存储的场景。

已上线的业务需要将明文存储修改为密文存储,主要需要解决几个问题:

  • 历史数据怎么清洗
  • 怎么平滑的进行迁移:在清洗阶段,可能会有部分数据是明文,部分数据密文的情况,增量数据怎么处理
  • 如何回滚,如果出现问题,如何迅速的回滚,尽量减少对用户的影响

直接加密

如果项目规模比较小,需要加密的数据表量级较少,并且服务可以允许停机维护,那么可以考虑采取这种方案,直接停机,将数据库备份之后,将需要加密的字段由明文全部清洗为密文,然后发布一个新版本上线,一劳永逸。

优点:

「简单粗暴,没有什么技术复杂度,成本低。」

缺点:

「需要停机维护,停机时间视数据清洗的时间而定。」

「回滚风险,由于直接将明文字段修改为密文,如果发布出现了异常,回滚复杂(需要先停机,然后回滚DB,再回滚版本),可能会丢数据。」

ShardingSphere:Encrypt-JDBC

如果项目规模比较大,并且对可用性要求非常高,不允许出现服务不可用的情况下,可以考虑使用Apache ShardingSphere中的Encrypt-JDBC。

❝ Apache ShardingSphere 是一套开源的分布式数据库中间件解决方案组成的生态圈,它提供了多个可插拔的组件,包括数据分片、读写分离、多数据副本、数据加密、影子库压测等功能,可以支持 MySQL、PostgreSQL、SQLServer、Oracle 等 SQL 与协议。github地址 ❞

Encrypt-JDBC通过拦截用户的SQL,并且通过SQL语法解析器来解析SQL,然后进行重写操作后再和数据库进行交互。可以实现对业务代码的透明。他主要提供了几个重要的特性来解决我们上面说到的三个问题。

数据加密

Encrypt-JDBC会将插入、更新操作时的明文进行加密后进行存储,在查询时先将数据解密后再返回。

加密规则配置

这里有一个比较核心的功能是逻辑列(logic cloumn),用户可以配置两个列:cipherColumn和plainColumn。前者用来存储密文数据,后者用来存储明文数据。用户在使用的时候,是使用logicColumn,在配置中配置好logicColumn和对应列的映射关系即可。即对于用户而言,是屏蔽了底层的加密字段和明文字段,只需要使用logic column来查询。这样就可以做到线上的平滑切换和回滚。

❝ 上面的行为都是改写SQL实现的,理论来说用户不需要修改任何原有的SQL,当然,仅仅只是理论上。 ❞

切换步骤

基于上面的特点,我们就可以基本实现线上加密字段的平滑切换。

假设我们有一张用户表(t_user),需要对里面的手机号字段(c_phone)进行加密存储。那么我们可以:

先把t_user表,新增c_phone_encrypt字段。

然后再配置加密规则:

# 配置一个AES加密器,并设置aes.key
encryptRule:
  encryptors:
    aes_encryptor:
      type: aes
      props:
        aes.key.value: xxxxxx
        
# 配置需要加密的表
  tables:
    t_user:
      columns:
        # 加密表的逻辑字段
        c_phone:
          # 明文字段
          plainColumn: c_phone
          # 加密字段
          cipherColumn: c_phone_encrypt
          # 上面配置的加密器
          encryptor: aes_encryptor
          
# 配置不使用密文查询
props:
    query.with.cipher.column: false

接入上面配置后,当t_user表在写入c_phone字段时,会同时写入明文字段(c_phone)和加密字段(c_phone_encrypt),但是在查询时,还是查询明文字段。

接下来需要清洗数据,将历史数据中的c_phone_encrypt字段全部填充为密文。

最后修改配置。

props:
    query.with.cipher.column: true

将查询配置修改为根据密文查询,此时写入还是同时写入明文字段和密文字段,但是查询时会查询密文字段(c_phone_encrypt)。如果此时出现了问题需要回滚,将配置回滚即可。

最后,在验证没有问题之后,就可以将明文字段删除,再将配置中的plainColumn移除即可。

上面就是接入ShardingSphere来进行数据加密的过程。通过上面的步骤,我们总结一下它的优点和缺点。

优点:

  1. 对业务侵入性小,可以做到最少的改动。
  2. 可以做到线上不停机的平滑切换。
  3. 可以做到异常及时回滚,影响较小。

缺点:

  1. 由于是和Sharding-JDBC共用解析器,可以部分SQL语法是不支持的,包括CASE WHEN、HAVING、UNION(ALL)等,具体可以查看ShardingSphere官网。
  2. Encrypt-JDBC使用群体没有那么广泛,存在不少坑,需要大量的测试来避免。
  3. 存在性能问题,每次SQL第一次查询时由于需要解析语法树,大概需要1~2秒的时间,对于性能敏感的应用来说无法接受。

❝ 虽然它会缓存解析结果,但是缓存的key是SQL的签名,比如 select a,b,c from a where id in (?),如果存在大量的参数个数变化的SQL时,每次都需要重新解析。对于QPS比较高的应用来说,一条SQL造成1~2秒的延迟影响的不仅仅是这一个请求,而是整个服务的吞吐量。 ❞

基于Mybatis拦截器 + Alias实现数据加密和字段动态切换

ShardingSphere对于大部分对性能不敏感的应用来说是可以解决数据加密线上平滑迁移的问题的,但是如果需要更好的性能,下面这种方案以业务的侵入性为代价,来提升部分性能。

ShardingSphere里面的思路是非常好的,我们可以参考它的实现,将部分需要语法解析树实现的逻辑,通过编写业务代码的方式来替换它,达到平滑切换的目的。

加解密

加解密使用Mybatis的Alias实现,简单有效

Alias:

/**
 * 加密字段,标识更新/插入的加密字段
 */
@Alias("encryptString")
public class EncryptString {
}

/**
 * 加密字段,标识查询时的加密字段
 */
@Alias("encryptQuery")
public class EncryptQuery {
}

string encrypt handler:

@MappedTypes(EncryptString.class)
public class StringEncryptTypeHandler extends BaseTypeHandler<String> {

    private static final Logger logger = LoggerFactory.getLogger(StringEncryptTypeHandler.class);

    private Encryptor encryptor;

    public StringEncryptTypeHandler() {
        if (MybatisEncryptEnableHolder.isEnabled()) {
            //获取加密器
            this.encryptor = ServiceLoader.getService(Encryptor.class);
        }
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String s,
        JdbcType jdbcType) throws SQLException {
        if (!MybatisEncryptEnableHolder.isEnabled()) {
            ps.setString(i, s);
            return;
        }
        if (encryptor.isEncrypt(s)) {
            //异常,不应该是加密数据
            logger.error("encrypt error {}", s);
        }
        String encryptValue = encryptor.encrypt(s);
        ps.setString(i, encryptValue);
    }

    @Override
    public String getNullableResult(ResultSet rs, String s) throws SQLException {
        String value = rs.getString(s);
        if (!MybatisEncryptEnableHolder.isEnabled()) {
            return value;
        }
        if (value == null) return null;
        try{
            return encryptor.isEncrypt(value) ? encryptor.decrypt(value).toString() : value;
        }catch (Throwable e) {
            return value;
        }
    }

    @Override
    public String getNullableResult(ResultSet rs, int i) throws SQLException {
        //略
    }

    @Override
    public String getNullableResult(CallableStatement cs, int i) throws SQLException {
        //略
    }
}

query encrypt handler:

@MappedTypes(EncryptQuery.class)
public class QueryParamEncryptTypeHandler extends BaseTypeHandler<String> {

    private Encryptor encryptor;

    public QueryParamEncryptTypeHandler() {
        if (MybatisEncryptEnableHolder.isEnabled()) {
            this.encryptor = ConfigurableServiceLoader.getService(Encryptor.class);
        }
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String s, JdbcType jdbcType) throws SQLException {
        //这里增加了配置,用于切换在查询时,是否需要将查询参数加密
        if (!MybatisEncryptEnableHolder.isEnabled() || !MybatisEncryptEnableHolder.isEnabledDynamicColumn()) {
            ps.setString(i, s);
            return;
        }
        String encryptValue = encryptor.encrypt(s);
        ps.setString(i, encryptValue);
    }

    @Override
    public String getNullableResult(ResultSet rs, String s) throws SQLException {
        String value = rs.getString(s);
        if (!MybatisEncryptEnableHolder.isEnabled()) {
            return value;
        }
        if (value == null) return null;
        try{
            return encryptor.isEncrypt(value) ? encryptor.decrypt(value).toString() : value;
        }catch (Throwable e) {
            return value;
        }
    }

    @Override
    public String getNullableResult(ResultSet rs, int i) throws SQLException {
        //略
    }

    @Override
    public String getNullableResult(CallableStatement cs, int i) throws SQLException {
       //略
    }
}

动态切换拦截器

实现拦截器,简单的替换SQL字段,做到动态切换查询字段

Interceptor:

@Intercepts({
                @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
        })
public class DynamicColumnInterceptor implements Interceptor {

    private static final Logger log = LoggerFactory.getLogger(DynamicColumnInterceptor.class);

    //加密字段和普通字段的映射关系
    Map<String, String> encryptColumnMap = new HashMap<>();

    //加密的表名
    private Set<String> tableNames = new HashSet<>();

    public DynamicColumnInterceptor() {
        String columnMapStr = ConfigHolder.getProperties().getProperty("encryptColumnMap", "");
        this.encryptColumnMap = toMap(columnMapStr);
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        if (MybatisEncryptEnableHolder.isEnabledDynamicColumn()) {
            StatementHandler statementHandler = (StatementHandler) realTarget(invocation.getTarget());
            MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
            MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
            SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
            //如果是查询语句
            if (sqlCommandType.equals(SqlCommandType.SELECT)) {
                BoundSql boundSql = statementHandler.getBoundSql();
                String sql = boundSql.getSql();
                //如果是加密表
                if (containsTable(sql)) {
                    try{
                        //改写SQL
                        String replacedSql = replaceSql(sql);
                        Field field = boundSql.getClass().getDeclaredField("sql");
                        field.setAccessible(true);
                        field.set(boundSql, replacedSql);
                    }catch (Exception e) {
                        log.error("替换SQL失败,原SQL:" + sql, e);
                    }

                }
            }
        }
        return invocation.proceed();
    }

    private boolean containsTable(String sql) {
        for (String tableName : tableNames) {
            if (sql.contains(tableName)) {
                return true;
            }
        }
        return false;
    }

    private Object realTarget(Object target) {
        if (Proxy.isProxyClass(target.getClass())) {
            MetaObject metaObject = SystemMetaObject.forObject(target);
            return realTarget(metaObject.getValue("h.target"));
        } else {
            return target;
        }
    }

    /**
     * 自己实现replaceAll方法,string.replaceAll中使用正则,性能损耗更高。
     */
    private String replaceAll(String str, String key, String value) {
        //略,可以参考StringUtils实现,同时考虑字段名部分重复的情况
    }

    private String replaceSql(String sql) {
        String replacedSql = sql;
        for (Map.Entry<String, String> entry : encryptColumnMap.entrySet()) {
            replacedSql = replaceAll(replacedSql, entry.getKey(), entry.getValue());
        }
        log.debug("原SQL:{}n替换后的SQL:{}", sql, replacedSql);
        return replacedSql;
    }

    @Override
    public Object plugin(Object o) {
        return Plugin.wrap(o, this);

    }

    @Override
    public void setProperties(Properties properties) {

    }
}

下面说说具体如何使用。

由于没有做SQL语法树的解析来改写SQL,所以很多操作需要我们在代码中进行,还是以上面的t_user为例:

  1. t_user表新新增c_phone_encrypt字段。
  2. t_user表的所有涉及到加密字段的新增和更新SQL需要手动改为双写,如:insert into t_user (c_phone) valuse #{phone},需要改写为:insert into t_user (c_phone, c_phone_encrypt) values #{phone}, #{phone, javaType=encryptString}。
  3. t_user表的所有涉及到加密字段的查询参数需要修改对应的jdbcType为encryptQuery,如:select c_phone from t_user where c_phone = #{phone},需要改写为:select c_phone from t_user where c_phone = #{phone, javaType=encryptQuery}。
  4. 加密开关和动态字段开关都关闭,发布版本。此时查询还是查原明文字段,但是写入变成了同时写入明文和加密字段。
  5. 清洗数据,填充c_phone_encrypt为密文。
  6. 打开加密开关和动态字段开关,则此时写入还是双写,但是查询时会查询密文字段,如果此时出现了异常,则将动态字段开关关闭即可,则会重新查询明文字段。
  7. 验证无问题后修改SQL,将明文字段也全部写入密文。再清洗所有明文数据为密文。
  8. 修改SQL,将所有加密字段全部移除。再将加密字段删除。

优点:

  1. 性能良好,由于少了语法树解析这一步,除了AES的必要开销外性能几乎和原始查询没有区别。
  2. 由于只是粗暴的替换字段,对SQL的语法没有要求。
  3. 实现起来比较简单。

缺点:

  1. 对业务的侵入性极高,在切换阶段,需要频繁的修改SQL语句。在组件化后推起来起阻力也会比较大。
  2. 整个切换过程十分冗长,需要多次版本迭代。
  3. 字段名粗暴替换,可能后面会踩坑。

总结

上面讨论了已上线业务需要做数据加密的几种方案,总的来说,如果对性能没有那么敏感,并且有使用过Sharding-JDBC的经验的话,可以使用ShardingSphere,可以快速的实现方案,但是建议需要花足够的时间来测试和验证。如果本身公司技术实力雄厚,也可以考虑自研语法解析组件来实现高性能和无侵入的加密框架。