浅析数据产品中数据源密码的安全管理设计与实现
概述
最近在全职负责一款数据产品,从后端研发,到前端研发(半吊子),到测试验证(近乎全职),到产品功能交互等设计(又是半吊子),几乎只有我一个人。之所以是这样的局面,是因为被具备中国特色社会主义的公司老板压榨着。
设计
功能浅析
世面上的数据产品,很多很多。其中有不少产品,都大体上可以理解为,基于数据源,提供数据处理,数据监控,推送与可视化的产品。我负责的就是这样一款产品。
显然数据源是一切功能的基础。
界面设计粗略一窥,左侧菜单栏如下图,其中数据源是核心:
数据源列表页:
某个数据源的操作功能如下:
新增,查看,编辑数据源,共用同一个模态框,下图中mongo数据源类型其实也是jdbc中一种:
数据源类型有:
其中:
- 分享与审批是与权限相关的;
- 查看,编辑,删除,没啥好说;
- 复制,提供快捷参考复制其他数据源的功能。
需要注意的是,为了避免数据源的反复创建,设计一条数据源唯一性规则:url + username + password。
密码加密
本文的核心是密码的安全管理。常规的列表接口都是原样存储数据到数据表,然后近乎原样以列表形式返回,前端展示一个表格组件。
绝大多数数据产品里,前端控件都会对密码进行mask处理,但对于稍微具备一点点Chrome开发知识的人来说,都知道开发者模式,即打开F12(或右键,检查)点击Network,查看请求的RequestBody和responseBody,responseBody里面即是数据源密码,这就意味着密码有泄露的风险。
解决方案,不难想到加密处理:
- 加密操作由后端来实现,对返回给前端的数据,过滤password字段,加密返回给前端;
- 后续需要前后端交互的场景,如上图中点击联通测试,需要前端传输用户名,密码等信息给后端;
- 如果是新增数据源,传输给后端的密码是原始密码;
- 如果是编辑数据源,但是不更新密码,而是更新数据源名称等其他信息,则传输给后端的应该是加密后的密码;
- 如果是编辑数据源更新密码,则传输给后端的是原始密码。
所以问题来了:后端无法判断编辑更新情况下,前端传过来的密码是明文还是密文,需不需要解密。
加密问题
对于如上加解密问题。稍加思考后,想到一个可行的方案,有一定的折衷退步思考意味。方案如下:列表页返回的是加密后的密码。如果是查看或编辑某个数据源,则前端调用后端新增提供的获取数据源详情接口,此接口返回的数据是明文,也就是常规接口设计,前后端交互流程。
值得一提的是:在我们这个数据产品中,查看或编辑某个数据源这个权限并不是暴露出来的,需要申请并审批通过之后方可生效。满足安全性要求。
也就是说,用户可以看到数据源列表页概要信息,名称,创建人,创建时间,修改人,修改时间,负责人,最机密的信息是连接串,通过Chrome开发者模式,可以看到数据源用户名,但是看到的密码是加密处理的。
总结:数据库存储的数据是明文;交互流程:
- 列表页展示时,后端加密处理,控制台只能看到;
- 新增数据源时,输入明文,保存明文;
- 数据源查看时,无需加密处理,
- 数据源编辑时,无需加密处理,
优化
数据库存储加密?
代码片段
PageHelper.startPage(params.getInteger("pageNo"), params.getInteger("pageSize"));
if (StringUtils.isNotBlank(params.getString("sortField"))) {
params.put("sortField", StringUtil.camelCaseToUnderscore(params.getString("sortField")));
}
List<DashboardDatasource> lists = datasourceMapper.findDataSourceByParams(params);
for (DashboardDatasource item : lists) {
item.setConnectionStr(this.getConnectionStr(item.getConfig()));
JSONObject config = JSONObject.parseObject(item.getConfig());
// 存在密码时才加密(Redis无密码)
if (StringUtils.isNotBlank(config.getString("password"))) {
config.put("password", DecodeUtil.encrypt(config.getString("password")));
}
item.setConfig(config.toJSONString());
}
public String testConnection(JSONObject dataSource) {
try {
/*JSONObject config = dataSource.getJSONObject("config");
// 新增数据源时无需解密
if (!dataSource.getString("modalType").equals("add")) {
// 解密一定要trim()
config.put("password", DecodeUtil.desEncrypt(config.getString("password")).trim());
}*/
dataProvider.checkConnection();
return JSONObject.toJSONString(ServiceUtil.returnSuccess());
} catch (Exception e) {
logger.error("datasource test error", e);
return JSONObject.toJSONString(ServiceUtil.returnError(e.getMessage()));
}
}