最近在做一款数据产品,涉及到数据源。既然是数据源,肯定有URL(含port信息),用户名和密码。页面上面,虽然有前端组件mask处理,不能复制出来。但是对于稍微懂点技术的同学,都知道去查看控制台。在之前的版本设计里面,产品和研发同学【居然】都没有考虑到这种安全机制问题。也就是说,在控制台,可以看到明文密码。
事实上,Impala数据源的用户名和密码广为人知,即发生密码泄露(虽然都是公司内部同事);但是不同业务线,不同团队的开发及测试使用同一套用户密码密码,连接大数据Impala集群肯定是不符合规范的。大数据数仓团队也不好根据此用户名做数据访问及使用统计。
好在大数据CDH集群那边可以拿到Impala数据源相同用户名的不同访问或执行IP,进而可以做业务线使用量统计。
因此,需要做密码加密处理。
前端只能做mask处理,控制台看到的数据是后端接口返回的,故而需要后端来解决这个问题。
不难想到,后端接口在返回密码等私密信息前,加密处理一下;
前端拿到什么数据,就给后端传输什么数据;
后续需要使用此密码数据时,后端需要解密一下。
即:加密,再解密。
数据源管理菜单的功能大致如下,针对每个数据源可以验证其连通性:
上面截图是MySQL,数据源也可以是ClickHouse等其他数据源。比如某个测试环境使用的ClickHouse数据源,在点击连接测试时,报错:
datasource test error
java.lang.Exception: java.lang.Exception: ru.yandex.clickhouse.except.ClickHouseException: ClickHouse exception, code: 193, host: 10.20.30.40, port: 8123; Code: 193, e.displayText() = DB::Exception: Wrong password for user default (version 19.9.5.36)
at com.johnny.common.dataprovider.impl.JdbcDataProvider.check(JdbcDataProvider.java:324)
at com.johnny.common.services.DataProviderService.testConnection(DataProviderService.java:98)
at com.johnny.web.controller.datasource.DataSourceController.checkDatasource(DataSourceController.java:248)
初始的密码是root,加密后再解密应该还是root,为啥会报错密码不对???
实在是莫名其妙,木有办法,只能断点调试,返回给前端2个字段:password,encryptedPassword,然后对比一下encryptedPassword解密后的password和最原始的password是否相同。
好家伙,截图来了:
看到断点调试得到的密码都是root
;但使用String.equals()
对比发现两者不相等。
简单来说,就是如下的截图:
使用String.contentEquals()
对比,发现两者依旧不相等。
测试代码如下:
public static void main(String[] args) {
String s1 = "root";
String s2 = DecodeUtil.encrypt(s1);
String s3 = DecodeUtil.desEncrypt(s2);
System.out.println("s2: " + s2 + "\ns3: " + s3 + "\ns1 == s3 ?: " + s1.equals(s3));
}
见了鬼了。
如果足够仔细的话,会发现第二个截图里面,左边的加密后再解密的密码的长度是16,即:char[16]
;右边的原始密码的长度是4,即:char[4]
。
验证如下:
实际上只需要占用4位,trim()
一下呢?
再对比一下:
解决问题。
数据源是可以查询,新增,编辑,复制,删除的。如果是新增一个数据源,此时用户输入的密码就是明文密码。
此时如果也去解密,做连通性测试的话,肯定会出问题的,事实上,报错如下:desEncrypt failed: Input length not multiple of 16 bytes
。此处的16 bytes
,正好也是上面说的:char[16]
。
解决方法
const [modalType, setModalType] = useState<'update' | 'add' | 'copy' | 'select'>('add');
const testClick = () => {
formModal.validateFields().then((values: any) => {
const {config, sourceName, sourceType} = values;
setTestLoading(true)
test({
name: sourceName,
type: sourceType,
// 新增入参
modalType,
config,
}).then((res: any) => {
message[res.status](`${res.msg}`, 1)
setTestLoading(false)
}).catch(() => {
setTestLoading(false)
})
});
};
// 新增数据源时无需解密
if (!dataSource.getString("modalType").equals("add")) {
// 解密一定要trim()
config.put("password", DecodeUtil.desEncrypt(config.getString("password")).trim());
}
使用的加解密工具类如下:
@Slf4j
public class DecodeUtil {
/**
* 使用AES-128-CBC加密模式,key需要为16位,key和iv可以相同!
*/
private static final String KEY = "dufy20180313java";
private static final String IV = "dufy20180313java";
/**
* 使用默认的key和iv加密
*/
public static String encrypt(String data) {
return encrypt(data, KEY, IV);
}
/**
* 使用默认的key和iv解密
*/
public static String desEncrypt(String data) {
return desEncrypt(data, KEY, IV);
}
/**
* 前端使用crptytojs加密,后端使用此方法解密
*/
public static String decryptAes(String content, String key) {
try {
SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, skeySpec);
return new String(cipher.doFinal(parseHexStr2Byte(content)), StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("decryptAES failed: {}", e.getMessage());
}
return "";
}
private static byte[] parseHexStr2Byte(String hexStr) {
if (hexStr.length() < 1) {
return new byte[]{};
}
byte[] result = new byte[hexStr.length() / 2];
for (int i = 0; i < hexStr.length() / 2; i++) {
int high = Integer.parseInt(hexStr.substring(i * 2, i * 2 + 1), 16);
int low = Integer.parseInt(hexStr.substring(i * 2 + 1, i * 2 + 2), 16);
result[i] = (byte) (high * 16 + low);
}
return result;
}
/**
* 加密方法
*
* @param data 要加密的数据
* @param key 加密key
* @param iv 加密iv
* @return 加密的结果
*/
private static String encrypt(String data, String key, String iv) {
try {
//"算法/模式/补码方式"
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
int blockSize = cipher.getBlockSize();
byte[] dataBytes = data.getBytes();
int plaintextLength = dataBytes.length;
if (plaintextLength % blockSize != 0) {
plaintextLength = plaintextLength + (blockSize - (plaintextLength % blockSize));
}
byte[] plaintext = new byte[plaintextLength];
System.arraycopy(dataBytes, 0, plaintext, 0, dataBytes.length);
SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivspec = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.ENCRYPT_MODE, keyspec, ivspec);
byte[] encrypted = cipher.doFinal(plaintext);
return new Base64().encodeToString(encrypted);
} catch (Exception e) {
log.error("encrypt failed: {}", e.getMessage());
return "";
}
}
/**
* 解密方法
*
* @param data 要解密的数据
* @param key 解密key
* @param iv 解密iv
* @return 解密的结果
*/
private static String desEncrypt(String data, String key, String iv) {
try {
byte[] encrypted1 = new Base64().decode(data);
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivspec = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.DECRYPT_MODE, keyspec, ivspec);
byte[] original = cipher.doFinal(encrypted1);
return new String(original, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("desEncrypt failed: {}", e.getMessage());
return "";
}
}
}