Source: server/middleware/auth/gmeauth.js

/*globals requireJS*/
/*eslint-env node*/

/**
 * @module Server:GMEAuth
 * @author ksmyth / https://github.com/ksmyth
 * @author pmeijer / https://github.com/pmeijer
 *
 * http://mongodb.github.io/node-mongodb-native/2.2/api/Collection.html
 */

var Mongodb = require('mongodb'),
    Q = require('q'),
    fs = require('fs'),
    //bcrypt = require('bcrypt'), include bcrypt and uncomment this line for faster encryption/decryption.
    bcrypt = require('bcryptjs'),
    jwt = require('jsonwebtoken'),
    Chance = require('chance'),
    mongoUri = require('mongo-uri'),
    MetadataStorage = require('../../storage/metadatastorage'),
    UTIL = requireJS('common/util/util'),
    EventDispatcher = requireJS('common/EventDispatcher'),
    GUID = requireJS('common/util/guid'),
    Logger = require('../../logger'),
    CONSTANTS = require('./constants'),
    chance = new Chance();

const crypto = require('crypto');
const _ = require('underscore');
const INFERRED_USER_EMAIL = 'em@il';
const getInferredIdFromEmail = email => {
    let uid = '_iuid_' + email;
    uid = uid.replace('@', '_at_').replace('.', '_p_');
    return uid;
};

function loadEncryptionKey(gmeConfig) {
    const { algorithm, key } = gmeConfig.authentication.encryption;

    const keyValue = Buffer.from(fs.readFileSync(key, 'utf-8').replace(/\r?\n$/, ''));
    try {
        crypto.createCipheriv(algorithm, keyValue, crypto.randomBytes(16));
    } catch (err) {
        throw new Error(`Error when loading encryption key: ${err.message}`);
    }

    return keyValue;
}

/**
 *
 * @param session
 * @param gmeConfig
 * @returns {{unload: unload, connect: connect, addUser: addUser, updateUser: updateUser,
 * updateUserDataField: updateUserDataField, updateUserSettings: updateUserSettings,
 * updateUserComponentSettings: updateUserComponentSettings, deleteUser: deleteUser,
 * getUser: getUser, listUsers: listUsers, addOrganization: addOrganization,
 * getOrganization: getOrganization, listOrganizations: listOrganizations,
 * removeOrganizationByOrgId: removeOrganizationByOrgId, addUserToOrganization: addUserToOrganization,
 * removeUserFromOrganization: removeUserFromOrganization,
 * setAdminForUserInOrganization: setAdminForUserInOrganization,
 * getAdminsInOrganization: getAdminsInOrganization, authenticateUser: authenticateUser,
 * generateJWToken: generateJWToken, generateJWTokenForAuthenticatedUser: generateJWTokenForAuthenticatedUser,
 * regenerateJWToken: regenerateJWToken, verifyJWToken: verifyJWToken, metadataStorage: MetadataStorage, authorizer: *,
 * CONSTANTS: {USER: string, ORGANIZATION: string}}}
 * @constructor
 */
