openmrs/openmrs-contrib-id

View on GitHub
app/ldap.js

Summary

Maintainability
D
1 day
Test Coverage
'use strict';
/**
 * The contents of this file are subject to the OpenMRS Public License
 * Version 1.1 (the "License"); you may not use this file except in
 * compliance with the License. You may obtain a copy of the License at
 * http://license.openmrs.org
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 *
 * Copyright (C) OpenMRS, LLC.  All Rights Reserved.
 */

const ldap = require('ldapjs');
const async = require('async');
const _ = require('lodash');
const log = require('log4js').addLogger('ldap');

const conf = require('./conf');
const serverAttr = conf.ldap.server;
const userAttr = conf.ldap.user;
const groupAttr = conf.ldap.group;


// ldap connection url
const url = serverAttr.uri;

const systemDN = `${serverAttr.rdn}=${serverAttr.loginUser},${serverAttr.baseDn}`;
const bindCredentials = serverAttr.password;

// LDAP client used for general operation
const client = ldap.createClient({
  url: url,
  maxConnections: 10,
  bindDN: systemDN,
  bindCredentials: bindCredentials,
  queueDisable: true, //fail fast when connections times out. https://github.com/mcavage/node-ldapjs/issues/328
  reconnect: { // tries to reconnect if LDAP server is down. https://github.com/mcavage/node-ldapjs/issues/403
    initialDelay: 100,
    maxDelay: 500,
    failAfter: Infinity,
  },
});

/* Private helper functions */

// Convert a normal user object into proper ldap form **with out groups**
// and check the types of the user's attributes,
// throw out an error if anything is wrong.
const checkAndConvert = user => {
  const invalid = (!_.isString(user.username) || !_.isString(user.password) ||
    !_.isString(user.firstName) || !_.isString(user.lastName) ||
    !_.isString(user.displayName || !_.isString(user.primaryEmail)));

  if (invalid) {
    throw new Error(`The user object is invalid with ${user}`);
  }
  const ret = {};
  ret[userAttr.username] = user.username;
  ret[userAttr.firstname] = user.firstName;
  ret[userAttr.lastname] = user.lastName;
  ret[userAttr.displayname] = user.displayName;
  ret[userAttr.email] = user.primaryEmail;
  ret[userAttr.password] = user.password;
  ret.objectClass = userAttr.defaultObjectClass;
  return ret;
};

// get the raw LDAP entry
const searchRaw = (username, attributes, cb) => {
  if (!_.isArray(attributes)) {
    attributes = [attributes];
  }
  const getAll = username === '*';
  const base = getAll ? userAttr.baseDn : `uid=${username},${userAttr.baseDn}`;
  const options = {
    scope: getAll ? 'sub' : 'base',
    attributes: attributes,
  };

  client.search(base, options, (err, res) => {
    if (err) {
      return cb(err);
    }
    const ret = [];
    res.on('searchEntry', entry => {
      ret.push(entry.object);
    });
    res.on('error', err => {
      if (err.code === 32) { // not found, no such dn
        return cb(null, null);
      }
      log.error(`error: ${err.message}`);
      return cb(err);
    });
    res.on('end', result => {
      log.debug(`ldap search ended with status: ${result.status}`);
      if (!ret.length) {
        return cb(null, null);
      } else if (ret.length === 1) {
        return cb(null, ret[0]);
      } else {
        return cb(null, _.filter(ret, item => item.uid));
      }
    });
  });
};

const convertUser = old => {
  const user = {};
  user.username = old[userAttr.username];
  user.password = old[userAttr.password];
  user.firstName = old[userAttr.firstname];
  user.lastName = old[userAttr.lastname];
  user.displayName = old[userAttr.displayname];
  user.primaryEmail = old[userAttr.email];
  return user;
};

// Helper function that searches the user records in LDAP.
const searchUser = (username, cb) => {
  const attributes = [
    userAttr.username,
    userAttr.firstname,
    userAttr.lastname,
    userAttr.displayname,
    userAttr.email,
    userAttr.password,
  ];
  searchRaw(username, attributes, (err, old) => {
    if (err) {
      return cb(err);
    }
    if (_.isEmpty(old)) {
      return cb(null, null);
    } else if (_.isArray(old)) {
      async.map(old, (el, callback) => {
        callback(null, convertUser(el));
      }, (err, users) => cb(null, users))
    } else {
      return cb(null, convertUser(old));
    }
  });
};

// Helper function that gets the groups that the user belongs to
const searchGroups = (username, cb) => {
  const base = groupAttr.baseDn;
  const options = {
    filter: `(${groupAttr.member}=${userAttr.rdn}=${username},${userAttr.baseDn})`,
    scope: 'sub',
    attributes: [groupAttr.rdn],
  };

  client.search(base, options, (err, res) => {
    if (err) {
      return cb(err);
    }
    const groups = [];
    res.on('searchEntry', entry => {
      groups.push(entry.object[groupAttr.rdn]);
    });
    res.on('error', err => {
      log.error(err);
      return cb(err);
    });
    res.on('end', result => {
      log.debug(`ldap search ended with status: ${result.status}`);
      return cb(null, groups);
    });
  });
};

