Spring Boot Mybatis 优雅解决敏感信息加解密问题

本贴最后更新于 1795 天前,其中的信息可能已经时过境迁

family.png

前言:在一些特定的应用场景下,需要对用户的敏感信息做加密处理,同样的在读取用户信息的时候又需要解密处理。

1. 常见解决方案

针对这种应用场景,通常我们的做法是:在数据入库之前,对敏感数据进行加密。执行查询 sql 返回结果后,再对结果进行解密。

我们很容易想到如下代码:

@Override public int saveOne(VipCard vipCard) { vipCard.setName(encrypt(vipCard.getName())); vipCard.setCardNo(encrypt(vipCard.getCardNo())); vipCard.setIdNumber(encrypt(vipCard.getIdNumber())); vipCard.setPhoneNumber(encrypt(vipCard.getPhoneNumber())); return vipCardMapper.saveOne(vipCard); } @Override public VipCard findById(Integer id) { VipCard vipCard = vipCardMapper.findById(id); vipCard.setName(decrypt(vipCard.getName())); vipCard.setCardNo(decrypt(vipCard.getCardNo())); vipCard.setIdNumber(decrypt(vipCard.getIdNumber())); vipCard.setPhoneNumber(decrypt(vipCard.getPhoneNumber())); return vipCard; }

如果在设计阶段就提出来了这加密的需求,那这种方案是没有问题的。而实际的应用场景可能并不是这么简单。

紧接着上面的解决方案,通过一问一答的方式了解这种方案的弊端:

Q:如果是后期业务更改,数据库中已经存在了大量明文敏感信息的情况怎么处理呢?

A:这个简单,对读取出来的内容做个判断再进行处理:

@Override public VipCard findById(Integer id) { VipCard vipCard = vipCardMapper.findById(id); // ...... String cardNo = vipCard.getCardNo(); vipCard.setCardNo(isEncrypt(cardNo) ? decrypt(vipCard.getCardNo()) : cardNo); // ...... return vipCard; }

Q:如果业务中大量存在这种逻辑代码呢?难道每一个都去手动修改吗?

A:这个也难不倒我,使用 Spring AOP 搭配自定义注解的方式对类似的操作进行统一处理。

Q:现在都是分布式系统架构,如果很多模块中都存在这种逻辑呢?

A:把上一步的代码写到通用模块中,其他应用模块使用就可以了。(心中暗暗自喜)

Q:嗯,这种方案可行是可行,但对已有的代码还是有较大的侵入性,后期维护也不是那么方便。还有更好的办法吗?

A:(纳尼?!还有更好的办法?)...... 应该有,但是目前不太了解。

2. 优雅解决方案

相信大部分朋友(包括我)都是使用上述方案来解决敏感信息加解密问题的,那么到底有没有更好的解决方案呢?

直到发现了 github 上的一个开源项目 typehandlers-encrypt ,我仿佛打开了新世界的大门。该项目中通过 MybatisTypeHandler 来实现通用的加解密解决方案。

2.1 TypeHandler 介绍

想要使用它,当然是要先了解它,我们先来看看它的源码:

public interface TypeHandler<T> { void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; T getResult(ResultSet rs, int columnIndex) throws SQLException; T getResult(CallableStatement cs, int columnIndex) throws SQLException; }

重点看下 setParameter 方法中的参数:

  • PreparedStatement ps:预编译 SQL 对象
  • int i:参数中所在位置下标
  • T parameter:参数的 Java 类型
  • JdbcType jdbcType:数据库中的字段类型

不难推断出:该接口在指定 实体类——数据类型映射中起关键作用


TypeHandler 有一个子类 BaseTypeHandler ,源码这里就不贴了。 BaseTypeHandler 是一个抽象类,它实现了 TypeHandler 方法去调用它自己的抽象方法,抽象方法通过具体的子类实现(这里使用的是 模板方法模式)。

BaseTypeHandler 的子类几乎囊括了我们常用的 Java 类型处理器,这里就拿 StringTypeHandler 作为示例:

public class StringTypeHandler extends BaseTypeHandler<String> { @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, parameter); } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return rs.getString(columnName); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return rs.getString(columnIndex); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return cs.getString(columnIndex); } }

2.2 自定义 CryptTypeHandler

参考 StringTypeHandler 自定义一个 TypeHandler 用于处理字段的加解密。

