openmrs/openmrs-contrib-id

View on GitHub
app/models/user.js

Summary

Maintainability
C
1 day
Test Coverage
'use strict';
/**
 * This file defines the Schema of OpenMRS-ID
 */

const mongoose = require('mongoose');
const async = require('async');
const _ = require('lodash');

const Schema = mongoose.Schema;

const log = require('log4js').addLogger('user model');
const ldap = require('../ldap');
const utils = require('../utils');

const conf = require('../conf');
const Group = require('./group');

// Ensure the email list is not empty and no duplicate
// Because mongo won't ensure all the members to be unique in one array
const nonEmpty = {
  validator: function(ar) {
    return ar.length > 0;
  },
  msg: 'The array can\'t be empty'
};

const chkArrayDuplicate = {
  validator: function(arr) {
    const sorted = arr.slice();
    sorted.sort();

    let i;
    for (i = 1; i < sorted.length; ++i) {
      if (sorted[i] === sorted[i - 1]) {
        return false;
      }
    }
    return true;
  },
  msg: 'Some items are duplicate'
};

function arrToLowerCase(arr) {
  arr.forEach((str, index, array) => {
    array[index] = str.toLowerCase();
  });
  return arr;
}

const userSchema = new Schema({
  username: {
    type: String,
    unique: true,
    required: true,
    lowercase: true,
    trim: true,
  }, // unique username

  firstName: {
    type: String,
    index: true,
  },

  lastName: {
    type: String,
    index: true,
  },

  displayName: {
    type: String,
    index: true,
  },

  primaryEmail: {
    type: String, // Used for notifications
    required: true,
    lowercase: true,
    index: true,
  },

  displayEmail: {
    type: String, // Used for displaying
  },

  emailList: {
    type: [String], // All the user's Emails
    required: true,
    unique: true,
    set: arrToLowerCase,
    validate: [nonEmpty, chkArrayDuplicate],
  },

  password: {
    type: String, //hashed password
  },

  groups: { // All the groups that user belong
    type: [String],
    validate: [chkArrayDuplicate],
  },

  locked: { // seal this user from log-in
    type: Boolean,
    required: true,
  },

  createdAt: { // TTL index, let mongodb automatically delete this doc
    type: Date,
    expires: conf.mongo.commonExpireTime,
  },

  extra: {
    type: Schema.Types.Mixed
  },

  inLDAP: { // flag used to mark whether this record is stored in LDAP yet
    type: Boolean,
    default: false,
  },

  // Special flag used to skip the LDAP procedure.
  // Note that this flag will be deleted in pre middleware,
  // so it will only works once.
  skipLDAP: {
    type: Boolean,
  },
});

// diable autoIndex in production
if ('production' === process.env.NODE_ENV) {
  userSchema.set('autoIndex', false);
}

// ensure primaryEmail be one of emailList
userSchema.path('primaryEmail').validate(function(email) {
  return -1 !== this.emailList.indexOf(email);
}, 'The primaryEmail should be one member of emailList');

// generate an iterative function over a group with a callback function that
// takes 1 err argument
const createIteratorOverGroups = (groups, operation) => {
  const updateGroup = (groupName, cb) => {
    //efficiently update groups
    Group.findOneAndUpdate({
      groupName: groupName
    }, operation, {
      lean: true,
      select: 'groupName',
    }, (err, group) => {
      if (err) {
        return cb(err);
      }
      if (_.isEmpty(group)) {
        return cb(new Error(`No such group ${groupName}`));
      }
      return cb();
    });
  };

  return callback => {
    async.each(groups, updateGroup, callback);
  };
};

// maintain the groups relations
userSchema.pre('save', function(next) {
  const user = this;
  const userRef = {
    objId: user.id,
    username: user.username
  };

  // get the added and removed array
  const prepare = callback => {
    User.findById(user._id, (err, oldUser) => {
      if (err) {
        return callback(err);
      }
      const added = _.difference(user.groups, oldUser.groups);
      const removed = _.difference(oldUser.groups, user.groups);
      return callback(null, added, removed);
    });
  };



  let addGroups = (added, removed, callback) => {
    const worker = createIteratorOverGroups(added, {
      $addToSet: {
        member: userRef,
      }
    });
    return worker(err => {
      callback(err, removed);
    });
  };

  const delGroups = (removed, callback) => {
    const worker = createIteratorOverGroups(removed, {
      $pop: {
        member: userRef,
      }
    });
    return worker(callback);
  };

  // real logic starts
  if (this.isNew) { // Mongoose new document boolean flag
    const added = this.groups;
    addGroups = createIteratorOverGroups(added, {
      $addToSet: {
        member: userRef,
      }
    });
    return addGroups(next);
  }

  async.waterfall([
    prepare,
    addGroups,
    delGroups,
  ], next);
});