/* Public API functions */

/**
 * Authenticates user to LDAP
 * @param  {string}   username  User's username
 * @param  {string}   pass      User's password in cleartext (maybe unsafe)
 * @param  {Function} cb        cb(err)
 */
exports.authenticate = (username, pass, cb) => {
  log.debug(`${username}: will authenticate`);
  // client used for authenticating users specially
  const userClient = ldap.createClient({
    url: url,
  });
  const userdn = `${userAttr.rdn}=${username},${userAttr.baseDn}`;
  userClient.bind(userdn, pass, err => {
    userClient.unbind();
    return cb(err);
  });
};


/**
 * Get a user's info from LDAP server based on the username
 * @param  {string}   username  User's username
 * @param  {Function} cb        cb(err, user)
 */
exports.getUser = (username, cb) => {
  log.debug(`check validity of username ${username}`);
  if (!userAttr.usernameRegex.test(username)) {
    return cb(new Error('Illegal username specified'));
  }

  async.parallel([
    searchUser.bind(null, username),
    searchGroups.bind(null, username),
  ], (err, results) => {
    if (err) {
      return cb(err);
    }
    const user = results[0];
    const groups = results[1];
    if (_.isEmpty(user)) {
      log.debug(`user: ${username} not found`);
      return cb(null, null);
    }
    user.groups = groups;
    return cb(null, user);
  });
};

/**
 * Get users list from LDAP
 * @param {Function} cb cb(err, users)
 */
exports.getAllUsers = cb => searchUser('*', (err, users) => {
  if (users && !_.isArray(users)) {
    user = [users];
  }
  async.map(users, (user, callback) => {
    if (user.username) {
      searchGroups(user.username, (err, groups) => {
        user.groups = groups;
        callback(null, user);
      });
    } else {
      callback();
    }
  }, (err, results) => {
    cb(err, _.reject(results, el => !el));
  });
});


// Get the changes used for updating a user
// Note that the username and password couldn't be changed this way
const getChanges = (newUser, oldUser) => {
  const ret = [];
  const attrs = ['firstName', 'lastName', 'displayName', 'primaryEmail', ];
  const ldapNames = [userAttr.firstname, userAttr.lastname, userAttr.displayname,
    userAttr.email,
  ];
  for (let i = 0; i < attrs.length; ++i) {
    const attr = attrs[i];
    const ldapAttr = ldapNames[i];
    const item = {
      modification: {},
    };

    if (_.isEmpty(oldUser[attr]) && !_.isEmpty(newUser)) {
      // this should be impossible in normal condtion
      item.operation = 'add';
    } else if (oldUser[attr] === newUser[attr]) { //same
      continue;
    } else {
      item.operation = 'replace';
    }
    item.modification[ldapAttr] = newUser[attr];
    ret.push(item);
  }
  return ret;
};

/**
 * Update a existing user's memberships
 * @param  {Object}   user valid user object
 * @param  {Function} cb   cb(err, newUser)
 */
exports.updateUser = (user, cb) => {
  // first check the validity
  checkAndConvert(user);
  const userDn = `${userAttr.rdn}=${user.username},${userAttr.baseDn}`;

  // get the differences
  const diff = (oldUser, next) => {
    const oldGroups = oldUser.groups;
    const added = _.difference(user.groups, oldGroups);
    const removed = _.difference(oldGroups, user.groups);
    const changes = getChanges(user, oldUser);
    return next(null, changes, added, removed);
  };

  const updateU = (changes, added, removed, next) => {
    if (_.isEmpty(changes)) {
      return next(null, added, removed);
    }
    client.modify(userDn, changes, err => {
      if (err) {
        return next(err);
      }
      return next(null, added, removed);
    });
  };

  // update the groups
  const operateOnGroups = (groups, change, callback) => {
    async.each(groups, (group, next) => {
      const groupDn = `${groupAttr.rdn}=${group},${groupAttr.baseDn}`;
      client.modify(groupDn, change, next);
    }, callback);
  };

  const updateG = (added, removed, next) => {
    // add
    const addChange = {
      operation: 'add',
      modification: {},
    };
    addChange.modification[groupAttr.member] = userDn;
    // remove
    const removeChange = {
      operation: 'delete',
      modification: {},
    };
    removeChange.modification[groupAttr.member] = userDn;
    async.parallel([
      operateOnGroups.bind(null, added, addChange),
      operateOnGroups.bind(null, removed, removeChange),
    ], next);
  };

  async.waterfall([
    exports.getUser.bind(null, user.username),
    diff,
    updateU,
    updateG,
  ], err => {
    if (err) {
      return cb(err);
    }
    exports.getUser(user.username, cb);
  });
};

/**
 * Add a user into LDAP
 * @param {Object}   user Valid user object with neccessary attributes
 *                        i.e. username, password, firstName, lastName
 *                        displayName, primaryEmail, groups.
 *                        It will report error when the username or primaryEmail
 *                        is invalid.
 * @param {Function} cb   cb(err, newUser), newUser with groups
 */
