写在前面
最近公司需要做一个功能,将数据库储存的敏感信息如身份证银行卡加密保存;因为涉及需要加解密的业务代码分布分散,一一添加加密和解密方法比较繁琐,所以选择model层加解密数据,以下是使用的方法和遇到的注意点。
环境:
- mongoose v4.11.3
- node v8.9.3
基础实现
先看官网例子
function capitalize (val) {
if (typeof val !== 'string') val = '';
return val.charAt(0).toUpperCase() + val.substring(1);
}
// defining within the schema
var s = new Schema({ name: { type: String, set: capitalize }})
// or by retreiving its SchemaType
var s = new Schema({ name: String })
s.path('name').set(capitalize)
然后根据这个特性我们可以设计这样的代码
const Bcrypt = require('./lib/Bcrypt');
function decrypt (val) {
// 兼容旧数据
if (!val | | val.length < 24) return val;
return Bcrypt.decrypt(val);
}
function encrypt (val) {
// 兼容旧数据
if (!val | | val.length > 24) return val;
return Bcrypt.encrypt(val);
}
// defining within the schema
var schema = new Schema({ bankcardNo: { type: String, set: encrypt, get: decrypt}})
schema.set('toObject', {getters: true, virtuals: true}); // toObject时能够转换
schema.set('toJSON', {getters: true, virtuals: true}); // toJson时能够转换
var User = db.model('user', schema);
这样我们就实现了model层对数据的加解密,即让代码的改动减少,也让业务代码无需改动,保证了业务代码的稳定性。
那这种model层加解密应该怎么进行单元测试呢,例如我们在user表插入{bankcardNo: '6231123445456632345'}
,然后可以可以通过assert.equal(user.bankcardNo, Bcrypt.encrypt('6231123445456632345'))
检验吗?答案是不能的,因为在model层查询出来时bankcardNo
已经经过了get方法解密了,所以查出来还是6231123445456632345
的。
单元测试
这里需要用到mongoose的native方法,直接看代码
const Bcrypt = require('./lib/Bcrypt');
const assert = require('assert');
async test () {
await User.create({bankcardNo: '6231123445456632345'})
const user = User.findOne();
assert.strictEqual(user.bankcardNo, Bcrypt.encrypt('6231123445456632345')); // not ok
const nativeUser = User.collection.findOne();
assert.strictEqual(nativeUser.bankcardNo, Bcrypt.encrypt('6231123445456632345')); // ok
}
使用mongoose的native方法不会经过schema的getters方法转换,就可以达到测试的目的。
第二层数据的加解密
因业务需求,有些深层的数据也需要加密保存,例如
var schema = {
userInfo: {
bankcardNo: String,
bankCode: String,
mobile: String
}
}
我们需要在model层对userInfo.bankcardNo
做加密保存和解密查询操作,可以这么编写
var schema = {
userInfo: {
bankcardNo: {type: String, set: Bcrypt.encrypt, get: Bcrypt.decrypt},
bankCode: String,
mobile: String
}
}
如果对于不指定key的结构可以使用
const Bcrypt = require('./lib/Bcrypt');
function decrypt (val) {
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length < 24) return val;
val.bankcardNo = Bcrypt.decrypt(val.bankcardNo)
return val;
}
function encrypt (val) {
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length > 24) return val;
val.bankcardNo = Bcrypt.encrypt(val.bankcardNo)
return val;
}
var schema = {
userInfo: {
type: String,
get: decrypt,
set: encrypt
}
}
这样看起来是可以实现我们的需求的,接下来让我们测试下
第二层数据加解密测试
以下针对不指定key的测试(指定key的和普通无区别)
const Bcrypt = require('./lib/Bcrypt');
const assert = require('assert');
async test () {
const data = {
bankcardNo: '6231123445456632345',
bankCode: 'BCNK',
mobile: '13688888888'
}
await User.create({bankcardNo: '6231123445456632345'})
const user = User.findOne();
assert.strictEqual(user.bankcardNo, Bcrypt.encrypt('6231123445456632345')); // not ok
const nativeUser = User.collection.findOne(); // {userInfo: {bankcardNo: '6231123445456632345', bankCode: 'BCNK', mobile: '13688888888'}}
assert.strictEqual(nativeUser.bankcardNo, Bcrypt.encrypt('6231123445456632345')); // not ok
}
可以看到native方法查出来后,并不是加密后的数据,是native方法不起效了吗?让我们来调试下;
在加解密方法加上调试log
function decrypt (val) {
console.log('decrypt start', val);
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length < 24) return val;
val.bankcardNo = Bcrypt.decrypt(val.bankcardNo)
console.log('decrypt end', val);
return val;
}
function encrypt (val) {
console.log('encrypt start', val);
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length > 24) return val;
val.bankcardNo = Bcrypt.encrypt(val.bankcardNo)
console.log('encrypt end', val);
return val;
}
再次运行test函数,结果
// encrypt start {bankcardNo: '6231123445456632345', ...}
// encrypt end {bankcardNo: 'K3dE4hC0YQ+X9rY8swWgtvT1wmna08o1bqPN0RaqrHY=', ...}
// decrypt start {bankcardNo: 'K3dE4hC0YQ+X9rY8swWgtvT1wmna08o1bqPN0RaqrHY=', ...}
// decrypt end {bankcardNo: '6231123445456632345', ...}
可以看到,test函数只执行了create方法,但是却调用了schema的getters方法(这里不知道为什么会调用getters方法),然后因为getters方法修改了引用类型变量的值,导致修改了数据库数据(怀疑是调用了model.findAndModify或者是model.save)
问题找到了,虽然问题的原因还不明确,但可以知道怎么避免,修改getters方法
const _ = require('lodash');
function decrypt (val) {
console.log('decrypt start', val);
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length < 24) return val;
const data = _.deepClone(val);
data.bankcardNo = Bcrypt.decrypt(val.bankcardNo)
console.log('decrypt end', data);
return data;
}
这样就解决了问题
完整代码
const Bcrypt = require('./lib/Bcrypt');
const _ = require('lodash');
function decrypt (val) {
const data = _.deepClone(val);
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length < 24) return val;
data.bankcardNo = Bcrypt.decrypt(val.bankcardNo)
return data;
}
function encrypt (val) {
if (!val || !val.bankcardNo) return val;
// 兼容旧数据
if (val.bankcardNo.length > 24) return val;
val.bankcardNo = Bcrypt.encrypt(val.bankcardNo)
return val;
}
var schema = {
userInfo: {
type: String,
get: decrypt,
set: encrypt
}
}
其他问题
- model.update原有字段不能生效setters,需要使用model.save
- mongoose populate不能生效getters,尝试过别人的解决方法:
schema.set('toObject', {getters: true, virtuals: true});
schema.set('toJSON', {getters: true, virtuals: true});
但没有生效,暂时手动解密
总结
getters和setters可以在不进行大改动的情况下实现model层数据加解密,虽然在实现过程中遇到了不少block,但还是将功能上线了(实际开发调试时间比预期多很多...)