背景
最近思考到数据安全展示的另一个维度,怎么给客户用户带来价值?一个系统中希望以不同账户调用接口展示的敏感数据不同。之前已经通过端口代理的方式可以基于传输层面处理数据(修改请求、修改返回报文),根据传输报文的格式解析实现对敏感数据的展示做脱敏处理,但是仔细回想这样的处理方式判断敏感信息是否需要脱敏加密维度只能是数据库的登录账户,客户端的ip,判断维度有点粗,适用于直接在客户连接整个库查询的场景。针对更细粒度的思考目前就是在服务中做一个基于jdbc的拦截器代理,查看了一下jdbc源码发现其中还是有很多我们日常使用中没有发现的财富是可以使用的,仔细思考了几种方案进行对比,这里与感兴趣的朋友一起分享下。
需求:
1.对原有系统的改动量不大。
2.在某些集团下的数据库都是用专属的db服务器统一存储的,对于一些资源信息,用户信息有存在公共库的情况,如服务器的基础登录信息,用户的基础信息等,按传统做法多个子系统之间都做数据同步,各自维护到自己库里面,一旦有信息变更多个数据库都需要进行修改,成本太大,那么用同一个库去存储控制读写权限就是一个比较好的选择了,但是这样的问题又来了,为了安全比如用户登录密码明文是不会直接出现在系统中的,几乎都是对比密文,而不同的系统加密方式又不一样,所以如果存储密文存在一定问题(暂时的解决方案是数据库中存明文,或者存储base64编译后的内容,因为安全不是绝对的,只能说內部相对安全)。
3.一个系统中有多种权限的管理员,如果是普通用户权限看其他人的信息敏感信息如薪水、身份证号、电话号码、等信息都是需要做脱敏处理的,但是拥有特殊权限的人应该是可以看到相应的明文的:
财务:可见薪水;
leader:可见电话号码;
负责社保的工作人员:可见身份证号。
方案对比分析:
要实现这样的统一处理功能肯定是需要在某一个地方统一管理的,实现切面和拦截器自然是首选,下面分析下常用的几种具体方案,查阅了下网上现有的方案几乎都是用框架自带拦截器或者注解去处理的吗,注解的方式改动成本太大了,自定义拦截器倒是一种不错选择,不过没法处理原生jdbc连接的查询。
方案1:使用端口代理的脱敏方式
无法满足需求,背景中已经详细介绍。
缺点:
1.对判断维度太粗了,只能精确到数据库用户和客户端ip,没法细化到登录用户。
2.端口代理不适用于一直跑的服务,一旦代理服务器出问题影响比较大。
3.数据源关系多了一层链路,传输效率有影响,且一旦代理地址链路发生更改客户端没有变更则影响无法预估。
方案2:具体业务具体对象自己处理
优点:
1.灵活,可以自定义实现功能。
缺点:
1.编码复杂,几乎是面向过程编程,所有的内容都修要自行编码处理达不到统一处理的效果。
2.代码分散,一旦有新的敏感信息表接入需要继续编码处理,如果需求变更了修改维护也比较麻烦。
方案3:mybatis-plus提供的mybatis-mate-sensitive-jackson
mybatis-plus提供的一个插件,很直接的一个原因就是要收费,而且必须用mybatis-plus,里面的字段还需要加很多的注解去实现,感觉还是比较麻烦的,对于其他框架项目无能为力,老项目改造也比较耗费成本,规划好的新项目可以考虑。
方案4:如mybatis,jpa都有自带的拦截器可以对数据进行处理
优点:
1.相对灵活,可以自定义实现功能,具体实现逻辑可自行查找官方文档。
2.只关注自己的连接方式,经过了多个版本迭代相对稳定。
3.对于数据库版本没有区别,基于框架层面处理。
缺点:
1.存在一定的局限性,必须使用相应的框架连接才行,对于一些老系统或轻量级系统使用原生jdbc连接的无法处理。
2.不同框架都需要配置自己的拦截器,不同框架之间无法复用,拦截层面较高,如果系统中有没使用框架查询的方法无法拦截。
3.受限于框架自带的功能,对于某些特殊的需求如果框架不支持也就无能为力了。
方案5: 基于jdbc层面拦截(只是一种思路,简单研究了一个demo)
优点:
1.灵活多变,可以自行处理相应的逻辑,所有源码来源于自己写的,可扩展性强。
2.拦截层面比较靠前,直接从jdbc层面拦截,任何返回请求都可以拦截。
3.对系统的侵入性比较小,只需要在连接的url后添加一个参数就可以了,引入方式可以直接把jar包放到jdk扩展路径中去。
4.使用简单,默认可以提供相应的处理方式,可以实现0编码,如果需要自定义算法则继承拦截器重写处理方式即可。
缺点:
1.目前没有成熟的应用产品,需要根据业务自行创建。
2.适配的单位是数据库类型,每种数据库类型都需要加载不同的拦截器(当然现在是从0开始啥都没有,如果做好了一个成熟的版本使用就比较简单了)。
实现
本来最初的想法是基于aop对ResultSet接口最切面处理,但是事实不允许,因为切面必须由spring提供的对象去调用才行,而jdbc内部很多的请求丢失this.调用的没办法处理,而且不用spring就没法做切面,局限行太大了。
目前研究了方案的可行性,只研究了一种驱动,这里选择的是mysql:5.1.48版本驱动(由于mysql驱动5到6有一个大的改动从com.mysql.jdbc.Driver切换到了com.mysql.cj.jdbc.Driver)导致了很多包路径改动了,6版本的驱动可能不一致暂未测试。
看了下目录结构,打开mysql驱动包里面有一个路径:com.mysql.jdbc.interceptors
ResultSetScannerInterceptor这个拦截器很明显就是拦截返回内容的,找到入口,后面再使用他就很简单了,自定义一个相应的拦截器在连接的url后面加上参数statementInterceptors=com.yanci.MaskResultSetScannerInterceptor指定使用这个拦截器即可。
说明
这一部分代码不多,主要是提供一种思路一起看一下运行效果和可部署方式,内置处理方式暂时比较简单,后续可以梳理一套常见的敏感字段及处理算法,后续再研究,期望的是数据库中存明文,当查询的时候如果字段为password则对返回的结果做加密处理(可解密,主要防止打印对象的时候日志暴露),如果返回的是phone则对中间部分内容做脱敏处理,具体的根据登录用户做数据权限可以继承这个类里面写session判断逻辑等,见效果图。
注意
由于jpa的查询字段可能会由别名如phone可能变成 phone5_0_,为了兼容jpa放弃精确匹配字段的方法
源码如下:
package com.yanci;
import com.mysql.jdbc.Connection;
import com.mysql.jdbc.ResultSetInternalMethods;
import com.mysql.jdbc.Statement;
import com.mysql.jdbc.StatementInterceptor;
import com.yanci.util.DesUtil;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.SQLException;
import java.util.Properties;
public class MaskResultSetScannerInterceptor implements StatementInterceptor {
public MaskResultSetScannerInterceptor() {
}
@Override
public void init(Connection conn, Properties props) throws SQLException {
}
@Override
public ResultSetInternalMethods postProcess(String sql, Statement interceptedStatement, final ResultSetInternalMethods originalResultSet, Connection connection) throws SQLException {
return (ResultSetInternalMethods) Proxy.newProxyInstance(originalResultSet.getClass().getClassLoader(), new Class[]{ResultSetInternalMethods.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("equals".equals(method.getName())) {
return args[0].equals(this);
} else {
Object invocationResult = method.invoke(originalResultSet, args);
String methodName = method.getName();
if (invocationResult != null && invocationResult instanceof String && "getString".equals(methodName)) {
return dealString(sql, invocationResult, args);
}
return invocationResult;
}
}
});
}
@Override
public boolean executeTopLevelOnly() {
return false;
}
@Override
public void destroy() {
}
@Override
public ResultSetInternalMethods preProcess(String sql, Statement interceptedStatement, Connection connection) throws SQLException {
return null;
}
public Object dealString(String sql, Object obj, Object... org) {
if (obj == null || org == null || org.length == 0) {
return obj;
}
String key = String.valueOf(org[0]);
String value = String.valueOf(obj);
if (key.contains("phone")) {
char[] chars = value.toCharArray();
int length = chars.length / 3;
for (int i = length; i < 2 * length; i++) {
chars[i] = '*';
}
return new String(chars);
} else if (key.contains("password")) {
return DesUtil.encrypt(value);
}
return obj;
}
}
另外在提供一个内置加密类可以根据动态密钥进行加解密,对于返回敏感字段实现脱敏,返回的密码等不可见字段实现加密功能
DesUtil
package com.yanci.util;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
public class DesUtil {
private final static String DES = "DES";
private final static String ENCODE = "GBK";
private static String defaultKey = "";
private static Object obj = new Object();
public static void checkEncryKey() {
if (Objects.equals("", defaultKey)) {
synchronized (obj) {
if (Objects.equals("", defaultKey)) {
defaultKey = ResourcesUtil.getProperties("key") + "abcdefgh";
}
}
}
}
public static String encrypt(String data) {
checkEncryKey();
try {
byte[] bt = encrypt(data.getBytes(ENCODE), defaultKey.getBytes(ENCODE));
String strs = new BASE64Encoder().encode(bt);
return strs;
} catch (Exception e) {
}
return data;
}
public static String decrypt(String data) throws IOException, Exception {
checkEncryKey();
if (data == null)
return null;
BASE64Decoder decoder = new BASE64Decoder();
byte[] buf = decoder.decodeBuffer(data);
byte[] bt = decrypt(buf, defaultKey.getBytes(ENCODE));
return new String(bt, ENCODE);
}
public static String encrypt(String data, String key) throws Exception {
byte[] bt = encrypt(data.getBytes(ENCODE), key.getBytes(ENCODE));
String strs = new BASE64Encoder().encode(bt);
return strs;
}
public static String decrypt(String data, String key) throws IOException,
Exception {
if (data == null)
return null;
BASE64Decoder decoder = new BASE64Decoder();
byte[] buf = decoder.decodeBuffer(data);
byte[] bt = decrypt(buf, key.getBytes(ENCODE));
return new String(bt, ENCODE);
}
private static byte[] encrypt(byte[] data, byte[] key) throws Exception {
SecureRandom sr = new SecureRandom();
DESKeySpec dks = new DESKeySpec(key);
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(DES);
SecretKey securekey = keyFactory.generateSecret(dks);
Cipher cipher = Cipher.getInstance(DES);
cipher.init(Cipher.ENCRYPT_MODE, securekey, sr);
return cipher.doFinal(data);
}
private static byte[] decrypt(byte[] data, byte[] key) throws Exception {
SecureRandom sr = new SecureRandom();
DESKeySpec dks = new DESKeySpec(key);
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(DES);
SecretKey securekey = keyFactory.generateSecret(dks);
Cipher cipher = Cipher.getInstance(DES);
cipher.init(Cipher.DECRYPT_MODE, securekey, sr);
return cipher.doFinal(data);
}
public static void main(String[] args) throws Exception {
String data = "12345789";
System.err.println(encrypt(data, "23456789"));
System.err.println(encrypt(data, "1234578910"));
System.err.println(encrypt(data, "12345789111"));
System.err.println(encrypt(data, "1234578910asd"));
}
}
加载动态密钥:ResourcesUtil
package com.yanci.util;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public class ResourcesUtil {
public static String getProperties(String name) {
Properties props = getPropertiesInCache();
String value = props.getProperty(name);
return value;
}
public static Properties getPropertiesInCache() throws SecurityException {
Properties pro = null;
if (pro == null) {
pro = getProperties();
}
return pro;
}
private static Properties getProperties() {
try (InputStream in = Thread.currentThread().getContextClassLoader().getResource("encry.properties").openStream()) {
Properties props = new Properties();
props.load(in);
in.close();
return props;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
代码结构图如下
使用方式:
1.连接方式添加参数
2.引入方式
2-1.引入maven坐标
2-2.直接把jar包加载到jdk和jre的扩展路径下
可以看到也是可运行的,同时不会影响其他工程的使用,这样就不需要去改动所有工程的pom文件了。
应用框架测试
1.原生jdbcUtil连接
2.mybatis框架测试
3.jpa+自定义拦截器
package com.yanci.intercepter;
import com.yanci.MaskResultSetScannerInterceptor;
import com.yanci.util.DesUtil;
import java.util.Objects;
public class CustomerMaskResultSetScannerInterceptor extends MaskResultSetScannerInterceptor {
@Override
public Object dealString(String sql, Object obj, Object... org) {
if (obj == null || org == null || org.length == 0) {
return obj;
}
String key = String.valueOf(org[0]);
String value = String.valueOf(obj);
String tableName = getTableName(sql, key);
if (Objects.equals(getUserId(), "1")) {
return obj;
}
if (!Objects.equals(tableName, "user")) {
return obj;
}
if (key.contains("phone")) {
char[] chars = value.toCharArray();
int length = chars.length / 3;
for (int i = length; i < 2 * length; i++) {
chars[i] = '#';
}
return new String(chars);
} else if (key.contains("password")) {
return DesUtil.encrypt(value);
}
return obj;
}
private String getUserId() {
return "user";
}
private String getTableName(String sql, String key) {
return "user";
}
}
连接地址修改,指向自定义的拦截器
效果,原有的*替换成了#
github源码地址
总结
总结一下以上五种方案1、2、3几乎可以忽略,不在考虑的范围内,目前比常用的成熟方案还是用注解+框架自带拦截器的能解决大部分问题,因为现在已经很少由大的系统还用原生的jdbc去连接了,第五种新方案属于突发奇想想研究一下源码,尝试了一下确实可用,当作学习的一个方向还是不错的,目前只研究了mysql的驱动实现方式,其他数据库的驱动需要再研究,在特定场景确实是可以给用户带来价值的,这个方案也从侧面反映了做jdbc驱动的开发人员想的还是比较周到的,有可能需要用的内容都给我们留下了相应的处理方式,只是在我们日常使用中没有去过深地研究他每一个参数存在的意义,欢迎有兴趣的小伙伴们一起研究。
|