mongoose schema层加解密(getters setters)

988 阅读5分钟
原文链接: www.jianshu.com

写在前面

最近公司需要做一个功能,将数据库储存的敏感信息如身份证银行卡加密保存;因为涉及需要加解密的业务代码分布分散,一一添加加密和解密方法比较繁琐,所以选择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,但还是将功能上线了(实际开发调试时间比预期多很多...)