exports.addUser = (user, cb) => {
  const userobj = checkAndConvert(user);
  if (!userAttr.usernameRegex.test(user.username)) {
    return cb(new Error('Illegal username specified'));
  }
  if (!conf.email.validation.emailRegex.test(user.primaryEmail)) {
    return cb(new Error('Illegal email specified'));
  }

  const dn = `${userAttr.rdn}=${user.username},${userAttr.baseDn}`;

  async.waterfall([
    next => {
      client.add(dn, userobj, err => next(err));
    },
    exports.updateUser.bind(null, user),
  ], cb);
};

/**
 * Delete a user and its relevent memberships records
 * @param  {string}   username a valid username
 * @param  {Function} cb       cb(err)
 */
exports.deleteUser = (username, cb) => {
  async.waterfall([
    exports.getUser.bind(null, username),
    function check(user, next) {
      if (!user) {
        return cb(null);
      }
      return next(null, user);
    },
    function updateGroups(user, next) {
      if (!user) {
        return next(null);
      }
      user.groups = [];
      exports.updateUser(user, next);
    },
    function delUser(user, next) {
      const dn = `${userAttr.rdn}=${user.username},${userAttr.baseDn}`;
      client.del(dn, next);
    },
  ], cb);
};

/**
 * Reset a new password for a specified user
 * @param  {string}   username A valid username
 * @param  {Function} cb       cb(err)
 */
exports.resetPassword = (username, newPass, cb) => {
  if (!userAttr.usernameRegex.test(username)) {
    return cb(new Error('Illegal username specified'));
  }

  // first check the existence
  const search = next => {
    searchRaw(username, 'pwdPolicySubentry', (err, user) => {
      if (err) {
        return next(err);
      }
      return next(null, user);
    });
  };

  const modifyPolicy = (entry, next) => {
    const change = {
      modification: {
        pwdPolicySubentry: userAttr.passwordResetPolicy,
      },
    };
    if (entry.pwdPolicySubentry) {
      log.warn(`"${username}" already has a pwdPolicySubentry attribute, which is not supposed to appear on users (!)`);
      change.operation = 'replace';
    } else {
      change.operation = 'add';
    }

    client.modify(entry.dn, change, err => {
      if (err) {
        return next(err);
      }
      return next(null, entry.dn);
    });
  };

  const update = (userdn, next) => {
    const changes = [{
      operation: 'replace',
      modification: {
        userPassword: newPass,
      },
    }, {
      operation: 'delete',
      modification: {
        pwdPolicySubentry: userAttr.passwordResetPolicy,
      },
    }, ];

    client.modify(userdn, changes, err => next(err));
  };

  async.waterfall([
    search,
    modifyPolicy,
    update,
  ], cb);
};

/**
 * Add lock time attribute for a user
 * @param  {string}   username valid username
 * @param  {Function} cb       cb(err)
 */
exports.lockoutUser = (username, cb) => {
  // find the user
  const search = next => {
    searchRaw(username, 'pwdAccountLockedTime', (err, user) => {
      if (err) {
        return next(err);
      }
      return next(null, user);
    });
  };

  const modify = (userobj, next) => {
    if (userobj.pwdAccountLockedTime) {
      log.debug(`${username} is already locked`);
      return cb();
    }
    const userdn = `${userAttr.rdn}=${username},${userAttr.baseDn}`;
    const change = {
      operation: 'add',
      modification: {
        pwdAccountLockedTime: '000001010000Z', // lock user permanently
      },
    };
    client.modify(userdn, change, err => next(err));
  };

  async.waterfall([
    search,
    modify,
  ], cb);
};

/**
 * Unlock a user
 * @param  {string}   username
 * @param  {Function} cb       cb(err)
 */
exports.enableUser = (username, cb) => {
  // find the user
  const search = next => {
    searchRaw(username, 'pwdAccountLockedTime', (err, user) => {
      if (err) {
        return next(err);
      }
      return next(null, user);
    });
  };

  const modify = (userobj, next) => {
    if (_.isEmpty(userobj.pwdAccountLockedTime)) {
      log.debug(`${username} is not locked`);
      return cb();
    }
    const userdn = `${userAttr.rdn}=${username},${userAttr.baseDn}`;
    const change = {
      operation: 'delete',
      modification: {
        pwdAccountLockedTime: [],
      },
    };
    client.modify(userdn, change, err => next(err));
  };

  async.waterfall([
    search,
    modify,
  ], cb);
};


/**
 * Add a group into LDAP
 * @param {object}   options    {groupName: ... , description: ... }
 * @param {Function} callback   callback(err)
 */
exports.addGroup = (options, callback) => {
  if (_.isEmpty(options.groupName)) {
    throw new Error('missing groupName');
  }
  log.info(`adding group ${options.groupName} to LDAP`);
  const entry = {
    cn: options.groupName,
    objectClass: groupAttr.objectClass,
    member: [''],
  };
  if (!_.isEmpty(options.description)) {
    entry.description = options.description;
  }

  const dn = `${groupAttr.rdn}=${options.groupName},${groupAttr.baseDn}`;
  client.add(dn, entry, callback);
};