UNPKG

happn-3

Version:

pub/sub api as a service using primus and mongo & redis or nedb, can work as cluster, single process or embedded using nedb

610 lines (529 loc) 20.3 kB
const commons = require('happn-commons'); const CONSTANTS = commons.constants; const uuid = commons.uuid; const SD_EVENTS = CONSTANTS.SECURITY_DIRECTORY_EVENTS; const PermissionManager = require('./permissions'); const util = commons.utils; const nodeUtil = require('util'); const UsersByGroupCache = require('./users-by-group-cache'); function SecurityUsers(opts) { this.log = opts.logger.createLogger('SecurityUsers'); this.log.$$TRACE('construct(%j)', opts); this.opts = opts; } SecurityUsers.prototype.initialize = util.maybePromisify(initialize); SecurityUsers.prototype.clearCaches = clearCaches; SecurityUsers.prototype.__validate = __validate; SecurityUsers.prototype.__upsertUser = __upsertUser; SecurityUsers.prototype.__getUserNamesFromGroupLinks = __getUserNamesFromGroupLinks; SecurityUsers.prototype.__linkGroupsToUser = __linkGroupsToUser; SecurityUsers.prototype.getPasswordHash = util.maybePromisify(getPasswordHash); SecurityUsers.prototype.upsertUser = util.maybePromisify(upsertUser); SecurityUsers.prototype.upsertUserWithoutValidation = __upsertUser; SecurityUsers.prototype.deleteUser = deleteUser; SecurityUsers.prototype.getUser = util.maybePromisify(getUser); SecurityUsers.prototype.getUserNoGroups = nodeUtil.callbackify(getUserNoGroups); SecurityUsers.prototype.listUsers = util.maybePromisify(listUsers); SecurityUsers.prototype.listUserNamesByGroup = listUserNamesByGroup; SecurityUsers.prototype.listUsersByGroup = util.maybePromisify(listUsersByGroup); SecurityUsers.prototype.getGroupMemberships = getGroupMemberships; SecurityUsers.prototype.listPermissions = listPermissions; SecurityUsers.prototype.attachPermissions = attachPermissions; SecurityUsers.prototype.removePermission = removePermission; SecurityUsers.prototype.upsertPermission = upsertPermission; SecurityUsers.prototype.upsertPermissions = util.maybePromisify(upsertPermissions); SecurityUsers.prototype.userBelongsToGroups = util.maybePromisify(userBelongsToGroups); SecurityUsers.prototype.prepareUserName = prepareUserName; SecurityUsers.prototype.clearGroupUsersFromCache = clearGroupUsersFromCache; SecurityUsers.prototype.validateDeleteUser = validateDeleteUser; /** * Clones the passed in user, and generates a hash of the users password to be pushed into the data store. */ SecurityUsers.prototype.__prepareUserForUpsert = __prepareUserForUpsert; function initialize(config, securityService, callback) { try { this.securityService = securityService; this.permissionManager = PermissionManager.create(config, 'user', this.happn, securityService); this.cacheService = this.happn.services.cache; this.dataService = this.happn.services.data; this.utilsService = this.happn.services.utils; this.errorService = this.happn.services.error; this.cryptoService = this.happn.services.crypto; this.sessionService = this.happn.services.session; if (!config.__cache_users) config.__cache_users = { max: 10e3, maxAge: 0, }; this.__cache_users = this.cacheService.create('cache_security_users', { type: 'LRU', cache: config.__cache_users, }); this.__cache_passwords = this.cacheService.create('cache_security_passwords', { type: 'LRU', cache: config.__cache_users, }); if (!config.__cache_users_by_groups) { config.__cache_users_by_groups = { max: 10e3, maxAge: 0, }; } this.__cache_users_by_groups = UsersByGroupCache.create( this.cacheService, config.__cache_users_by_groups ); if (!config.__cache_groups_by_user) { config.__cache_groups_by_user = { max: 10e3, maxAge: 0, }; } this.__cache_groups_by_user = this.cacheService.create('cache_groups_by_user', { type: 'LRU', cache: config.__cache_groups_by_user, }); //backward compatibility after taking groups methods out this.unlinkGroup = this.groups.unlinkGroup.bind(this.groups); this.linkGroup = this.groups.linkGroup.bind(this.groups); this.getGroup = this.groups.getGroup.bind(this.groups); this.listGroups = this.groups.listGroups.bind(this.groups); this.deleteGroup = this.groups.deleteGroup.bind(this.groups); this.upsertGroup = this.groups.upsertGroup.bind(this.groups); if (config.usernamesCaseInsensitive) { if (!Array.isArray(config.usernamesCaseInsensitiveExclude)) config.usernamesCaseInsensitiveExclude = []; if (config.usernamesCaseInsensitiveExclude.indexOf('_ADMIN') === -1) config.usernamesCaseInsensitiveExclude.push('_ADMIN'); } this.config = config; callback(); } catch (e) { callback(e); } } function clearGroupUsersFromCache(groupName) { return this.listUserNamesByGroup(groupName).then((usernames) => { usernames.forEach((username) => { this.__cache_users.remove(username); }); }); } function clearCaches(whatHappnd, changedData) { if (whatHappnd == null) { this.__cache_passwords.clear(); this.__cache_users.clear(); this.__cache_groups_by_user.clear(); this.__cache_users_by_groups.clear(); this.permissionManager.cache.clear(); return; } if ( whatHappnd === SD_EVENTS.DELETE_GROUP || whatHappnd === SD_EVENTS.UNLINK_GROUP || whatHappnd === SD_EVENTS.LINK_GROUP ) { this.__cache_groups_by_user.clear(); let groupName; if (whatHappnd === SD_EVENTS.DELETE_GROUP) groupName = changedData.obj.name; if (whatHappnd === SD_EVENTS.LINK_GROUP) groupName = changedData._meta.path.split('/').pop(); if (whatHappnd === SD_EVENTS.UNLINK_GROUP) groupName = changedData.path.split('/').pop(); this.__cache_users_by_groups.groupChanged(groupName); return this.clearGroupUsersFromCache(groupName); } if (whatHappnd === SD_EVENTS.PERMISSION_REMOVED || whatHappnd === SD_EVENTS.PERMISSION_UPSERTED) { if (changedData.username) { this.__cache_users.remove(changedData.username); if (this.permissionManager) { this.permissionManager.cache.remove(changedData.username); } } } if (whatHappnd === SD_EVENTS.UPSERT_USER || whatHappnd === SD_EVENTS.DELETE_USER) { const userName = whatHappnd === SD_EVENTS.UPSERT_USER ? changedData.username : changedData.obj._meta.path.replace('/_SYSTEM/_SECURITY/_USER/', ''); this.__cache_users_by_groups.userChanged(userName); this.__cache_passwords.remove(userName); this.__cache_users.remove(userName); this.__cache_groups_by_user.remove(userName); if (this.permissionManager) this.permissionManager.cache.remove(userName); } } function __validate(validationType, options, obj, callback) { if (obj.name) { this.securityService.validateName(obj.name || obj.username, validationType); } if (validationType === 'user') { if (!obj.username) return callback(new Error('validation failure: no username specified')); if (obj.username.indexOf(':nogroups') > -1) { return callback( new Error("validation failure: username cannot contain the ':nogroups' directive") ); } if (obj.username === '_ANONYMOUS') { return callback(new Error('validation failure: username cannot be reserved name _ANONYMOUS')); } if (!obj.password && !obj.publicKey) { return this.dataService.get('/_SYSTEM/_SECURITY/_USER/' + obj.username, {}, (e, result) => { if (e) return callback(e); if (!result) return callback( new Error('validation failure: no password or publicKey specified for a new user') ); this.securityService.checkOverwrite( validationType, obj, '/_SYSTEM/_SECURITY/_USER/' + obj.username, obj.username, options, callback ); }); } return this.securityService.checkOverwrite( validationType, obj, '/_SYSTEM/_SECURITY/_USER/' + obj.username, obj.username, options, callback ); } return callback(new Error('Unknown validation type: ' + validationType)); } function getPasswordHash(username, callback) { const hash = this.__cache_passwords.get(username); if (hash) return callback(null, hash); this.dataService.get('/_SYSTEM/_SECURITY/_USER/' + username, (e, user) => { if (e) return callback(e); if (!user) return callback(new Error(username + ' does not exist in the system')); this.__cache_passwords.set(user.data.username, user.data.password); callback(null, user.data.password); }); } function __prepareUserForUpsert(user) { return new Promise((resolve, reject) => { this.getUserNoGroups(user.username, (e, existing) => { if (e) return reject(e); var clonedUser = this.utilsService.clone(user); //we are passing the back to who knows where and it lives here in the cache... if (!existing) clonedUser.userid = uuid.v4(); if (!user.password) return resolve(clonedUser); return this.cryptoService.generateHash( user.password, this.config.pbkdf2Iterations, (e, hash) => { if (e) return reject(e); clonedUser.password = hash; resolve(clonedUser); } ); }); }); } async function __upsertUser(user) { const permissions = this.utilsService.clone(user.permissions); const prepared = await this.__prepareUserForUpsert(user); delete prepared.permissions; //Don't store in DB const result = await this.dataService.upsert( '/_SYSTEM/_SECURITY/_USER/' + prepared.username, prepared, { merge: true } ); this.log.debug(`user upserted: ${prepared.username}`); const upserted = this.securityService.serialize('user', result); upserted.permissions = permissions || {}; if (permissions) await this.permissionManager.upsertMultiplePermissions(upserted.username, permissions); await this.securityService.dataChanged( CONSTANTS.SECURITY_DIRECTORY_EVENTS.UPSERT_USER, upserted, user ); if (!permissions) return upserted; upserted.permissions = permissions; return upserted; } function upsertUser(user, options, callback) { if (typeof options === 'function') callback = options; if (typeof user !== 'object' || user == null) return callback(new Error('user is null or not an object')); user.username = this.prepareUserName(user.username); this.__validate('user', options, user, async (e) => { if (e) return callback(e); try { let upserted = await this.__upsertUser(user, options); callback(null, upserted); } catch (e) { callback(e); } }); } async function upsertPermissions(user, callback) { try { await this.permissionManager.upsertMultiplePermissions(user.username, user.permissions); await this.securityService.dataChanged(CONSTANTS.SECURITY_DIRECTORY_EVENTS.UPSERT_USER, user); // Using the upsert event, as the logic is the same. } catch (e) { callback(e); return; } callback(null, user); } function validateDeleteUser(username) { return ['_ADMIN', '_ANONYMOUS'].indexOf(username) === -1; } function throwOrReturnOrCallback(result, callback) { if (typeof callback !== 'function') { if (result instanceof Error) { throw result; } return result; } if (result instanceof Error) { callback(result); return; } callback(null, result); } async function deleteUser(user, callback) { if (typeof user !== 'object' || user == null) { throwOrReturnOrCallback(new Error('user is null or not an object'), callback); return; } const preparedUserName = this.prepareUserName(user.username); if (!this.validateDeleteUser(preparedUserName)) { throwOrReturnOrCallback( new Error(`unable to delete a user with the reserved name: ${preparedUserName}`), callback ); return; } let deleted; try { await this.permissionManager.removeAllUserPermissions(preparedUserName); const tree = await this.dataService.remove(`/_SYSTEM/_SECURITY/_USER/${preparedUserName}/*`); const obj = await this.dataService.remove(`/_SYSTEM/_SECURITY/_USER/${preparedUserName}`); this.log.debug(`user deleted: ${user.username}`); deleted = { obj, tree, }; await this.__cache_users.remove(preparedUserName); await this.__cache_passwords.remove(preparedUserName); this.securityService.dataChanged( CONSTANTS.SECURITY_DIRECTORY_EVENTS.DELETE_USER, deleted, null, (e) => { if (e) { this.log.error(`user delete failure to propagate event: ${e.message}`); } } ); } catch (e) { throwOrReturnOrCallback(e, callback); return; } return throwOrReturnOrCallback(deleted, callback); } function prepareUserName(userName, dontRemoveWildCards) { let preparedUserName = userName; if (!dontRemoveWildCards) preparedUserName = userName.replace(/[*]/g, ''); //remove any wildcards if ( this.config.usernamesCaseInsensitive && this.config.usernamesCaseInsensitiveExclude.indexOf(userName) === -1 ) preparedUserName = preparedUserName.toLowerCase(); return preparedUserName; } async function getUserNoGroups(userName) { const preparedUserName = this.prepareUserName(userName); if (this.__cache_users.has(preparedUserName)) { //we strip out the groups (cloning the cached user) - just in case it contains groups return commons._.omit(this.__cache_users.get(preparedUserName), 'groups'); } const userPath = '/_SYSTEM/_SECURITY/_USER/' + preparedUserName; const found = await this.dataService.get(userPath); if (!found) return null; const password = found.data.password; const returnUser = this.securityService.serialize('user', found); const attached = await this.permissionManager.attachPermissions(returnUser); this.__cache_users.set(preparedUserName, attached, { clone: false }); this.__cache_passwords.set(preparedUserName, password, { clone: false }); return attached; } function __linkGroupsToUser(user, callback) { user.groups = {}; this.getGroupMemberships(user.username, (e, userGroups) => { if (e) return callback(e); userGroups.forEach((userGroup) => { //we have found a group linked to the user, add the group, by its name to the groups object user.groups[userGroup.groupName] = userGroup.membership; }); callback(null, user); }); } function userBelongsToGroups(username, groupNames, callback) { if (!Array.isArray(groupNames)) { return callback(new Error('groupNames must be an array')); } // TODO: possible optimisation for caching at this level this.getGroupMemberships(username, (e, memberships) => { if (e) return callback(e); if (groupNames.length === 0) return callback(null, false); const userGroupNames = memberships.map((membership) => membership.groupName); const belongs = groupNames.every((element) => { return userGroupNames.includes(element); }); callback(null, belongs); }); } function getGroupMemberships(username, callback) { if (this.__cache_groups_by_user.has(username)) { return callback(null, this.__cache_groups_by_user.get(username)); } const userPath = `/_SYSTEM/_SECURITY/_USER/${username}`; this.dataService.get( `${userPath}/_USER_GROUP/*`, { sort: { path: 1, }, }, (e, userGroups) => { if (e) return callback(e); const filtered = userGroups .filter((userGroup) => { return userGroup._meta.path.indexOf(`${userPath}/`) === 0; }) .map((membership) => { return { groupName: membership._meta.path.replace(`${userPath}/_USER_GROUP/`, ''), membership: commons._.omit(membership, '_meta'), }; }); this.__cache_groups_by_user.set(username, filtered); callback(null, filtered); } ); } function getUser(userName, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } this.getUserNoGroups(userName, (e, user) => { if (e) return callback(e); if (!user) return callback(null, null); if (options.includeGroups === false) return callback(null, user); this.__linkGroupsToUser(user, callback); }); } function listUsers(userName, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } if (typeof userName === 'function') { callback = userName; userName = '*'; options = {}; } if (!options) options = {}; let preparedUserName = this.prepareUserName(userName, true); if (preparedUserName[preparedUserName.length - 1] !== '*') preparedUserName += '*'; const searchPath = '/_SYSTEM/_SECURITY/_USER/' + preparedUserName; options.clone = false; if (!options.criteria) { options.criteria = {}; } options.criteria.$and = [{ username: { $exists: true } }]; let searchParameters = { criteria: options.criteria, sort: options.sort, options: {}, }; if (options.limit) searchParameters.options.limit = options.limit; if (options.skip) searchParameters.options.skip = options.skip; if (options.collation) searchParameters.options.collation = options.collation; if (options.count) { this.dataService.count(searchPath, searchParameters, (e, results) => { if (e) return callback(e); callback(null, results.data); }); return; } this.dataService.get(searchPath, searchParameters, (e, results) => { if (e) return callback(e); var returnUsers = this.securityService.serializeAll('user', results, options); callback(null, returnUsers); }); } function __getUserNamesFromGroupLinks(userGroupLinks) { if (userGroupLinks == null) return []; let iterableUserGroupLinks = userGroupLinks.paths ? userGroupLinks.paths : userGroupLinks; return iterableUserGroupLinks.map((link) => { return link._meta.path.split('/_SYSTEM/_SECURITY/_USER/')[1].split('/_USER_GROUP/')[0]; }); } function listUserNamesByGroup(groupName) { return new Promise((resolve, reject) => { if (typeof groupName !== 'string') return reject(new Error('validation error: groupName must be specified')); let cachedResult; if (groupName.indexOf('*') === -1) { cachedResult = this.__cache_users_by_groups.getResult(groupName); if (cachedResult != null) return resolve(cachedResult); } this.dataService .get('/_SYSTEM/_SECURITY/_USER/*/_USER_GROUP/' + groupName, { path_only: true }) // TODO return to true once #180 is resolved .then((userGroupLinks) => { let result = this.__getUserNamesFromGroupLinks(userGroupLinks); if (groupName.indexOf('*') === -1) { this.__cache_users_by_groups.cacheResult(groupName, result); } resolve(result); }) .catch(reject); }); } function listUsersByGroup(groupName, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } if (typeof groupName !== 'string') return callback(new Error('validation error: groupName must be specified')); if (!options) options = {}; var userSearchCriteria = { $and: [] }; if (options.criteria) userSearchCriteria.$and.push(options.criteria); userSearchCriteria.$and.push({ username: { $exists: true } }); this.listUserNamesByGroup(groupName) .then((usernames) => { if (usernames.length === 0) return callback(null, []); userSearchCriteria.$and.push({ username: { $in: usernames } }); this.dataService.get( '/_SYSTEM/_SECURITY/_USER/*', { criteria: userSearchCriteria }, (e, results) => { if (e) return callback(e); callback(null, this.securityService.serializeAll('user', results, options)); } ); }) .catch(callback); } function listPermissions(username) { return this.permissionManager.listPermissions(username); } function attachPermissions(user) { return this.permissionManager.attachPermissions(user); } function removePermission(username, path, action) { return this.permissionManager.removePermission(username, path, action); } function upsertPermission(username, path, action, authorized) { return this.permissionManager.upsertPermission(username, path, action, authorized); } module.exports = SecurityUsers;