// sync with LDAP
userSchema.pre('save', function(next) {
  // aliases
  const uid = this.username;
  const that = this;
  if (!_.isEmpty(this.password) && 0 !== this.password.indexOf('{SSHA}')) {
    this.password = utils.getSSHA(this.password);
  }
  if (this.locked) {
    return next();
  }
  if (this.skipLDAP) {
    return next();
  }
  if (!this.inLDAP) { // not stored in LDAP yet
    ldap.addUser(that, err => {
      if (err) {
        log.error(`${uid} failed to add record to OpenLDAP`);
        return next(err);
      }
      log.info(`${uid} stored in OpenLDAP`);
      that.inLDAP = true;
      return next();
    });
    return;
  }
  // already stored in LDAP, modify it
  const getUser = callback => {
    ldap.getUser(uid, (err, userobj) => {
      if (err) {
        return callback(err);
      }
      return callback(null, userobj);
    });
  };

  const updateUser = (userobj, callback) => {
    ldap.updateUser(that, callback);
  };

  // due to limitation of LDAP, password shall be dealt individually
  const changePassword = (userobj, callback) => {
    if (userobj.password === that.password) { // not changed
      return callback();
    }
    ldap.resetPassword(uid, that.password, callback);
  };

  async.waterfall([
      getUser,
      updateUser,
      changePassword,
    ],
    err => {
      if (err) {
        log.error(`${uid} failed to sync with OpenLDAP`);
        log.error(err);
        return next(err);
      }
      return next();
    });
});

// Hook used to remove the record from LDAP
userSchema.pre('remove', function(next) {
  if (!this.inLDAP) {
    return next();
  }
  ldap.deleteUser(this.username, next);
});

// Hook used to remove user from groups
userSchema.pre('remove', function(next) {
  const user = this;
  const userRef = {
    objId: user.id,
    username: user.username
  };
  const delGroups = createIteratorOverGroups(user.groups, {
    $pop: {
      member: userRef,
    }
  });
  delGroups(next);
});

// When rendering JSON, omit sensitive attributes from the model
userSchema.options.toJSON = {
  transform: function(doc, ret, options) {
    delete ret.password;
    delete ret.locked;
    delete ret.inLDAP;
    delete ret.skipLDAP;
    delete ret.createdAt;
    delete ret.__v;
  }
};

var User = mongoose.model('User', userSchema);

exports = module.exports = User;

/**
 * Dynamically retrieve data from OpenLDAP
 * and sync it in Mongo*
 */
const findAndSync = (filter, callback) => {

  const findMongo = cb => {
    User.findOne(filter, (err, user) => {
      if (err) {
        return cb(err);
      }
      if (user) { // if found, directly end the chain
        return callback(null, user);
      }
      return cb();
    });
  };

  // not found in mongo, have a try in OpenLDAP
  const findLDAP = cb => {
    let finder;
    let condition;
    if (filter.username) { // choose finder
      finder = ldap.getUser;
      condition = filter.username;
    } else {
      return callback(); // ldap.js findByEmail is deprecated, end chain
    }

    finder(condition, (err, userobj) => { // find in OpenLDAP
      if (err) {
        return cb(err);
      }
      if (!userobj) { // not found, end chain
        return callback();
      }
      return cb(null, userobj);
    });
  };

  // store the data retrieved to Mongo
  const syncMongo = (userobj, cb) => {
    const userInfo = {
      username: userobj.username,
      firstName: userobj.firstName,
      lastName: userobj.lastName,
      displayName: userobj.displayName,
      primaryEmail: userobj.primaryEmail,
      password: userobj.password,
      emailList: [userobj.primaryEmail],
      locked: false,
      createdAt: undefined,
      inLDAP: true,
    };
    const user = new User(userInfo);
    user.addGroupsAndSave(userobj.groups, cb);
  };

  async.waterfall([
      findMongo,
      findLDAP,
      syncMongo,
    ],
    (err, user) => {
      if (err) {
        return callback(err);
      }
      log.info(`${user.username} retrieved from OpenLDAP and stored`);
      return callback(null, user);
    });
};

/**
 * Helper function for searching user via username case-insensitively.
 * Just delegate the query to <code>Model.findOne</code>.
 *
 * @param  {string}   username The username used for searching,
 * case-insensitive.
 * @param  {Function} callback Receive(err,user)
 */
User.findByUsername = (username, callback) => {
  username = username.toLowerCase();
  findAndSync({
    username: username
  }, callback);
};

/**
 * Just similar to above
 */
User.findByEmail = (email, callback) => {
  email = email.toLowerCase();
  findAndSync({
    emailList: email
  }, callback); // actually there won't be sync
};

/**
 * Just a delegator
 */
User.findByFilter = (filter, callback) => {
  _.forIn(filter, (value, key) => {
    filter[key] = value.toLowerCase();
  });
  findAndSync(filter, callback);
};


/**
 * Add specific groups to an user, and the user to those groups as well.
 * Note that this could only be used in adding new users.
 * @param {[String]}   groups  groupNames
 * @param {Function} callback  same as the one of <code>Model#save</code>
 */
User.prototype.addGroupsAndSave = function(groups, callback) {
  // ToDo May have duplicate problems
  if (!Array.isArray(groups)) {
    groups = [groups];
  }
  const user = this;
  const userRef = {
    objId: user.id,
    username: user.username
  };
  async.each(groups, function addToGroup(groupName, cb) {
      // efficiently update groups
      Group.findOneAndUpdate({
        groupName: groupName
      }, {
        $addToSet: {
          member: userRef,
        }
      }, {
        lean: true,
        select: 'groupName',
      }, (err, group) => {
        if (err) {
          return cb(err);
        }
        if (_.isEmpty(group)) {
          return cb(new Error('No such groups'));
        }
        return cb();
      });
    },
    err => {
      if (err) {
        return callback(err);
      }
      user.groups.addToSet.apply(user.groups, groups);
      user.save(callback);
    });
};