@Slf4j public class CryptTypeHandler extends BaseTypeHandler<String> { static final int INPUT_MAXIMUM_LENGTH = 18; static RSAPublicKey publicKey; static RSAPrivateKey privateKey; static { try { KeyPair keyPair = RsaUtil.getKeyPair(); publicKey = (RSAPublicKey) keyPair.getPublic(); privateKey = (RSAPrivateKey) keyPair.getPrivate(); } catch (NoSuchAlgorithmException e) { log.error("RsaUtil create keyPair fail", e); } } @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { try { String encrypt = RsaUtil.encrypt(parameter, publicKey); ps.setString(i, encrypt); } catch (Exception e) { log.error("encrypt message failed", e); // 加密失败,使用原始数据 ps.setString(i, parameter); } } @Override public String getNullableResult(ResultSet rs, String columnName) { try { String parameter = rs.getString(columnName); if (isEncrypt(parameter)) { return RsaUtil.decrypt(parameter, privateKey); } // 不是密文直接返回 return parameter; } catch (Exception e) { log.error("decrypt message failed", e); } return null; } @Override public String getNullableResult(ResultSet rs, int columnIndex) { try { String parameter = rs.getString(columnIndex); if (isEncrypt(parameter)) { return RsaUtil.decrypt(parameter, privateKey); } return parameter; } catch (Exception e) { log.error("decrypt message failed", e); } return null; } @Override public String getNullableResult(CallableStatement cs, int columnIndex) { try { String parameter = cs.getString(columnIndex); if (isEncrypt(parameter)) { return RsaUtil.decrypt(parameter, privateKey); } return parameter; } catch (Exception e) { log.error("decrypt message failed", e); } return null; } /** * 判断是否为加密过的内容 */ private boolean isEncrypt(String parameter) { // 这里只是粗略地对密文长度进行判断,不一定准确,实际开发中可从但不限于以下维度进行判断: // 1. 密文长度 // 2. 是否包含中文 // 3. 特殊字符 // 4. 特定格式(正则表达式) // 5. .... return parameter.length() > INPUT_MAXIMUM_LENGTH; } }

这里使用 RSA 加密算法对敏感信息进行加解密处理,这种方式有弊端,后面会说到。如需更换加密算法只需更改加解密代码即可,但更推荐使用模板方法模式编写通用加解密工具适配各种加密算法

2.3 使用类型别名

虽然我们自定义了 CryptTypeHandler ,但如何使用它呢?

我们在 mapper.xml 文件中通常这样写:

<resultMap id="VipCardMap" type="com.demo.crypt.bean.VipCard"> <result property="id" column="id" javaType="Integer"/> <result property="cardNo" column="card_no" javaType="String"/> ...... </resultMap>

而我们要使用 CryptTypeHandler, 就得这样写:

<resultMap id="VipCardMap" type="com.demo.crypt.bean.VipCard"> <result property="id" column="id" javaType="Integer"/> <result property="cardNo" column="card_no" typeHandler="com.demo.crypt.core.handler.CryptTypeHandler"/> ...... </resultMap>

使用 typeHandler 属性就得写 CryptTypeHandler 权限定名。Mybatis 提供的 @Alias 注解可以帮助我们使用它的别名来指定字段的类型处理器。

创建 CryptType 类,添加别名:

@Alias("Crypt") public class CryptType { }

CryptTypeHandler 中指定处理类型:

@Slf4j @MappedTypes(CryptType.class) public class CryptTypeHandler extends BaseTypeHandler<String> { // ...... }

然后 mapper.xml 文件就可以这样写:

<resultMap id="VipCardMap" type="com.demo.crypt.bean.VipCard"> <result property="id" column="id" javaType="Integer"/> <result property="cardNo" column="card_no" javaType="Crypt"/> ...... </resultMap>

2.4 注册类型以及类型处理器

以为这样就可以用了?

😳 你都没注册这些组件,怎么用嘛!

Mybatis 在 Spring Boot 中配置起来就很方便了,只需这样:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.crypt.core.alias type-handlers-package: com.demo.crypt.core.handler

3. 测试

建表语句:

drop table if exists `vip_card`; create table `vip_card` ( `id` int not null auto_increment comment 'id', `card_no` varchar(2048) not null comment '卡号', `name` varchar(2048) not null comment '用户名', `id_number` varchar(2048) not null comment '身份证号', `phone_number` varchar(2048) not null comment '手机号', `create_time` datetime default current_timestamp comment '创建时间', `update_time` datetime comment '更新时间', primary key (`id`) ) comment '会员卡表' engine = InnoDB default charset = utf8; insert into vip_card (card_no, name, id_number, phone_number) values ('10000000000', '张三', '11010119900307221X', '13812345678');

为了模拟数据表中已经存在明文数据,这里直接插入一条数据。

为了方便观察执行的 sql,开启 debug:

logging: level: com.demo.crypt.mapper: debug

现在我们使用 postman 添加一条数据

image.png

控制台输出:

c.d.crypt.mapper.VipCardMapper.saveOne : ==> Preparing: insert into vip_card (card_no, name, id_number, phone_number) values (?, ?, ?, ?) c.d.crypt.mapper.VipCardMapper.saveOne : ==> Parameters: S7S56NhMCr3d ..... ElcORxQ==(String), KDzAkeBY512Lilo ...... 5rdHA==(String), hT4QGXpe ...... o8UBA==(String), RICq0gSk ...... MU1ig7Q==(String) c.d.crypt.mapper.VipCardMapper.saveOne : <== Updates: 1

可见插入数据库的数据都经过加密处理。

查询之前已存在的明文数据

image.png

查询之后逻辑添加的密文数据

image.png

看见了吧,无论是之前存在的明文数据还是之后的密文数据,对用户而言总是能得到想要的结果

4. 问题

上面提到过当前使用 RsaUtil 加密敏感信息会存在问题。

4.1 问题一

尝试通过敏感字段进行查询,比如 手机号:

image.png

由于 Controller 没有做空值判断我们直接看控制台:

c.d.c.mapper.VipCardMapper.findByPhone : ==> Preparing: select id, card_no, name, id_number, phone_number from vip_card where card_no in (?, ?) limit 1 c.d.c.mapper.VipCardMapper.findByPhone : ==> Parameters: DSX5oiqHy ...... fDe1fnztw==(String), 13112345678(String) c.d.c.mapper.VipCardMapper.findByPhone : <== Total: 0

发现通过手机号查不出会员卡信息,为啥呢?

查阅资料后发现 RSA 加密算法对同样的数据每次加密的结果不一样!不仅仅是 RSA ,还有其他的一些主流加密算法,如 AES 、DES 等等也是这样的。

当然这种问题也是可以解决的,解决方案不限于 更换加密算法

4.2 问题二

由于这个项目中 RSA 的密钥对是通过工具类随机生成的,所以在每次重启项目后获取的密钥对不一致,从而导致上一次启动后新增的数据无法在本次启动中正确解密,也就拿不到数据。这可是一个大 Bug!

解决方案不限于:

