diff --git a/app/lib/server/functions/setUserAvatar.js b/app/lib/server/functions/setUserAvatar.js index 5bcd36224583..8fa18240cf18 100644 --- a/app/lib/server/functions/setUserAvatar.js +++ b/app/lib/server/functions/setUserAvatar.js @@ -7,7 +7,7 @@ import { Users } from '../../../models/server'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { api } from '../../../../server/sdk/api'; -export const setUserAvatar = function(user, dataURI, contentType, service) { +export const setUserAvatar = function(user, dataURI, contentType, service, etag = null) { let encoding; let image; @@ -64,8 +64,8 @@ export const setUserAvatar = function(user, dataURI, contentType, service) { fileStore.insert(file, buffer, (err, result) => { Meteor.setTimeout(function() { - Users.setAvatarData(user._id, service, result.etag); - api.broadcast('user.avatarUpdate', { username: user.username, avatarETag: result.etag }); + Users.setAvatarData(user._id, service, etag || result.etag); + api.broadcast('user.avatarUpdate', { username: user.username, avatarETag: etag || result.etag }); }, 500); }); }; diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index f1c0560f3a7e..a021a91f25e6 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -150,12 +150,24 @@ export class UsersRaw extends BaseRaw { return this.findOne(query); } - async findLDAPUsers(options) { + findLDAPUsers(options) { const query = { ldap: true }; return this.find(query, options); } + findConnectedLDAPUsers(options) { + const query = { + ldap: true, + 'services.resume.loginTokens': { + $exists: true, + $ne: [], + }, + }; + + return this.find(query, options); + } + isUserInRole(userId, roleName) { const query = { _id: userId, diff --git a/definition/ldap/ILDAPOptions.ts b/definition/ldap/ILDAPOptions.ts index 32a8494de1d5..e7721dcd041d 100644 --- a/definition/ldap/ILDAPOptions.ts +++ b/definition/ldap/ILDAPOptions.ts @@ -24,4 +24,8 @@ export interface ILDAPConnectionOptions { groupFilterGroupMemberAttribute?: string; groupFilterGroupMemberFormat?: string; groupFilterGroupName?: string; + authentication: boolean; + authenticationUserDN: string; + authenticationPassword: string; + attributesToQuery: Array; } diff --git a/ee/definition/ldap/ILDAPEEOptions.ts b/ee/definition/ldap/ILDAPEEOptions.ts deleted file mode 100644 index 35d62f46fa1f..000000000000 --- a/ee/definition/ldap/ILDAPEEOptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ILDAPEEConnectionOptions { - authentication: boolean; - authenticationUserDN: string; - authenticationPassword: string; -} diff --git a/ee/server/configuration/ldap.ts b/ee/server/configuration/ldap.ts index 1b89f5338e3d..a7071354973c 100644 --- a/ee/server/configuration/ldap.ts +++ b/ee/server/configuration/ldap.ts @@ -6,36 +6,61 @@ import { LDAPEE } from '../sdk'; import { settings } from '../../../app/settings/server'; import { logger } from '../../../server/lib/ldap/Logger'; import { cronJobs } from '../../../app/utils/server/lib/cron/Cronjobs'; -import { LDAPEEConnection } from '../lib/ldap/Connection'; import { LDAPEEManager } from '../lib/ldap/Manager'; import { callbacks } from '../../../app/callbacks/server'; -import type { LDAPConnection } from '../../../server/lib/ldap/Connection'; import type { IImportUser } from '../../../definition/IImportUser'; import type { ILDAPEntry } from '../../../definition/ldap/ILDAPEntry'; +import type { SettingValue } from '../../../definition/ISetting'; +import type { SettingCallback } from '../../../app/settings/lib/settings'; import { onLicense } from '../../app/license/server'; +import { addSettings } from '../settings/ldap'; + +Meteor.startup(() => onLicense('ldap-enterprise', () => { + addSettings(); -onLicense('ldap-enterprise', () => { // Configure background sync cronjob - const jobName = 'LDAP_Sync'; - const addCronJob = _.debounce(Meteor.bindEnvironment(function addCronJobDebounced() { - if (settings.get('LDAP_Background_Sync') !== true) { - logger.info('Disabling LDAP Background Sync'); - if (cronJobs.nextScheduledAtDate(jobName)) { - cronJobs.remove(jobName); + function configureBackgroundSync(jobName: string, enableSetting: string, intervalSetting: string, cb: () => {}): SettingCallback { + let lastSchedule: string; + + return _.debounce(Meteor.bindEnvironment(function addCronJobDebounced() { + if (settings.get('LDAP_Enable') !== true || settings.get(enableSetting) !== true) { + if (cronJobs.nextScheduledAtDate(jobName)) { + logger.info({ msg: 'Disabling LDAP Background Sync', jobName }); + cronJobs.remove(jobName); + } + return; + } + + const schedule = settings.get(intervalSetting); + if (schedule) { + if (schedule !== lastSchedule && cronJobs.nextScheduledAtDate(jobName)) { + cronJobs.remove(jobName); + } + + lastSchedule = schedule; + logger.info({ msg: 'Enabling LDAP Background Sync', jobName }); + cronJobs.add(jobName, schedule, () => cb(), 'text'); } - return; - } + }), 500); + } - const schedule = settings.get('LDAP_Background_Sync_Interval'); - if (schedule) { - logger.info('Enabling LDAP Background Sync'); - cronJobs.add(jobName, schedule, () => Promise.await(LDAPEE.sync()), 'text'); - } - }), 500); + const addCronJob = configureBackgroundSync('LDAP_Sync', 'LDAP_Background_Sync', 'LDAP_Background_Sync_Interval', () => Promise.await(LDAPEE.sync())); + const addAvatarCronJob = configureBackgroundSync('LDAP_AvatarSync', 'LDAP_Background_Sync_Avatars', 'LDAP_Background_Sync_Avatars_Interval', () => Promise.await(LDAPEE.syncAvatars())); + const addLogoutCronJob = configureBackgroundSync('LDAP_AutoLogout', 'LDAP_Sync_AutoLogout_Enabled', 'LDAP_Sync_AutoLogout_Interval', () => Promise.await(LDAPEE.syncLogout())); Meteor.defer(() => { settings.get('LDAP_Background_Sync', addCronJob); settings.get('LDAP_Background_Sync_Interval', addCronJob); + settings.get('LDAP_Background_Sync_Avatars', addAvatarCronJob); + settings.get('LDAP_Background_Sync_Avatars_Interval', addAvatarCronJob); + settings.get('LDAP_Sync_AutoLogout_Enabled', addLogoutCronJob); + settings.get('LDAP_Sync_AutoLogout_Interval', addLogoutCronJob); + + settings.get('LDAP_Enable', (key: string, value: SettingValue, initialLoad?: boolean) => { + addCronJob(key, value, initialLoad); + addAvatarCronJob(key, value, initialLoad); + addLogoutCronJob(key, value, initialLoad); + }); settings.get('LDAP_Groups_To_Rocket_Chat_Teams', (_key, value) => { try { @@ -46,12 +71,8 @@ onLicense('ldap-enterprise', () => { }); }); - callbacks.add('getLDAPConnectionClass', function(): typeof LDAPConnection { - return LDAPEEConnection; - }, callbacks.priority.HIGH, 'replaceLDAPConnectionClass'); - callbacks.add('mapLDAPUserData', (userData: IImportUser, ldapUser: ILDAPEntry) => { LDAPEEManager.copyCustomFields(ldapUser, userData); LDAPEEManager.copyActiveState(ldapUser, userData); }, callbacks.priority.MEDIUM, 'mapLDAPCustomFields'); -}); +})); diff --git a/ee/server/lib/ldap/Connection.ts b/ee/server/lib/ldap/Connection.ts deleted file mode 100644 index 13daa8d290b4..000000000000 --- a/ee/server/lib/ldap/Connection.ts +++ /dev/null @@ -1,65 +0,0 @@ -import ldapjs from 'ldapjs'; - -import { LDAPConnection } from '../../../../server/lib/ldap/Connection'; -import { logger, bindLogger } from '../../../../server/lib/ldap/Logger'; -import { settings } from '../../../../app/settings/server'; -import type { ILDAPEEConnectionOptions } from '../../../definition/ldap/ILDAPEEOptions'; - -export class LDAPEEConnection extends LDAPConnection { - public eeOptions: ILDAPEEConnectionOptions; - - private usingAuthentication: boolean; - - constructor() { - super(); - - this.eeOptions = { - authentication: settings.get('LDAP_Authentication') ?? false, - authenticationUserDN: settings.get('LDAP_Authentication_UserDN') ?? '', - authenticationPassword: settings.get('LDAP_Authentication_Password') ?? '', - }; - } - - /* - Bind UserDN and Password if specified and not yet bound - */ - public async maybeBindDN(): Promise { - if (this.usingAuthentication) { - return; - } - - if (!this.eeOptions.authentication) { - return; - } - - if (!this.eeOptions.authenticationUserDN) { - logger.error('Invalid UserDN for authentication'); - return; - } - - bindLogger.info({ msg: 'Binding UserDN', userDN: this.eeOptions.authenticationUserDN }); - await this.bindDN(this.eeOptions.authenticationUserDN, this.eeOptions.authenticationPassword); - this.usingAuthentication = true; - } - - public disconnect(): void { - this.usingAuthentication = false; - super.disconnect(); - } - - public async testConnection(): Promise { - await super.testConnection(); - - await this.maybeBindDN(); - } - - protected async runBeforeSearch(searchOptions: ldapjs.SearchOptions): Promise { - await this.maybeBindDN(); - - if (!Array.isArray(searchOptions.attributes)) { - searchOptions.attributes = searchOptions.attributes ? [searchOptions.attributes] : ['*']; - } - searchOptions.attributes.push('pwdAccountLockedTime'); - super.runBeforeSearch(searchOptions); - } -} diff --git a/ee/server/lib/ldap/Manager.ts b/ee/server/lib/ldap/Manager.ts index 2c60f326f799..4893c2c3e2d0 100644 --- a/ee/server/lib/ldap/Manager.ts +++ b/ee/server/lib/ldap/Manager.ts @@ -15,23 +15,22 @@ import { Subscriptions as SubscriptionsRaw, } from '../../../../app/models/server/raw'; import { LDAPDataConverter } from '../../../../server/lib/ldap/DataConverter'; -import type { LDAPConnection } from '../../../../server/lib/ldap/Connection'; +import { LDAPConnection } from '../../../../server/lib/ldap/Connection'; import { LDAPManager } from '../../../../server/lib/ldap/Manager'; -import { logger } from '../../../../server/lib/ldap/Logger'; +import { logger, searchLogger } from '../../../../server/lib/ldap/Logger'; import { templateVarHandler } from '../../../../app/utils/lib/templateVarHandler'; -import { LDAPEEConnection } from './Connection'; import { api } from '../../../../server/sdk/api'; import { addUserToRoom, removeUserFromRoom, createRoom } from '../../../../app/lib/server/functions'; import { Team } from '../../../../server/sdk'; export class LDAPEEManager extends LDAPManager { public static async sync(): Promise { - if (settings.get('LDAP_Enable') !== true) { + if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync') !== true) { return; } const options = this.getConverterOptions(); - const ldap = new LDAPEEConnection(); + const ldap = new LDAPConnection(); const converter = new LDAPDataConverter(true, options); try { @@ -58,6 +57,25 @@ export class LDAPEEManager extends LDAPManager { } } + public static async syncAvatars(): Promise { + if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync_Avatars') !== true) { + return; + } + + try { + const ldap = new LDAPConnection(); + await ldap.connect(); + + try { + await this.updateUserAvatars(ldap); + } finally { + ldap.disconnect(); + } + } catch (error) { + logger.error(error); + } + } + public static validateLDAPTeamsMappingChanges(json: string): void { if (!json) { return; @@ -76,6 +94,25 @@ export class LDAPEEManager extends LDAPManager { } } + public static async syncLogout(): Promise { + if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Sync_AutoLogout_Enabled') !== true) { + return; + } + + try { + const ldap = new LDAPConnection(); + await ldap.connect(); + + try { + await this.logoutDeactivatedUsers(ldap); + } finally { + ldap.disconnect(); + } + } catch (error) { + logger.error(error); + } + } + private static async advancedSync(ldap: LDAPConnection, importUser: IImportUser, converter: LDAPDataConverter, isNewRecord: boolean): Promise { const user = converter.findExistingUser(importUser); if (!user || user.username) { @@ -335,6 +372,45 @@ export class LDAPEEManager extends LDAPManager { return ldapUserGroups.filter((entry) => entry?.raw?.ou).map((entry) => (ldap.extractLdapAttribute(entry.raw.ou) as string)).flat(); } + private static isUserDeactivated(ldapUser: ILDAPEntry): boolean { + // Account locked by "Draft-behera-ldap-password-policy" + if (ldapUser.pwdAccountLockedTime) { + return true; + } + + // EDirectory: Account manually disabled by an admin + if (ldapUser.loginDisabled) { + return true; + } + + // Oracle: Account must not be allowed to authenticate + if (ldapUser.orclIsEnabled && ldapUser.orclIsEnabled !== 'ENABLED') { + return true; + } + + // Active Directory - Account locked automatically by security policies + if (ldapUser.lockoutTime) { + // Automatic unlock is disabled + if (!ldapUser.lockoutDuration) { + return true; + } + + const lockoutTime = new Date(Number(ldapUser.lockoutTime)); + lockoutTime.setMinutes(lockoutTime.getMinutes() + Number(ldapUser.lockoutDuration)); + // Account has not unlocked itself yet + if (lockoutTime.valueOf() > Date.now()) { + return true; + } + } + + // Active Directory - Account disabled by an Admin + if (ldapUser.userAccountControl && (ldapUser.userAccountControl & 2) === 2) { + return true; + } + + return false; + } + public static copyActiveState(ldapUser: ILDAPEntry, userData: IImportUser): void { if (!ldapUser) { return; @@ -345,7 +421,7 @@ export class LDAPEEManager extends LDAPManager { return; } - const deleted = Boolean(ldapUser.pwdAccountLockedTime); + const deleted = this.isUserDeactivated(ldapUser); if (deleted === userData.deleted || (userData.deleted === undefined && !deleted)) { return; } @@ -466,31 +542,61 @@ export class LDAPEEManager extends LDAPManager { } private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPDataConverter): Promise { - return new Promise(async (resolve, reject) => { - try { - const users = await UsersRaw.findLDAPUsers(); - for await (const user of users) { - let ldapUser: ILDAPEntry | undefined; - - if (user.services?.ldap?.id) { - ldapUser = await ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute); - } else if (user.username) { - ldapUser = await ldap.findOneByUsername(user.username); - } + const users = await UsersRaw.findLDAPUsers().toArray(); + for await (const user of users) { + const ldapUser = await this.findLDAPUser(ldap, user); - if (ldapUser) { - const userData = this.mapUserData(ldapUser, user.username); - converter.addUser(userData); - } - } + if (ldapUser) { + const userData = this.mapUserData(ldapUser, user.username); + converter.addUser(userData); + } + } + } - resolve(); - } catch (error) { - reject(error); + private static async updateUserAvatars(ldap: LDAPConnection): Promise { + const users = await UsersRaw.findLDAPUsers().toArray(); + for await (const user of users) { + const ldapUser = await this.findLDAPUser(ldap, user); + if (!ldapUser) { + continue; } + + LDAPManager.syncUserAvatar(user, ldapUser); + } + } + + private static async findLDAPUser(ldap: LDAPConnection, user: IUser): Promise { + if (user.services?.ldap?.id) { + return ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute); + } + + if (user.username) { + return ldap.findOneByUsername(user.username); + } + + searchLogger.debug({ + msg: 'existing LDAP user not found during Sync', + ldapId: user.services?.ldap?.id, + ldapAttribute: user.services?.ldap?.idAttribute, + username: user.username, }); } + private static async logoutDeactivatedUsers(ldap: LDAPConnection): Promise { + const users = await UsersRaw.findConnectedLDAPUsers().toArray(); + + for await (const user of users) { + const ldapUser = await this.findLDAPUser(ldap, user); + if (!ldapUser) { + continue; + } + + if (this.isUserDeactivated(ldapUser)) { + UsersRaw.unsetLoginTokens(user._id); + } + } + } + private static getCustomField(customFields: Record, property: string): any { try { return _.reduce(property.split('.'), (acc, el) => acc[el], customFields); diff --git a/ee/server/local-services/ldap/service.ts b/ee/server/local-services/ldap/service.ts index a09b22f46fba..977f8d5a0055 100644 --- a/ee/server/local-services/ldap/service.ts +++ b/ee/server/local-services/ldap/service.ts @@ -11,6 +11,14 @@ export class LDAPEEService extends ServiceClass implements ILDAPEEService { async sync(): Promise { return LDAPEEManager.sync(); } + + async syncAvatars(): Promise { + return LDAPEEManager.syncAvatars(); + } + + async syncLogout(): Promise { + return LDAPEEManager.syncLogout(); + } } api.registerService(new LDAPEEService()); diff --git a/ee/server/sdk/types/ILDAPEEService.ts b/ee/server/sdk/types/ILDAPEEService.ts index 1d3d5bbd04f1..246d52884a8f 100644 --- a/ee/server/sdk/types/ILDAPEEService.ts +++ b/ee/server/sdk/types/ILDAPEEService.ts @@ -1,3 +1,5 @@ export interface ILDAPEEService { sync(): Promise; + syncAvatars(): Promise; + syncLogout(): Promise; } diff --git a/ee/server/settings/index.ts b/ee/server/settings/index.ts index c67512e1c280..c5dd94f6bac5 100644 --- a/ee/server/settings/index.ts +++ b/ee/server/settings/index.ts @@ -1 +1,2 @@ +import './ldap'; import './saml'; diff --git a/ee/server/settings/ldap.ts b/ee/server/settings/ldap.ts new file mode 100644 index 000000000000..af172127683a --- /dev/null +++ b/ee/server/settings/ldap.ts @@ -0,0 +1,221 @@ +import { settings } from '../../../app/settings/server'; + +export function addSettings(): void { + settings.addGroup('LDAP', function() { + const enableQuery = { _id: 'LDAP_Enable', value: true }; + + this.set({ + tab: 'LDAP_Enterprise', + enterprise: true, + modules: ['ldap-enterprise'], + }, function() { + this.section('LDAP_DataSync_BackgroundSync', function() { + this.add('LDAP_Background_Sync', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + const backgroundSyncQuery = [ + enableQuery, + { _id: 'LDAP_Background_Sync', value: true }, + ]; + + this.add('LDAP_Background_Sync_Interval', 'Every 24 hours', { + type: 'string', + enableQuery: backgroundSyncQuery, + invalidValue: 'Every 24 hours', + }); + + this.add('LDAP_Background_Sync_Import_New_Users', true, { + type: 'boolean', + enableQuery: backgroundSyncQuery, + invalidValue: true, + }); + + this.add('LDAP_Background_Sync_Keep_Existant_Users_Updated', true, { + type: 'boolean', + enableQuery: backgroundSyncQuery, + invalidValue: true, + }); + + this.add('LDAP_Background_Sync_Avatars', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + this.add('LDAP_Background_Sync_Avatars_Interval', 'Every 24 hours', { + type: 'string', + enableQuery: [ + enableQuery, + { _id: 'LDAP_Background_Sync_Avatars', value: true }, + ], + invalidValue: 'Every 24 hours', + }); + }); + + this.section('LDAP_DataSync_Advanced', function() { + this.add('LDAP_Sync_User_Active_State', 'disable', { + type: 'select', + values: [ + { key: 'none', i18nLabel: 'LDAP_Sync_User_Active_State_Nothing' }, + { key: 'disable', i18nLabel: 'LDAP_Sync_User_Active_State_Disable' }, + { key: 'both', i18nLabel: 'LDAP_Sync_User_Active_State_Both' }, + ], + i18nDescription: 'LDAP_Sync_User_Active_State_Description', + enableQuery: { _id: 'LDAP_Enable', value: true }, + invalidValue: 'none', + }); + + this.add('LDAP_User_Search_AttributesToQuery', '*,+', { + type: 'string', + enableQuery: { _id: 'LDAP_Enable', value: true }, + invalidValue: '*,+', + }); + }); + + this.section('LDAP_DataSync_AutoLogout', function() { + this.add('LDAP_Sync_AutoLogout_Enabled', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + this.add('LDAP_Sync_AutoLogout_Interval', 'Every 5 minutes', { + type: 'string', + enableQuery: [ + enableQuery, + { _id: 'LDAP_Sync_AutoLogout_Enabled', value: true }, + ], + invalidValue: '', + }); + }); + + this.section('LDAP_DataSync_CustomFields', function() { + this.add('LDAP_Sync_Custom_Fields', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + this.add('LDAP_CustomFieldMap', '{}', { + type: 'code', + multiline: true, + enableQuery: [ + enableQuery, + { _id: 'LDAP_Sync_Custom_Fields', value: true }, + ], + invalidValue: '{}', + }); + }); + + this.section('LDAP_DataSync_Roles', function() { + this.add('LDAP_Sync_User_Data_Roles', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + const syncRolesQuery = [ + enableQuery, + { _id: 'LDAP_Sync_User_Data_Roles', value: true }, + ]; + this.add('LDAP_Sync_User_Data_Roles_AutoRemove', false, { + type: 'boolean', + enableQuery: syncRolesQuery, + invalidValue: false, + }); + + this.add('LDAP_Sync_User_Data_Roles_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { + type: 'string', + enableQuery: syncRolesQuery, + invalidValue: '', + }); + + this.add('LDAP_Sync_User_Data_Roles_BaseDN', '', { + type: 'string', + enableQuery: syncRolesQuery, + invalidValue: '', + }); + + this.add('LDAP_Sync_User_Data_RolesMap', '{\n\t"rocket-admin": "admin",\n\t"tech-support": "support"\n}', { + type: 'code', + multiline: true, + public: false, + code: 'application/json', + enableQuery: syncRolesQuery, + invalidValue: '', + }); + }); + + this.section('LDAP_DataSync_Channels', function() { + this.add('LDAP_Sync_User_Data_Channels', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + const syncChannelsQuery = [ + enableQuery, + { _id: 'LDAP_Sync_User_Data_Channels', value: true }, + ]; + + this.add('LDAP_Sync_User_Data_Channels_Admin', 'rocket.cat', { + type: 'string', + enableQuery: syncChannelsQuery, + invalidValue: 'rocket.cat', + }); + + this.add('LDAP_Sync_User_Data_Channels_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { + type: 'string', + enableQuery: syncChannelsQuery, + invalidValue: '', + }); + + this.add('LDAP_Sync_User_Data_Channels_BaseDN', '', { + type: 'string', + enableQuery: syncChannelsQuery, + invalidValue: '', + }); + + this.add('LDAP_Sync_User_Data_ChannelsMap', '{\n\t"employee": "general",\n\t"techsupport": [\n\t\t"helpdesk",\n\t\t"support"\n\t]\n}', { + type: 'code', + multiline: true, + public: false, + code: 'application/json', + enableQuery: syncChannelsQuery, + invalidValue: '', + }); + + this.add('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels', false, { + type: 'boolean', + enableQuery: syncChannelsQuery, + invalidValue: false, + }); + }); + + this.section('LDAP_DataSync_Teams', function() { + this.add('LDAP_Enable_LDAP_Groups_To_RC_Teams', false, { + type: 'boolean', + enableQuery: { _id: 'LDAP_Enable', value: true }, + invalidValue: false, + }); + this.add('LDAP_Groups_To_Rocket_Chat_Teams', '{}', { + type: 'code', + enableQuery: { _id: 'LDAP_Enable_LDAP_Groups_To_RC_Teams', value: true }, + invalidValue: '{}', + }); + this.add('LDAP_Validate_Teams_For_Each_Login', false, { + type: 'boolean', + enableQuery: { _id: 'LDAP_Enable_LDAP_Groups_To_RC_Teams', value: true }, + invalidValue: false, + }); + this.add('LDAP_Query_To_Get_User_Teams', '(&(ou=*)(uniqueMember=uid=#{username},dc=example,dc=com))', { + type: 'string', + enableQuery: { _id: 'LDAP_Enable_LDAP_Groups_To_RC_Teams', value: true }, + invalidValue: '(&(ou=*)(uniqueMember=uid=#{username},dc=example,dc=com))', + }); + }); + }); + }); +} diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 5c91d5d573f8..1c9cc48fd390 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2436,7 +2436,7 @@ "LDAP_DataSync": "Data Sync", "LDAP_DataSync_DataMap": "Mapping", "LDAP_DataSync_Avatar": "Avatar", - "LDAP_DataSync_ActiveState": "User Active State", + "LDAP_DataSync_Advanced": "Advanced Sync", "LDAP_DataSync_CustomFields": "Sync Custom Fields", "LDAP_DataSync_Roles": "Sync Roles", "LDAP_DataSync_Channels": "Sync Channels", @@ -2457,6 +2457,9 @@ "LDAP_Avatar_Field": "User Avatar Field", "LDAP_Avatar_Field_Description": " Which field will be used as *avatar* for users. Leave empty to use `thumbnailPhoto` first and `jpegPhoto` as fallback.", "LDAP_Background_Sync": "Background Sync", + "LDAP_Background_Sync_Avatars": "Avatar Background Sync", + "LDAP_Background_Sync_Avatars_Description": "Enable a separate background process to sync user avatars.", + "LDAP_Background_Sync_Avatars_Interval": "Avatar Background Sync Interval", "LDAP_Background_Sync_Import_New_Users": "Background Sync Import New Users", "LDAP_Background_Sync_Import_New_Users_Description": "Will import all users (based on your filter criteria) that exists in LDAP and does not exists in Rocket.Chat", "LDAP_Background_Sync_Interval": "Background Sync Interval", @@ -2513,6 +2516,8 @@ "LDAP_Search_Size_Limit_Description": "The maximum number of entries to return.
**Attention** This number should greater than **Search Page Size**", "LDAP_Sync_Custom_Fields": "Sync Custom Fields", "LDAP_CustomFieldMap": "Custom Fields Mapping", + "LDAP_Sync_AutoLogout_Enabled": "Enable Auto Logout", + "LDAP_Sync_AutoLogout_Interval": "Auto Logout Interval", "LDAP_Sync_Now": "Sync Now", "LDAP_Sync_Now_Description": "This will start a **Background Sync** operation now, without waiting for the next scheduled Sync.\nThis action is asynchronous, please see the logs for more information.", "LDAP_Sync_User_Active_State": "Sync User Active State", @@ -2546,6 +2551,8 @@ "LDAP_Timeout_Description": "How many mileseconds wait for a search result before return an error", "LDAP_Unique_Identifier_Field": "Unique Identifier Field", "LDAP_Unique_Identifier_Field_Description": "Which field will be used to link the LDAP user and the Rocket.Chat user. You can inform multiple values separated by comma to try to get the value from LDAP record.
Default value is `objectGUID,ibm-entryUUID,GUID,dominoUNID,nsuniqueId,uidNumber`", + "LDAP_User_Search_AttributesToQuery": "Attributes to Query", + "LDAP_User_Search_AttributesToQuery_Description": "Specify which attributes should be returned on LDAP queries, separating them with commas. Defaults to everything. `*` represents all regular attributes and `+` represents all operational attributes. Make sure to include every attribute that is used by every Rocket.Chat sync option.", "LDAP_User_Search_Field": "Search Field", "LDAP_User_Search_Field_Description": "The LDAP attribute that identifies the LDAP user who attempts authentication. This field should be `sAMAccountName` for most Active Directory installations, but it may be `uid` for other LDAP solutions, such as OpenLDAP. You can use `mail` to identify users by email or whatever attribute you want.
You can use multiple values separated by comma to allow users to login using multiple identifiers like username or email.", "LDAP_User_Search_Filter": "Filter", diff --git a/server/lib/ldap/Connection.ts b/server/lib/ldap/Connection.ts index d118508147b3..532022081d4a 100644 --- a/server/lib/ldap/Connection.ts +++ b/server/lib/ldap/Connection.ts @@ -5,7 +5,7 @@ import type { ILDAPConnectionOptions, LDAPEncryptionType, LDAPSearchScope } from import type { ILDAPEntry } from '../../../definition/ldap/ILDAPEntry'; import type { ILDAPCallback, ILDAPPageCallback } from '../../../definition/ldap/ILDAPCallback'; import { callbacks } from '../../../app/callbacks/server'; -import { logger, connLogger, searchLogger, authLogger } from './Logger'; +import { logger, connLogger, searchLogger, authLogger, bindLogger, mapLogger } from './Logger'; import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; interface ILDAPEntryCallback { @@ -43,6 +43,8 @@ export class LDAPConnection { private _connectionCallback: ILDAPCallback; + private usingAuthentication: boolean; + constructor() { this.ldapjs = ldapjs; @@ -73,6 +75,10 @@ export class LDAPConnection { groupFilterGroupMemberAttribute: settings.get('LDAP_Group_Filter_Group_Member_Attribute'), groupFilterGroupMemberFormat: settings.get('LDAP_Group_Filter_Group_Member_Format'), groupFilterGroupName: settings.get('LDAP_Group_Filter_Group_Name'), + authentication: settings.get('LDAP_Authentication') ?? false, + authenticationUserDN: settings.get('LDAP_Authentication_UserDN') ?? '', + authenticationPassword: settings.get('LDAP_Authentication_Password') ?? '', + attributesToQuery: this.parseAttributeList(settings.get('LDAP_User_Search_AttributesToQuery')), }; if (!this.options.host) { @@ -96,6 +102,7 @@ export class LDAPConnection { } public disconnect(): void { + this.usingAuthentication = false; this.connected = false; connLogger.info('Disconnecting'); @@ -105,7 +112,12 @@ export class LDAPConnection { } public async testConnection(): Promise { - await this.connect(); + try { + await this.connect(); + await this.maybeBindDN(); + } finally { + this.disconnect(); + } } public async searchByUsername(escapedUsername: string): Promise { @@ -113,6 +125,7 @@ export class LDAPConnection { filter: this.getUserFilter(escapedUsername), scope: this.options.userSearchScope || 'sub', sizeLimit: this.options.searchSizeLimit, + attributes: this.options.attributesToQuery, }; if (this.options.searchPageSize > 0) { @@ -137,7 +150,7 @@ export class LDAPConnection { public async searchById(id: string, attribute?: string): Promise { const searchOptions: ldapjs.SearchOptions = { scope: this.options.userSearchScope || 'sub', - attributes: ['*', '+'], + attributes: this.options.attributesToQuery, }; if (attribute) { @@ -184,6 +197,7 @@ export class LDAPConnection { filter: this.getUserFilter('*'), scope: this.options.userSearchScope || 'sub', sizeLimit: this.options.searchSizeLimit, + attributes: this.options.attributesToQuery, }; if (this.options.searchPageSize > 0) { @@ -264,6 +278,8 @@ export class LDAPConnection { Object.keys(values._raw).forEach((key) => { values[key] = this.extractLdapAttribute(values._raw[key]); + + mapLogger.debug({ msg: 'Extracted Attribute', key, type: typeof values[key], value: values[key] }); }); return values; @@ -515,6 +531,25 @@ export class LDAPConnection { this.client._updateIdle(override); } + protected async maybeBindDN(): Promise { + if (this.usingAuthentication) { + return; + } + + if (!this.options.authentication) { + return; + } + + if (!this.options.authenticationUserDN) { + logger.error('Invalid UserDN for authentication'); + return; + } + + bindLogger.info({ msg: 'Binding UserDN', userDN: this.options.authenticationUserDN }); + await this.bindDN(this.options.authenticationUserDN, this.options.authenticationPassword); + this.usingAuthentication = true; + } + protected async runBeforeSearch(searchOptions: ldapjs.SearchOptions): Promise { callbacks.run('beforeLDAPSearch', searchOptions, this); } @@ -645,4 +680,17 @@ export class LDAPConnection { } }, clientOptions.connectTimeout); } + + private parseAttributeList(csv: string | undefined): Array { + if (!csv) { + return ['*', '+']; + } + + const list = csv.split(',').map((item) => item.trim()); + if (!list?.length) { + return ['*', '+']; + } + + return list; + } } diff --git a/server/lib/ldap/Logger.ts b/server/lib/ldap/Logger.ts index 14bdc80af4e2..1b2341dc548a 100644 --- a/server/lib/ldap/Logger.ts +++ b/server/lib/ldap/Logger.ts @@ -5,3 +5,4 @@ export const connLogger = logger.section('Connection'); export const bindLogger = logger.section('Bind'); export const searchLogger = logger.section('Search'); export const authLogger = logger.section('Auth'); +export const mapLogger = logger.section('Mapping'); diff --git a/server/lib/ldap/Manager.ts b/server/lib/ldap/Manager.ts index a8bfabadaa8a..d7cc6cd5d4d1 100644 --- a/server/lib/ldap/Manager.ts +++ b/server/lib/ldap/Manager.ts @@ -31,7 +31,7 @@ export class LDAPManager { let ldapUser: ILDAPEntry | undefined; - const ldap = this.getNewConnection(); + const ldap = new LDAPConnection(); try { try { await ldap.connect(); @@ -57,26 +57,37 @@ export class LDAPManager { } } - public static getNewConnection(): LDAPConnection { - const ClassRef = callbacks.run('getLDAPConnectionClass') || LDAPConnection; - - return new ClassRef(); - } - public static async testConnection(): Promise { try { - const ldap = LDAPManager.getNewConnection(); - try { - await ldap.testConnection(); - } finally { - ldap.disconnect(); - } + const ldap = new LDAPConnection(); + await ldap.testConnection(); } catch (error) { connLogger.error(error); throw error; } } + public static syncUserAvatar(user: IUser, ldapUser: ILDAPEntry): void { + if (!user?._id || settings.get('LDAP_Sync_User_Avatar') !== true) { + return; + } + + const avatar = this.getAvatarFromUser(ldapUser); + if (!avatar) { + return; + } + + const hash = SHA256(avatar.toString()); + if (user.avatarETag === hash) { + return; + } + + logger.debug({ msg: 'Syncing user avatar', username: user.username }); + // #ToDo: Remove Meteor references here + // runAsUser is needed for now because the UploadFS class rejects files if there's no userId + Meteor.runAsUser(user._id, () => setUserAvatar(user, avatar, 'image/jpeg', 'rest', hash)); + } + // This method will only find existing users that are already linked to LDAP protected static async findExistingLDAPUser(ldapUser: ILDAPEntry): Promise { const uniqueIdentifierField = this.getLdapUserUniqueID(ldapUser); @@ -204,7 +215,7 @@ export class LDAPManager { throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeeded, but there's already an existing user with provided username [${ user.username }] in Mongo.`); } - const syncData = settings.get('LDAP_Update_Data_On_Login') ?? false; + const syncData = settings.get('LDAP_Update_Data_On_Login') ?? true; logger.debug({ msg: 'Logging user in', syncData }); const updatedUser = (syncData && await this.syncUserForLogin(ldapUser, user)) || user; @@ -215,7 +226,7 @@ export class LDAPManager { } private static async syncUserForLogin(ldapUser: ILDAPEntry, existingUser?: IUser, usedUsername?: string | undefined): Promise { - logger.debug({ msg: 'Syncing user data', ldapUser, user: { ...existingUser && { email: existingUser.emails, _id: existingUser._id } } }); + logger.debug({ msg: 'Syncing user data', ldapUser: _.omit(ldapUser, '_raw'), user: { ...existingUser && { email: existingUser.emails, _id: existingUser._id } } }); const userData = this.mapUserData(ldapUser, usedUsername); const options = this.getConverterOptions(); @@ -394,20 +405,4 @@ export class LDAPManager { return ldapUser._raw.jpegPhoto; } } - - private static syncUserAvatar(user: IUser, ldapUser: ILDAPEntry): void { - if (!user?._id || settings.get('LDAP_Sync_User_Avatar') !== true) { - return; - } - - const avatar = this.getAvatarFromUser(ldapUser); - if (!avatar) { - return; - } - - logger.debug('Syncing user avatar'); - // #ToDo: Remove Meteor references here - // runAsUser is needed for now because the UploadFS class rejects files if there's no userId - Meteor.defer(() => Meteor.runAsUser(user._id, () => setUserAvatar(user, avatar, 'image/jpeg', 'rest'))); - } } diff --git a/server/settings/ldap.ts b/server/settings/ldap.ts index 6582db256a2d..cb9d235634ae 100644 --- a/server/settings/ldap.ts +++ b/server/settings/ldap.ts @@ -24,6 +24,17 @@ settings.addGroup('LDAP', function() { this.add('LDAP_Login_Fallback', false, { type: 'boolean', enableQuery }); + this.section('LDAP_Connection_Authentication', function() { + const enableAuthentication = [ + enableQuery, + { _id: 'LDAP_Authentication', value: true }, + ]; + + this.add('LDAP_Authentication', false, { type: 'boolean', enableQuery, invalidValue: false }); + this.add('LDAP_Authentication_UserDN', '', { type: 'string', enableQuery: enableAuthentication, secret: true, invalidValue: '' }); + this.add('LDAP_Authentication_Password', '', { type: 'password', enableQuery: enableAuthentication, secret: true, invalidValue: '' }); + }); + this.section('LDAP_Connection_Encryption', function() { this.add('LDAP_Encryption', 'plain', { type: 'select', @@ -103,7 +114,7 @@ settings.addGroup('LDAP', function() { this.add('LDAP_Group_Filter_ObjectClass', 'groupOfUniqueNames', { type: 'string', enableQuery: groupFilterQuery }); this.add('LDAP_Group_Filter_Group_Id_Attribute', 'cn', { type: 'string', enableQuery: groupFilterQuery }); this.add('LDAP_Group_Filter_Group_Member_Attribute', 'uniqueMember', { type: 'string', enableQuery: groupFilterQuery }); - this.add('LDAP_Group_Filter_Group_Member_Format', 'uniqueMember', { type: 'string', enableQuery: groupFilterQuery }); + this.add('LDAP_Group_Filter_Group_Member_Format', '', { type: 'string', enableQuery: groupFilterQuery }); this.add('LDAP_Group_Filter_Group_Name', 'ROCKET_CHAT', { type: 'string', enableQuery: groupFilterQuery }); }); }); @@ -119,7 +130,7 @@ settings.addGroup('LDAP', function() { enableQuery, }); - this.add('LDAP_Update_Data_On_Login', false, { + this.add('LDAP_Update_Data_On_Login', true, { type: 'boolean', enableQuery, }); @@ -185,192 +196,4 @@ settings.addGroup('LDAP', function() { }); }); }); - - this.set({ - tab: 'LDAP_Enterprise', - enterprise: true, - modules: ['ldap-enterprise'], - }, function() { - this.section('LDAP_Connection_Authentication', function() { - const enableAuthentication = [ - enableQuery, - { _id: 'LDAP_Authentication', value: true }, - ]; - - this.add('LDAP_Authentication', false, { type: 'boolean', enableQuery, invalidValue: false }); - this.add('LDAP_Authentication_UserDN', '', { type: 'string', enableQuery: enableAuthentication, secret: true, invalidValue: '' }); - this.add('LDAP_Authentication_Password', '', { type: 'password', enableQuery: enableAuthentication, secret: true, invalidValue: '' }); - }); - - this.section('LDAP_DataSync_BackgroundSync', function() { - this.add('LDAP_Background_Sync', false, { - type: 'boolean', - enableQuery, - invalidValue: false, - }); - - const backgroundSyncQuery = [ - enableQuery, - { _id: 'LDAP_Background_Sync', value: true }, - ]; - - this.add('LDAP_Background_Sync_Interval', 'Every 24 hours', { - type: 'string', - enableQuery: backgroundSyncQuery, - invalidValue: 'Every 24 hours', - }); - - this.add('LDAP_Background_Sync_Import_New_Users', true, { - type: 'boolean', - enableQuery: backgroundSyncQuery, - invalidValue: true, - }); - - this.add('LDAP_Background_Sync_Keep_Existant_Users_Updated', true, { - type: 'boolean', - enableQuery: backgroundSyncQuery, - invalidValue: true, - }); - }); - - this.section('LDAP_DataSync_ActiveState', function() { - this.add('LDAP_Sync_User_Active_State', 'disable', { - type: 'select', - values: [ - { key: 'none', i18nLabel: 'LDAP_Sync_User_Active_State_Nothing' }, - { key: 'disable', i18nLabel: 'LDAP_Sync_User_Active_State_Disable' }, - { key: 'both', i18nLabel: 'LDAP_Sync_User_Active_State_Both' }, - ], - i18nDescription: 'LDAP_Sync_User_Active_State_Description', - enableQuery: { _id: 'LDAP_Enable', value: true }, - enterprise: true, - invalidValue: 'none', - }); - }); - - this.section('LDAP_DataSync_CustomFields', function() { - this.add('LDAP_Sync_Custom_Fields', false, { - type: 'boolean', - enableQuery, - invalidValue: false, - }); - - this.add('LDAP_CustomFieldMap', '{}', { - type: 'code', - multiline: true, - enableQuery: [ - enableQuery, - { _id: 'LDAP_Sync_Custom_Fields', value: true }, - ], - invalidValue: '{}', - }); - }); - - this.section('LDAP_DataSync_Roles', function() { - this.add('LDAP_Sync_User_Data_Roles', false, { - type: 'boolean', - enableQuery, - invalidValue: false, - }); - const syncRolesQuery = [ - enableQuery, - { _id: 'LDAP_Sync_User_Data_Roles', value: true }, - ]; - this.add('LDAP_Sync_User_Data_Roles_AutoRemove', false, { - type: 'boolean', - enableQuery: syncRolesQuery, - invalidValue: false, - }); - - this.add('LDAP_Sync_User_Data_Roles_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { - type: 'string', - enableQuery: syncRolesQuery, - invalidValue: '', - }); - - this.add('LDAP_Sync_User_Data_Roles_BaseDN', '', { - type: 'string', - enableQuery: syncRolesQuery, - invalidValue: '', - }); - - this.add('LDAP_Sync_User_Data_RolesMap', '{\n\t"rocket-admin": "admin",\n\t"tech-support": "support"\n}', { - type: 'code', - multiline: true, - public: false, - code: 'application/json', - enableQuery: syncRolesQuery, - invalidValue: '', - }); - }); - - this.section('LDAP_DataSync_Channels', function() { - this.add('LDAP_Sync_User_Data_Channels', false, { - type: 'boolean', - enableQuery, - invalidValue: false, - }); - - const syncChannelsQuery = [ - enableQuery, - { _id: 'LDAP_Sync_User_Data_Channels', value: true }, - ]; - - this.add('LDAP_Sync_User_Data_Channels_Admin', 'rocket.cat', { - type: 'string', - enableQuery: syncChannelsQuery, - invalidValue: 'rocket.cat', - }); - - this.add('LDAP_Sync_User_Data_Channels_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { - type: 'string', - enableQuery: syncChannelsQuery, - invalidValue: '', - }); - - this.add('LDAP_Sync_User_Data_Channels_BaseDN', '', { - type: 'string', - enableQuery: syncChannelsQuery, - invalidValue: '', - }); - - this.add('LDAP_Sync_User_Data_ChannelsMap', '{\n\t"employee": "general",\n\t"techsupport": [\n\t\t"helpdesk",\n\t\t"support"\n\t]\n}', { - type: 'code', - multiline: true, - public: false, - code: 'application/json', - enableQuery: syncChannelsQuery, - invalidValue: '', - }); - - this.add('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels', false, { - type: 'boolean', - enableQuery: syncChannelsQuery, - invalidValue: false, - }); - }); - - this.section('LDAP_DataSync_Teams', function() { - this.add('LDAP_Enable_LDAP_Groups_To_RC_Teams', false, { - type: 'boolean', - enableQuery: { _id: 'LDAP_Enable', value: true }, - invalidValue: false, - }); - this.add('LDAP_Groups_To_Rocket_Chat_Teams', '{}', { - type: 'code', - enableQuery: { _id: 'LDAP_Enable_LDAP_Groups_To_RC_Teams', value: true }, - invalidValue: '{}', - }); - this.add('LDAP_Validate_Teams_For_Each_Login', false, { - type: 'boolean', - enableQuery: { _id: 'LDAP_Enable_LDAP_Groups_To_RC_Teams', value: true }, - invalidValue: false, - }); - this.add('LDAP_Query_To_Get_User_Teams', '(&(ou=*)(uniqueMember=uid=#{username},dc=example,dc=com))', { - type: 'string', - enableQuery: { _id: 'LDAP_Enable_LDAP_Groups_To_RC_Teams', value: true }, - invalidValue: '(&(ou=*)(uniqueMember=uid=#{username},dc=example,dc=com))', - }); - }); - }); });