function GMEAuth(session, gmeConfig) {
    'use strict';
    var self = this,
        logger = Logger.create('gme:server:auth:gmeauth', gmeConfig.server.log),
        _collectionName = '_users',
        _projectCollectionName = '_projects',
        client,
        dbName = mongoUri.parse(gmeConfig.mongo.uri).database,
        db,
        collection,
        projectCollection,
        metadataStorage = new MetadataStorage(logger, gmeConfig),
        TokenGenerator = require(gmeConfig.authentication.jwt.tokenGenerator),
        tokenGenerator = new TokenGenerator(logger, gmeConfig, jwt),
        Authorizer = require(gmeConfig.authentication.authorizer.path),
        authorizer = new Authorizer(logger, gmeConfig),
        // JWT Keys
        PUBLIC_KEY;

    EventDispatcher.call(this);

    if (gmeConfig.authentication.enable === true) {
        PUBLIC_KEY = fs.readFileSync(gmeConfig.authentication.jwt.publicKey, 'utf8');
    }

    /**
     * 'users' collection has these fields:
     * _id: username
     * email:
     * passwordHash: bcrypt hash of password
     * canCreate: authorized to create new projects
     * tokenId: token associated with account
     * tokenCreation: time of token creation (they may be configured to expire)
     * projects: map from project name to object {read:, write:, delete: }
     * orgs: array of orgIds
     */
    /**
     * '_organizations' collection has these fields:
     * _id: username
     * projects: map from project name to object {read:, write:, delete: }
     */
    function addMongoOpsToPromize(collection_) {
        // TODO: Drop this at some point - js has much better async support these days
        collection_.findOne = function () {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'findOne', args);
            });
        };
        collection_.find = function (/*query, projection*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'find', args);
            });
        };
        collection_.updateOne = function (/*query, update, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'updateOne', args);
            });
        };
        collection_.replaceOne = function (/*query, update, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'replaceOne', args);
            });
        };
        collection_.updateMany = function (/*query, update, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'updateMany', args);
            });
        };
        collection_.insertOne = function (/*data, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'insertOne', args);
            });
        };
        collection_.insertMany = function (/*data, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'insertMany', args);
            });
        };
        collection_.deleteOne = function (/*query, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'deleteOne', args);
            });
        };
        collection_.deleteMany = function (/*query, options*/) {
            var args = arguments;
            return collection_.then(function (c) {
                return Q.npost(c, 'deleteMany', args);
            });
        };
    }

    function _prepareGuestAccount(callback) {
        var guestAcc = gmeConfig.authentication.guestAccount,
            canCreate = gmeConfig.authentication.guestCanCreate;

        return collection.findOne({ _id: guestAcc })
            .then(function (userData) {
                if (userData) {
                    logger.debug('Guest user exists');
                } else {
                    logger.warn('User "' + guestAcc + '" was not found. ' +
                        'We will attempt to create it automatically.');

                    return addUser(guestAcc, guestAcc, guestAcc, canCreate, { overwrite: true, guestOrAdmin: true });
                }
            })
            .then(function () {
                if (gmeConfig.authentication.allowGuests) {
                    logger.debug('Guest access can be disabled by setting' +
                        ' gmeConfig.authentication.allowGuests = false');
                }

                // TODO: maybe guest's project authorization can come from gmeConfig
                // TODO: check if guest user has access to the default project or not.
                // TODO: grant access to guest account for default project
                return getUser(guestAcc);
            })
            .then(function (guestAccount) {
                logger.debug('Guest account "' + guestAccount._id + '" canCreate:', guestAccount.canCreate === true);
                logger.debug('Guest account full-data: ', { metadata: guestAccount });
                return Q.resolve(guestAccount);
            })
            .nodeify(callback);
    }

    function _prepareAdminAccount(callback) {
        var admin = gmeConfig.authentication.adminAccount,
            pieces,
            adminId,
            password;

        if (!admin) {
            return Q();
        }

        if (!gmeConfig.authentication.enable) {
            logger.warn('gmeConfig.authentication.adminAccount is specified but auth is disabled -' +
                ' will not create account!');
            return Q();
        }

        pieces = admin.split(':');
        adminId = pieces[0];
        password = pieces[1];

        return collection.findOne({ _id: adminId })
            .then(function (userData) {
                if (userData) {
                    logger.debug('Admin user exists [' + adminId + ']');
                } else {
                    password = password || (chance.word({ syllables: 4 }) + chance.natural({ min: 1, max: 999 }));
                    logger.warn('Creating admin "' + adminId + '" with password: ' + password);
                    logger.warn('To change password login at the profile page or use ' +
                        'webgme-engine/src/bin/usermanager.js');

                    return addUser(adminId, admin, password, true, {
                        overwrite: true,
                        siteAdmin: true,
                        guestOrAdmin: true
                    });
                }
            })
            .nodeify(callback);
    }

    function _preparePublicOrganizations(callback) {
        var publicOrgs = gmeConfig.authentication.publicOrganizations;

        if (publicOrgs.length === 0) {
            return Q();
        }

        if (!gmeConfig.authentication.enable) {
            logger.warn('gmeConfig.authentication.publicOrgs is specified but auth is disabled - ' +
                'will not create organizations');
            return Q();
        }

        return Q.all(publicOrgs.map(function (orgId) {
            return collection.findOne({ _id: orgId })
                .then(function (userData) {
                    if (userData) {
                        logger.debug('Public organization exists [' + orgId + ']');
                    } else {
                        logger.info('Creating public organization "' + orgId + '"');
                        return addOrganization(orgId);
                    }
                });
        }))
            .nodeify(callback);
    }

    /**
     *
     * @param {function} [callback]
     * @returns {*}
     */
    function connect(callback) {
        var collectionDeferred = Q.defer(),
            projectCollectionDeferred = Q.defer();

        projectCollection = projectCollectionDeferred.promise;
        collection = collectionDeferred.promise;

        addMongoOpsToPromize(collection);
        addMongoOpsToPromize(projectCollection);

        logger.info('connecting to:', gmeConfig.mongo.uri);
        logger.debug('mongdb options', gmeConfig.mongo.uri, JSON.stringify(gmeConfig.mongo.options));
        return Q.ninvoke(Mongodb.MongoClient, 'connect', gmeConfig.mongo.uri, gmeConfig.mongo.options)
            .then(function (client_) {
                client = client_;
                db = client.db(dbName);
                return Q.ninvoke(db, 'collection', _collectionName);
            })
            .then(function (collection_) {
                collectionDeferred.resolve(collection_);
                return Q.ninvoke(db, 'collection', _projectCollectionName);
            })
            .then(function (projectCollection_) {
                projectCollectionDeferred.resolve(projectCollection_);
                return _prepareGuestAccount();
            })
            .then(function () {
                return _prepareAdminAccount();
            })
            .then(function () {
                return _preparePublicOrganizations();
            })
            .then(function () {
                return Q.all([
                    authorizer.start({ collection: collection }),
                    metadataStorage.start({ projectCollection: projectCollection }),
                    tokenGenerator.start({})
                ]);
            })
            .then(function () {
                return db;
            })
            .catch(function (err) {
                logger.error(err);
                collectionDeferred.reject(err);
                projectCollectionDeferred.reject(err);
                throw err;
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {function} [callback]
     * @returns {*}
     */
    function unload(callback) {
        return Q.all([collection, projectCollection, authorizer.stop(), metadataStorage.stop(), tokenGenerator.stop()])
            .finally(function () {
                return Q.ninvoke(client, 'close');
            })
            .nodeify(callback);
    }

    function authenticateUser(userId, password, callback) {
        var userData;
        return collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } })
            .then(function (userData_) {
                userData = userData_;

                if (!userData) {
                    throw new Error('no such user [' + userId + ']');
                }

                if (userId === gmeConfig.authentication.guestAccount && gmeConfig.authentication.allowGuests === true) {
                    return Q(true);
                } else {
                    return Q.ninvoke(bcrypt, 'compare', password, userData.passwordHash);
                }
            })
            .then(function (hashRes) {
                if (hashRes) {
                    return userData;
                } else {
                    throw new Error('incorrect password');
                }
            })
            .nodeify(callback);
    }

    function generateJWToken(userId, password, callback) {
        logger.debug('Generating token for user:', userId, '..');

        return authenticateUser(userId, password)
            .then(function () {
                return tokenGenerator.getToken(userId);
            })
            .nodeify(callback);
    }

    function generateJWTokenForAuthenticatedUser(userId, callback) {
        logger.debug('Generating token for user:', userId, '..');

        return collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } })
            .then(function (userData) {
                if (!userData) {
                    throw new Error('no such user [' + userId + ']');
                }

                return tokenGenerator.getToken(userId);
            })
            .nodeify(callback);
    }

    function regenerateJWToken(token, callback) {
        logger.debug('Regenerate token..');
        return verifyJWToken(token)
            .then(function (result) {
                return tokenGenerator.getToken(result.content.userId);
            })
            .nodeify(callback);
    }

    function verifyJWToken(token, callback) {
        logger.debug('Verifying token..');
        let userId = null;
        let result = {
            content: null,
            renew: false,
        };
        return Q.ninvoke(jwt, 'verify', token, PUBLIC_KEY, { algorithms: [gmeConfig.authentication.jwt.algorithm] })
            .then(function (content) {
                result.content = content;
                userId = content.userId;
                logger.debug('Verified token!');
                // Check if token is about to expire...
                if (gmeConfig.authentication.jwt.renewBeforeExpires > 0 &&
                    content.exp - (Date.now() / 1000) < gmeConfig.authentication.jwt.renewBeforeExpires) {
                    logger.debug('Token is about to expire');
                    result.renew = true;
                }

                // If this option is turned on, we assume that the e-mail addresses are unique,
                // except the default inferred.
                if (gmeConfig.authentication.useEmailForId && content.email) {
                    return collection.findOne({
                        email: content.email,
                        type: { $ne: CONSTANTS.ORGANIZATION },
                        disabled: { $ne: true }
                    });
                } else {
                    return Q(null);
                }
            })
            .then(emailUserData => {
                if (emailUserData && gmeConfig.authentication.useEmailForId) {
                    userId = emailUserData._id;
                }
                return collection.findOne(
                    { _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } });
            })
            .then(completeUserData => {
                if (!completeUserData && (userId || result.content.email)) {
                    // We create a new user - an inferred one that is if we got email or userid
                    if (!userId && gmeConfig.authentication.useEmailForId) {
                        userId = getInferredIdFromEmail(result.content.email);
                    }

                    return self.addUser(
                        userId,
                        result.content.email || INFERRED_USER_EMAIL,
                        GUID(),
                        gmeConfig.authentication.inferredUsersCanCreate,
                        {
                            overwrite: false,
                            displayName: result.content.displayName
                        });
                } else if (completeUserData && gmeConfig.authentication.useEmailForId &&
                    result.content.email && completeUserData.email === INFERRED_USER_EMAIL) {
                    // We should update the userdata with the email
                    return self.addUser(
                        userId,
                        result.content.email,
                        GUID(),
                        gmeConfig.authentication.inferredUsersCanCreate,
                        {
                            overwrite: true,
                            displayName: result.content.displayName
                        });
                } else {
                    return Q(null);
                }
            })
            .then(() => {
                return self.getUser(userId);
            })
            .then(() => {
                result.content.userId = userId;
                return Q(result);
            })
            .nodeify(callback);
    }

    function _resolveQuery(query, extraQuery) {
        Object.keys(extraQuery || {}).forEach(function (key) {
            if (typeof extraQuery[key] === 'undefined') {
                delete query[key];
            } else {
                query[key] = extraQuery[key];
            }
        });
    }

    /**
     *
     * @param {string} userId
     * @param {object} [query]
     * @param {function} [callback]
     * @returns {*}
     */
    function getUser(userId, query, callback) {
        var query_ = { _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } };

        if (typeof query === 'function') {
            callback = query;
            query = null;
        }

        _resolveQuery(query_, query);

        return collection.findOne(query_)
            .then(function (userData) {
                if (!userData) {
                    return Q.reject(new Error('no such user [' + userId + ']'));
                }

                delete userData.passwordHash;
                delete userData.resetHash;
                delete userData.lastReset;

                userData.data = userData.data || {};
                userData.settings = userData.settings || {};

                return userData;
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} userId
     * @param {boolean} force - removes the user from the db completely
     * @param {function} [callback]
     * @returns {*}
     */
    function deleteUser(userId, force, callback) {
        if (force) {
            return collection.deleteOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION } })
                .then(function () {
                    return collection.updateMany({ admins: userId }, { $pull: { admins: userId } });
                })
                .then(function (res) {
                    self.dispatchEvent(CONSTANTS.USER_DELETED, { userId: userId });
                    return res;
                })
                .nodeify(callback);
        } else {
            return collection.updateOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION } }, {
                $set: { disabled: true }
            })
                .then(function (result) {
                    if (result.modifiedCount === 0) {
                        return Q.reject(new Error('no such user [' + userId + ']'));
                    }

                    return collection.updateMany({ admins: userId }, { $pull: { admins: userId } });
                })
                .then(function (res) {
                    self.dispatchEvent(CONSTANTS.USER_DELETED, { userId: userId });
                    return res;
                })
                .nodeify(callback);
        }
    }

    /**
     * Updates/overwrites provided fields for the userData.
     * @param {string} userId
     * @param {object} userData
     * @param {function} [callback]
     * @returns {*}
     */
    function updateUser(userId, userData, callback) {
        return collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } })
            .then(function (oldUserData) {
                if (!oldUserData) {
                    return Q.reject(new Error('no such user [' + userId + ']'));
                }

                oldUserData.email = userData.email || oldUserData.email;


                if (Object.hasOwn(userData, 'data')) {
                    if (UTIL.isTrueObject(userData.data)) {
                        oldUserData.data = userData.data;
                    } else {
                        throw new Error('supplied userData.data is not an object [' + userData.data + ']');
                    }
                }

                if (Object.hasOwn(userData, 'settings')) {
                    if (UTIL.isTrueObject(userData.settings)) {
                        oldUserData.settings = userData.settings;
                    } else {
                        throw new Error('supplied userData.settings is not an object [' + userData.settings + ']');
                    }
                }

                if (Object.hasOwn(userData, 'canCreate')) {
                    oldUserData.canCreate = userData.canCreate === 'true' || userData.canCreate === true;
                }

                if (Object.hasOwn(userData, 'siteAdmin')) {
                    oldUserData.siteAdmin = userData.siteAdmin === 'true' || userData.siteAdmin === true;
                }

                oldUserData.displayName = userData.displayName || oldUserData.displayName;

                // reset related fields
                oldUserData.resetHash = userData.resetHash;
                oldUserData.lastReset = userData.lastReset;

                if (userData.password) {
                    return Q.ninvoke(bcrypt, 'hash', userData.password, gmeConfig.authentication.salts)
                        .then(function (hash) {
                            oldUserData.passwordHash = hash;
                            return collection.replaceOne({ _id: userId }, oldUserData, { upsert: true });
                        });
                } else {
                    return collection.replaceOne({ _id: userId }, oldUserData, { upsert: true });
                }
            })
            .then(function () {
                return getUser(userId);
            })
            .nodeify(callback);
    }

    async function _updateUserObjectField(userId, keys, newValue, options = {}) {
        const isNestedField = keys.length > 1;
        const { overwrite, encrypt } = options;
        const jointKey = keys.join('.');

        const isValidValue = isNestedField || UTIL.isTrueObject(newValue);
        if (!isValidValue) {
            throw new Error(`object required for user ${jointKey}. Found [${newValue}]`);
        }

        const userData = await collection.findOne({
            _id: userId,
            type: { $ne: CONSTANTS.ORGANIZATION },
            disabled: { $ne: true }
        });

        if (!userData) {
            throw new Error('no such user [' + userId + ']');
        }

        let isNewlyCreated = false;
        let currentValue = keys.reduce((value, key) => {
            if (Object.hasOwn(value, key)) {
                return value[key];
            } else {
                isNewlyCreated = true;
                return {};
            }
        }, userData);

        if (encrypt) {
            newValue = _encrypt(newValue);
        }

        const areBothObjects = UTIL.isTrueObject(newValue) &&
            UTIL.isTrueObject(currentValue);
        const isUpdatingObject = !overwrite && areBothObjects;

        if (isUpdatingObject) {
            UTIL.updateFieldsRec(currentValue, newValue);
        } else if (overwrite || isNewlyCreated) {
            currentValue = newValue;
        }

        const update = {};
        update.$set = {};
        update.$set[jointKey] = currentValue;

        await collection.updateOne({ _id: userId }, update, { upsert: true });
        return getUser(userId);
    }

    async function _deleteUserObjectField(userId, keys) {
        const jointKey = keys.join('.');
        const update = {};

        const isNestedKey = keys.length > 1;
        if (isNestedKey) {
            update.$unset = {};
            update.$unset[jointKey] = '';
        } else {
            update.$set = {};
            update.$set[jointKey] = {};
        }

        await collection.updateOne({ _id: userId }, update, { upsert: true });
    }

    const { algorithm } = gmeConfig.authentication.encryption;
    const key = loadEncryptionKey(gmeConfig);
    function _encrypt(input) {
        const type = typeof input;
        if (type === 'object') {
            return _.mapObject(input, _encrypt);
        } else if (type === 'string') {
            return _encryptString(input);
        } else {
            throw new Error('Unsupported data type for encryption: ' + type);
        }
    }

    function _encryptString(text) {
        const iv = crypto.randomBytes(16);
        const cipher = crypto.createCipheriv(algorithm, key, iv);
        let encrypted = cipher.update(text);
        encrypted = Buffer.concat([encrypted, cipher.final()]);
        return { iv: iv.toString('hex'), encryptedData: encrypted.toString('hex') };
    }

    function _decrypt(encrypted) {
        if (!encrypted) {
            return encrypted;
        }

        const isEncryptedData = encrypted.iv && encrypted.encryptedData;
        const isEncryptedObject = !isEncryptedData &&
            typeof encrypted === 'object' && !_.isArray(encrypted);
        if (isEncryptedObject) {
            return _.mapObject(encrypted, _decrypt);
        } else if (isEncryptedData) {
            return _decryptData(encrypted);
        } else {
            return encrypted;
        }
    }

    function _decryptData(encrypted) {
        const iv = Buffer.from(encrypted.iv, 'hex');
        const encryptedData = Buffer.from(encrypted.encryptedData, 'hex');
        const decipher = crypto.createDecipheriv(algorithm, key, iv);
        let decrypted = decipher.update(encryptedData);
        decrypted = Buffer.concat([decrypted, decipher.final()]);
        return _removeTrailingAcks(decrypted.toString());
    }

    function _removeTrailingAcks(text) {
        /* eslint-disable-next-line no-control-regex */
        return text.replace(/\u0006+$/g, '');
    }

    /**
     * Updates the provided fields in data (recursively) within userData.data.
     * @param {string} userId
     * @param {object} data
     * @param {boolean} [overwrite]  - if true the settings for the key will be overwritten.
     * @param {function} [callback]
     * @returns {*}
     */
    function updateUserDataField(userId, data, overwrite, callback) {
        const deferred = Q.defer();
        _updateUserObjectField(userId, ['data'], data, { overwrite })
            .then((userData) => {
                deferred.resolve(userData.data);
            })
            .catch(deferred.reject);

        return deferred.promise.nodeify(callback);
    }

    async function setUserDataField(userId, fields, data, options = {}) {
        if (typeof fields === 'string') {
            fields = [fields];
        }
        fields = ['data'].concat(fields);
        const userData = await _updateUserObjectField(userId, fields, data, options);
        return userData.data;
    }

    async function deleteUserDataField(userId, fields) {
        if (typeof fields === 'string') {
            fields = [fields];
        }
        fields = ['data'].concat(fields);
        return _deleteUserObjectField(userId, fields);
    }

    async function getUserDataField(userId, fields = []) {
        if (typeof fields === 'string') {
            fields = [fields];
        }
        const query = {
            _id: userId,
            type: { $ne: CONSTANTS.ORGANIZATION },
            disabled: { $ne: true }
        };
        const jointKey = fields.join('.');
        const user = await collection.findOne(query);
        let result = user.data;
        fields.forEach(key => {
            if (!Object.hasOwn(result, key)) {
                throw new Error(`User data field not found: ${jointKey}`);
            }
            result = result[key];
        });

        return _decrypt(result);
    }

    /**
     * Updates the provided fields in the settings stored at given componentId.
     * @param {string} userId
     * @param {string} componentId
     * @param {object} settings
     * @param {boolean} [overwrite] - if true the settings for the key will be overwritten.
     * @param {function} [callback]
     * @returns {*}
     */
    function updateUserComponentSettings(userId, componentId, settings, overwrite, callback) {
        const deferred = Q.defer();
        _updateUserObjectField(userId, ['settings', componentId], settings, { overwrite })
            .then((userData) => {
                deferred.resolve(userData.settings[componentId]);
            })
            .catch(deferred.reject);

        return deferred.promise.nodeify(callback);
    }

    /**
     * Updates the provided fields in the settings.
     * @param {string} userId
     * @param {object} settings
     * @param {boolean} [overwrite] - if true the settings for the key will be overwritten.
     * @param {function} [callback]
     * @returns {*}
     */
    function updateUserSettings(userId, settings, overwrite, callback) {
        const deferred = Q.defer();
        _updateUserObjectField(userId, ['settings'], settings, { overwrite })
            .then((userData) => {
                deferred.resolve(userData.settings);
            })
            .catch(deferred.reject);

        return deferred.promise.nodeify(callback);
    }

    /**
     *
     * @param {object} [query]
     * @param {object} [projection]
     * @param {function} [callback]
     * @returns {*}
     */
    function listUsers(query, projection, callback) {
        var query_ = { type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } };

        _resolveQuery(query_, query);

        if (typeof projection === 'function') {
            callback = projection;
            projection = undefined;
        }

        return collection.find(query_, projection)
            .then(function (users) {
                return Q.ninvoke(users, 'toArray');
            })
            .then(function (userDataArray) {
                var i;
                for (i = 0; i < userDataArray.length; i += 1) {
                    delete userDataArray[i].passwordHash;
                    delete userDataArray[i].resetHash;
                    delete userDataArray[i].lastReset;
                    userDataArray[i].data = userDataArray[i].data || {};
                    userDataArray[i].settings = userDataArray[i].settings || {};
                }
                return userDataArray;
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} userId
     * @param {string} email
     * @param {string} password
     * @param {boolean} [canCreate=false]
     * @param {object} options
     * @param {function} [callback]
     * @returns {*}
     */
    function addUser(userId, email, password, canCreate, options, callback) {
        // TODO: check user/orgId collision
        // FIXME: this will not update the users correctly
        var deferred = Q.defer(),
            rejected = false,
            data = {
                _id: userId,
                email: email,
                canCreate: canCreate,
                data: {},
                settings: {},
                projects: {},
                type: CONSTANTS.USER,
                orgs: [],
                siteAdmin: options.siteAdmin,
                displayName: options.displayName,
                disabled: false,
                aadId: options.aadId
            };

        if (Object.hasOwn(options, 'data')) {
            if (UTIL.isTrueObject(options.data)) {
                data.data = options.data;
            } else {
                deferred.reject(new Error('supplied userData.data is not an object [' + options.data + ']'));
                rejected = true;
            }
        }

        if (Object.hasOwn(options, 'settings')) {
            if (UTIL.isTrueObject(options.settings)) {
                data.settings = options.settings;
            } else {
                deferred.reject(new Error('supplied userData.settings is not an object [' + options.settings + ']'));
                rejected = true;
            }
        }

        if (typeof options.disabled === 'boolean') {
            data.disabled = options.disabled;
        }

        if (rejected === false) {
            Q.ninvoke(bcrypt, 'hash', password, gmeConfig.authentication.salts)
                .then(function (hash) {
                    data.passwordHash = hash;
                    return collection.findOne({ email: email, _id: { $ne: userId } });
                })
                .then(otherUserWithSameEmail => {
                    if (gmeConfig.authentication.useEmailForId
                        && otherUserWithSameEmail && email !== INFERRED_USER_EMAIL) {
                        throw new Error('email address already in use');
                    }

                    if (!options.overwrite) {
                        return collection.insertOne(data);
                    } else {
                        return collection.replaceOne({ _id: userId }, data, { upsert: true });
                    }
                })
                .then(function (res) {
                    var publicOrgs = gmeConfig.authentication.publicOrganizations.slice();

                    function addUserToPublicOrgs() {
                        var orgId = publicOrgs.pop();
                        if (orgId) {
                            return addUserToOrganization(userId, orgId)
                                .then(addUserToPublicOrgs);
                        }
                    }

                    // Do not dispatch if disabled user or existing user was overwritten.
                    if (!data.disabled && !(options.overwrite && res.matchedCount !== 0)) {
                        self.dispatchEvent(CONSTANTS.USER_CREATED, { userId: userId });
                        if (!options.guestOrAdmin && gmeConfig.authentication.enable) {
                            return addUserToPublicOrgs();
                        }
                    }
                })
                .then(function () {
                    return collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION } });
                })
                .then(function (userData) {
                    deferred.resolve(userData);
                })
                .catch(function (err) {
                    if (err.code === 11000) {
                        deferred.reject(new Error('user already exists [' + userId + ']'));
                    } else {
                        deferred.reject(err);
                    }
                });
        }

        return deferred.promise.nodeify(callback);
    }

    /**
     *
     * @param {string} userId
     * @param {function} [callback]
     * @returns {*}
     */
    function reEnableUser(userId, callback) {
        return collection.updateOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION } }, {
            $set: { disabled: false }
        })
            .then(function (result) {
                if (result.modifiedCount === 0) {
                    return Q.reject(new Error('no such user [' + userId + ']'));
                }

                self.dispatchEvent(CONSTANTS.USER_CREATED, { userId: userId });

                return getUser(userId);
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} orgId
     * @param {function} [callback]
     * @returns {*}
     */
    function addOrganization(orgId, info, callback) {
        return collection.insertOne({
            _id: orgId,
            projects: {},
            type: CONSTANTS.ORGANIZATION,
            admins: [],
            info: info || {}
        })
            .then(function () {
                self.dispatchEvent(CONSTANTS.ORGANIZATION_CREATED, { orgId: orgId });
                return getOrganization(orgId);
            })
            .catch(function (err) {
                if (err.code === 11000) {
                    throw new Error('user or org already exists [' + orgId + ']');
                } else {
                    throw err;
                }
            })
            .nodeify(callback);
    }

    function reEnableOrganization(orgId, callback) {
        return collection.updateOne({ _id: orgId, type: CONSTANTS.ORGANIZATION }, {
            $set: { disabled: false }
        })
            .then(function (result) {
                if (result.modifiedCount === 0) {
                    return Q.reject(new Error('no such organization [' + orgId + ']'));
                }

                self.dispatchEvent(CONSTANTS.ORGANIZATION_CREATED, { orgId: orgId });

                return getOrganization(orgId);
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} orgId
     * @param {object} info
     * @param {function} [callback]
     * @returns {*}
     */
    function updateOrganizationInfo(orgId, info, callback) {

        if (!UTIL.isTrueObject(info)) {
            throw new Error('supplied info is not an object [' + info + ']');
        }

        return collection.updateOne({ _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } },
            {
                $set: { info: info }
            })
            .then(function (res) {
                if (res.modifiedCount === 0) {
                    return Q.reject(new Error('no such organization [' + orgId + ']'));
                }

                return getOrganization(orgId);
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} orgId
     * @param {object} [query]
     * @param {function} [callback]
     * @returns {*}
     */
    function getOrganization(orgId, query, callback) {
        var query_ = { _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } };

        if (typeof query === 'function') {
            callback = query;
            query = null;
        }

        _resolveQuery(query_, query);

        return collection.findOne(query_)
            .then(function (org) {
                if (!org) {
                    return Q.reject(new Error('no such organization [' + orgId + ']'));
                }
                return [
                    org,
                    collection.find(
                        {
                            orgs: orgId,
                            type: { $ne: CONSTANTS.ORGANIZATION },
                            disabled: { $ne: true }
                        },
                        { _id: 1 })
                ];
            })
            .spread(function (org, users) {
                return [org, Q.ninvoke(users, 'toArray')];
            })
            .spread(function (org, users) {
                org.users = users.map(function (user) {
                    return user._id;
                });
                return org;
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {object} [query]
     * @param {function} [callback]
     * @returns {*}
     */
    function listOrganizations(query, callback) {
        var query_ = { type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } };

        _resolveQuery(query_, query);

        return collection.find(query_)
            .then(function (orgs) {
                return Q.ninvoke(orgs, 'toArray');
            })
            .then(function (organizationArray) {
                //TODO: See if there is a smarter query here (this sends one for each org).
                return Q.all(organizationArray.map(function (org) {
                    return collection.find(
                        {
                            orgs: org._id,
                            type: { $ne: CONSTANTS.ORGANIZATION },
                            disabled: { $ne: true }
                        },
                        { _id: 1 })
                        .then(function (users) {
                            return Q.ninvoke(users, 'toArray');
                        })
                        .then(function (users) {
                            org.users = users.map(function (user) {
                                return user._id;
                            });

                            return org;
                        });
                }));
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} orgId
     * @param {boolean} [force=false] - delete organization from db.
     * @param {function} [callback]
     * @returns {*}
     */
    function removeOrganizationByOrgId(orgId, force, callback) {
        if (force) {
            return collection.deleteOne({ _id: orgId, type: CONSTANTS.ORGANIZATION })
                .then(function () {
                    return collection.updateMany({ orgs: orgId }, { $pull: { orgs: orgId } });
                })
                .then(function (res) {
                    self.dispatchEvent(CONSTANTS.ORGANIZATION_DELETED, { orgId: orgId });
                    return res;
                })
                .nodeify(callback);
        } else {
            return collection.updateOne({ _id: orgId, type: CONSTANTS.ORGANIZATION },
                { $set: { disabled: true } })
                .then(function (result) {
                    if (result.modifiedCount === 0) {
                        return Q.reject(new Error('no such organization [' + orgId + ']'));
                    }

                    return collection.updateMany({ orgs: orgId }, { $pull: { orgs: orgId } });
                })
                .then(function (res) {
                    self.dispatchEvent(CONSTANTS.ORGANIZATION_DELETED, { orgId: orgId });
                    return res;
                })
                .nodeify(callback);
        }
    }

    /**
     *
     * @param {string} userId
     * @param {string} orgId
     * @param {function} [callback]
     * @returns {*}
     */
    function addUserToOrganization(userId, orgId, callback) {
        return collection.findOne({ _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } })
            .then(function (org) {
                if (!org) {
                    return Q.reject(new Error('no such organization [' + orgId + ']'));
                }
            })
            .then(function () {
                return collection.updateOne(
                    {
                        _id: userId,
                        type: { $ne: CONSTANTS.ORGANIZATION },
                        disabled: { $ne: true }
                    },
                    { $addToSet: { orgs: orgId } })
                    .then(function (result) {
                        if (result.matchedCount === 0) {
                            return Q.reject(new Error('no such user [' + userId + ']'));
                        }
                    });
            })
            .nodeify(callback);
    }

    /**
     *
     * @param userId
     * @param orgId
     * @param {function} [callback]
     * @returns {*}    
     */
    function removeUserFromOrganization(userId, orgId, callback) {
        return collection.findOne({ _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } })
            .then(function (org) {
                if (!org) {
                    return Q.reject(new Error('no such organization [' + orgId + ']'));
                }
            })
            .then(function () {
                return collection.updateOne(
                    { _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } },
                    { $pull: { orgs: orgId } }
                );
            })
            .nodeify(callback);
    }

    /**
     *
     * @param {string} userId
     * @param {string} orgId
     * @param {boolean} makeAdmin
     * @param {function} [callback]
     * @returns {*}
     */
    function setAdminForUserInOrganization(userId, orgId, makeAdmin, callback) {
        var admins;
        return getAdminsInOrganization(orgId)
            .then(function (admins_) {
                admins = admins_;
                return collection.findOne(
                    {
                        _id: userId,
                        type: { $ne: CONSTANTS.ORGANIZATION },
                        disabled: { $ne: true }
                    });
            })
            .then(function (user) {
                if (makeAdmin) {
                    if (!user) {
                        return Q.reject(new Error('no such user [' + userId + ']'));
                    }
                    return collection.updateOne(
                        { _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } },
                        { $addToSet: { admins: userId } }
                    );
                } else {
                    if (admins.indexOf(userId) > -1) {
                        return collection.updateOne(
                            { _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } },
                            { $pull: { admins: userId } }
                        );
                    }
                }
            })
            .nodeify(callback);
    }

    function getAdminsInOrganization(orgId, callback) {
        return collection.findOne({ _id: orgId, type: CONSTANTS.ORGANIZATION, disabled: { $ne: true } }, { admins: 1 })
            .then(function (org) {
                if (!org) {
                    return Q.reject(new Error('no such organization [' + orgId + ']'));
                }
                return org.admins;
            })
            .nodeify(callback);
    }

    function authorizeByUserId(userId, projectId, type, rights, callback) {
        var projectAuthParams = {
            entityType: authorizer.ENTITY_TYPES.PROJECT
        };

        logger.warn('authorizeByUserId/authorizeByUserOrOrgId are deprecated use authorizer.setAccessRights instead!');

        return authorizer.setAccessRights(userId, projectId, rights, projectAuthParams).nodeify(callback);
    }

    function generateRandomString(length) {
        const chars = '1234567890qwertyuiopasdfghjklzxcvbnmQAZWSXEDCRFVTGBYHNUJMIKLOP';
        let result = '';
        for (let i = 0; i < length; i += 1) {
            result += chars.charAt(Math.floor(Math.random() * Math.floor(chars.length)));
        }

        return result;
    }

    function resetPassword(userId, callback) {
        let resetId = null;
        let userData = {};
        const now = new Date();
        const deferred = Q.defer();

        collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } })
            .then(userData_ => {
                if (!userData_) {
                    throw new Error('Cannot find user: ' + userId);
                }

                userData = userData_;
                const then = userData.lastReset ? Number(userData.lastReset) : 0;
                if (!gmeConfig.authentication.allowPasswordReset) {
                    throw new Error('Password reset is not allowed!');
                }

                if (userId === gmeConfig.authentication.guestAccount) {
                    throw new Error('Password reset is not allowed for GUEST account!');
                }

                if (now - then < gmeConfig.authentication.allowedResetInterval) {
                    throw new Error('cannot reset password just yet!');
                }

                if (userData.email === INFERRED_USER_EMAIL) {
                    throw new Error('cannot change inferred user data!');
                }

                return generateRandomString(30);
            })
            .then(function (hash) {
                userData.resetHash = hash;
                userData.lastReset = now;
                userData.passwordHash = null;
                resetId = hash;
                return updateUser(userId, userData);
            })
            .then(() => {
                deferred.resolve(resetId);
            })
            .catch(deferred.reject);

        return deferred.promise.nodeify(callback);
    }

    function changePassword(userId, resetHash, newPassword, callback) {
        const deferred = Q.defer();
        const now = new Date();

        collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } })
            .then(userData => {

                if (!userData) {
                    throw new Error('Cannot find user: ' + userId);
                }

                const then = userData.lastReset ? Number(userData.lastReset) : 0;
                if (!gmeConfig.authentication.allowPasswordReset) {
                    throw new Error('Password reset is not allowed!');
                }

                if (userId === gmeConfig.authentication.guestAccount) {
                    throw new Error('Password reset is not allowed for GUEST account!');
                }

                if (now - then > gmeConfig.authentication.resetTimeout) {
                    throw new Error('Your reset token has expired!');
                }

                if (resetHash !== userData.resetHash) {
                    throw new Error('Invalid reset id!');
                }

                userData.password = newPassword;
                userData.resetHash = null;

                return updateUser(userId, userData);
            })
            .then(deferred.resolve)
            .catch(deferred.reject);

        return deferred.promise.nodeify(callback);
    }

    function isValidReset(userId, resetHash, callback) {
        const deferred = Q.defer();
        const now = new Date();
        collection.findOne({ _id: userId, type: { $ne: CONSTANTS.ORGANIZATION }, disabled: { $ne: true } })
            .then(userData => {
                if (!userData) {
                    throw new Error('Cannot find user: ' + userId);
                }

                const then = userData.lastReset ? Number(userData.lastReset) : 0;
                if (!gmeConfig.authentication.allowPasswordReset) {
                    throw new Error('Password reset is not allowed!');
                }

                if (userId === gmeConfig.authentication.guestAccount) {
                    throw new Error('Password reset is not allowed for GUEST account!');
                }

                if (now - then > gmeConfig.authentication.resetTimeout) {
                    throw new Error('Your reset token has expired!');
                }

                if (resetHash !== userData.resetHash) {
                    throw new Error('Invalid reset id!');
                }


            })
            .then(deferred.resolve)
            .catch(deferred.reject);

        return deferred.promise.nodeify(callback);
    }

    this.unload = unload;
    this.connect = connect;

    this.listUsers = listUsers;
    this.getUser = getUser;
    this.addUser = addUser;
    this.updateUser = updateUser;
    this.updateUserDataField = updateUserDataField;
    this.setUserDataField = setUserDataField;
    this.getUserDataField = getUserDataField;
    this.deleteUserDataField = deleteUserDataField;
    this.updateUserSettings = updateUserSettings;
    this.updateUserComponentSettings = updateUserComponentSettings;
    this.deleteUser = deleteUser;
    this.reEnableUser = reEnableUser;
    this.resetPassword = resetPassword;
    this.changePassword = changePassword;
    this.isValidReset = isValidReset;

    this.listOrganizations = listOrganizations;
    this.getOrganization = getOrganization;
    this.addOrganization = addOrganization;
    this.updateOrganizationInfo = updateOrganizationInfo;
    this.deleteOrganization = this.removeOrganizationByOrgId = removeOrganizationByOrgId;
    this.reEnableOrganization = reEnableOrganization;

    this.getAdminsInOrganization = getAdminsInOrganization;
    this.addUserToOrganization = addUserToOrganization;
    this.removeUserFromOrganization = removeUserFromOrganization;
    this.setAdminForUserInOrganization = setAdminForUserInOrganization;

    this.authenticateUser = authenticateUser;
    this.generateJWToken = generateJWToken;
    this.generateJWTokenForAuthenticatedUser = generateJWTokenForAuthenticatedUser;
    this.regenerateJWToken = regenerateJWToken;
    this.verifyJWToken = verifyJWToken;

    this.metadataStorage = metadataStorage;
    this.authorizer = authorizer;

    this.CONSTANTS = CONSTANTS;

    // These are left in order to not break all tests.
    this.authorizeByUserId = authorizeByUserId;
    this.authorizeByUserOrOrgId = authorizeByUserId;
}

// Inherit from EventDispatcher
GMEAuth.prototype = Object.create(EventDispatcher.prototype);
GMEAuth.prototype.constructor = GMEAuth;

module.exports = GMEAuth;