  1. 自行创建 RSA 密钥对,以文件的形式存放在项目中。
  2. 项目首次启动创建密钥对,并将密钥对以文件形式存放在项目中。
  3. 使用缓存,项目首次启动创建密钥对,并将密钥对存放在缓存中。

5. 总结

这里虽然只是提到如何通过 Mybatis 的 TypeHandler 实现通用的敏感信息加解密方案,但其实是想通过了解 TypeHandler ,然后在实际开发中使用它来实现更多功能。

源码地址:https://github.com/NekoChips/SpringDemo/tree/master/22.springboot-Mybatis-TypeHandler

参考项目:typehandlers-encrypt

  • Spring

    Spring 是一个开源框架,是于 2003 年兴起的一个轻量级的 Java 开发框架,由 Rod Johnson 在其著作《Expert One-On-One J2EE Development and Design》中阐述的部分理念和原型衍生而来。它是为了解决企业应用开发的复杂性而创建的。框架的主要优势之一就是其分层架构,分层架构允许使用者选择使用哪一个组件,同时为 JavaEE 应用程序开发提供集成的框架。

    948 引用 • 1460 回帖
  • MyBatis

    MyBatis 本是 Apache 软件基金会 的一个开源项目 iBatis,2010 年这个项目由 Apache 软件基金会迁移到了 google code,并且改名为 MyBatis ,2013 年 11 月再次迁移到了 GitHub。

    173 引用 • 414 回帖 • 368 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...