diff --git a/apps/meteor/app/importer/server/classes/converters/UserConverter.ts b/apps/meteor/app/importer/server/classes/converters/UserConverter.ts index 1b411100e13c1..46a6c0262430a 100644 --- a/apps/meteor/app/importer/server/classes/converters/UserConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/UserConverter.ts @@ -259,6 +259,10 @@ export class UserConverter extends RecordConverter[0]); } @@ -347,6 +351,7 @@ export class UserConverter extends RecordConverter('LDAP_Authentication') ?? false, authenticationUserDN: settings.get('LDAP_Authentication_UserDN') ?? '', authenticationPassword: settings.get('LDAP_Authentication_Password') ?? '', + useVariables: settings.get('LDAP_DataSync_UseVariables') ?? false, + variableMap: settings.get('LDAP_DataSync_VariableMap') ?? '{}', attributesToQuery: this.parseAttributeList(settings.get('LDAP_User_Search_AttributesToQuery')), }; + this._variableMap = + (this.options.useVariables && + wrapExceptions(() => JSON.parse(this.options.variableMap)).suppress(() => { + mapLogger.error({ msg: 'Failed to parse LDAP Variable Map', map: this.options.variableMap }); + })) || + {}; + if (!this.options.host) { logger.warn('LDAP Host is not configured.'); } @@ -322,7 +335,7 @@ export class LDAPConnection { mapLogger.debug({ msg: 'Extracted Attribute', key, type: dataType, value: values[key] }); }); - return values; + return processLdapVariables(values, this._variableMap); } public async doCustomSearch(baseDN: string, searchOptions: ldapjs.SearchOptions, entryCallback: ILDAPEntryCallback): Promise { diff --git a/apps/meteor/server/lib/ldap/Manager.ts b/apps/meteor/server/lib/ldap/Manager.ts index a0f474cfe5d89..c2fcad7b123d3 100644 --- a/apps/meteor/server/lib/ldap/Manager.ts +++ b/apps/meteor/server/lib/ldap/Manager.ts @@ -12,6 +12,9 @@ import { LDAPConnection } from './Connection'; import { logger, authLogger, connLogger } from './Logger'; import { LDAPUserConverter } from './UserConverter'; import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; +import { getLdapDynamicValue } from './getLdapDynamicValue'; +import { getLdapString } from './getLdapString'; +import { ldapKeyExists } from './ldapKeyExists'; import type { UserConverterOptions } from '../../../app/importer/server/classes/converters/UserConverter'; import { setUserAvatar } from '../../../app/lib/server/functions/setUserAvatar'; import { settings } from '../../../app/settings/server'; @@ -41,6 +44,11 @@ export class LDAPManager { return this.fallbackToDefaultLogin(username, password); } + const homeServer = this.getFederationHomeServer(ldapUser); + if (homeServer) { + return this.fallbackToDefaultLogin(username, password); + } + const slugifiedUsername = this.slugifyUsername(ldapUser, username); const user = await this.findExistingUser(ldapUser, slugifiedUsername); @@ -78,6 +86,11 @@ export class LDAPManager { return; } + const homeServer = this.getFederationHomeServer(ldapUser); + if (homeServer) { + return; + } + const slugifiedUsername = this.slugifyUsername(ldapUser, username); const user = await this.findExistingUser(ldapUser, slugifiedUsername); @@ -165,6 +178,7 @@ export class LDAPManager { const { attribute: idAttribute, value: id } = uniqueId; const username = this.slugifyUsername(ldapUser, usedUsername || id || '') || undefined; + const homeServer = this.getFederationHomeServer(ldapUser); const emails = this.getLdapEmails(ldapUser, username).map((email) => email.trim()); const name = this.getLdapName(ldapUser) || undefined; const voipExtension = this.getLdapExtension(ldapUser); @@ -182,6 +196,10 @@ export class LDAPManager { id, }, }, + ...(homeServer && { + username: `${username}:${homeServer}`, + federated: true, + }), }; this.onMapUserData(ldapUser, userData); @@ -401,43 +419,9 @@ export class LDAPManager { connLogger.debug(ldapUser); } - private static ldapKeyExists(ldapUser: ILDAPEntry, key: string): boolean { - return !_.isEmpty(ldapUser[key.trim()]); - } - - private static getLdapString(ldapUser: ILDAPEntry, key: string): string { - return ldapUser[key.trim()]; - } - - private static getLdapDynamicValue(ldapUser: ILDAPEntry, attributeSetting: string | undefined): string | undefined { - if (!attributeSetting) { - return; - } - - // If the attribute setting is a template, then convert the variables in it - if (attributeSetting.includes('#{')) { - return attributeSetting.replace(/#{(.+?)}/g, (_match, field) => { - const key = field.trim(); - - if (this.ldapKeyExists(ldapUser, key)) { - return this.getLdapString(ldapUser, key); - } - - return ''; - }); - } - - // If it's not a template, then treat the setting as a CSV list of possible attribute names and return the first valid one. - const attributeList: string[] = attributeSetting.replace(/\s/g, '').split(','); - const key = attributeList.find((field) => this.ldapKeyExists(ldapUser, field)); - if (key) { - return this.getLdapString(ldapUser, key); - } - } - private static getLdapName(ldapUser: ILDAPEntry): string | undefined { const nameAttributes = getLDAPConditionalSetting('LDAP_Name_Field'); - return this.getLdapDynamicValue(ldapUser, nameAttributes); + return getLdapDynamicValue(ldapUser, nameAttributes); } private static getLdapExtension(ldapUser: ILDAPEntry): string | undefined { @@ -446,14 +430,14 @@ export class LDAPManager { return; } - return this.getLdapString(ldapUser, extensionAttribute); + return getLdapString(ldapUser, extensionAttribute); } private static getLdapEmails(ldapUser: ILDAPEntry, username?: string): string[] { const emailAttributes = getLDAPConditionalSetting('LDAP_Email_Field'); if (emailAttributes) { const attributeList: string[] = emailAttributes.replace(/\s/g, '').split(','); - const key = attributeList.find((field) => this.ldapKeyExists(ldapUser, field)); + const key = attributeList.find((field) => ldapKeyExists(ldapUser, field)); const emails: string[] = [].concat(key ? ldapUser[key.trim()] : []); const filteredEmails = emails.filter((email) => email.includes('@')); @@ -497,7 +481,40 @@ export class LDAPManager { protected static getLdapUsername(ldapUser: ILDAPEntry): string | undefined { const usernameField = getLDAPConditionalSetting('LDAP_Username_Field') as string; - return this.getLdapDynamicValue(ldapUser, usernameField); + return getLdapDynamicValue(ldapUser, usernameField); + } + + protected static getFederationHomeServer(ldapUser: ILDAPEntry): string | undefined { + if (!settings.get('Federation_Matrix_enabled')) { + return; + } + + const homeServerField = settings.get('LDAP_FederationHomeServer_Field'); + const homeServer = getLdapDynamicValue(ldapUser, homeServerField); + + if (!homeServer) { + return; + } + + logger.debug({ msg: 'User has a federation home server', homeServer }); + + const localServer = settings.get('Federation_Matrix_homeserver_domain'); + if (localServer === homeServer) { + return; + } + + return homeServer; + } + + protected static getFederatedUsername(ldapUser: ILDAPEntry, requestUsername: string): string { + const username = this.slugifyUsername(ldapUser, requestUsername); + const homeServer = this.getFederationHomeServer(ldapUser); + + if (homeServer) { + return `${username}:${homeServer}`; + } + + return username; } // This method will find existing users by LDAP id or by username. diff --git a/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts b/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts new file mode 100644 index 0000000000000..fc05cf05fb441 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts @@ -0,0 +1,30 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { getLdapString } from './getLdapString'; +import { ldapKeyExists } from './ldapKeyExists'; + +export function getLdapDynamicValue(ldapUser: ILDAPEntry, attributeSetting: string | undefined): string | undefined { + if (!attributeSetting) { + return; + } + + // If the attribute setting is a template, then convert the variables in it + if (attributeSetting.includes('#{')) { + return attributeSetting.replace(/#{(.+?)}/g, (_match, field) => { + const key = field.trim(); + + if (ldapKeyExists(ldapUser, key)) { + return getLdapString(ldapUser, key); + } + + return ''; + }); + } + + // If it's not a template, then treat the setting as a CSV list of possible attribute names and return the first valid one. + const attributeList: string[] = attributeSetting.replace(/\s/g, '').split(','); + const key = attributeList.find((field) => ldapKeyExists(ldapUser, field)); + if (key) { + return getLdapString(ldapUser, key); + } +} diff --git a/apps/meteor/server/lib/ldap/getLdapString.ts b/apps/meteor/server/lib/ldap/getLdapString.ts new file mode 100644 index 0000000000000..f6f7134da9eda --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapString.ts @@ -0,0 +1,5 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +export function getLdapString(ldapUser: ILDAPEntry, key: string): string { + return ldapUser[key.trim()]; +} diff --git a/apps/meteor/server/lib/ldap/ldapKeyExists.ts b/apps/meteor/server/lib/ldap/ldapKeyExists.ts new file mode 100644 index 0000000000000..1b68b71bdd8ba --- /dev/null +++ b/apps/meteor/server/lib/ldap/ldapKeyExists.ts @@ -0,0 +1,6 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import _ from 'underscore'; + +export function ldapKeyExists(ldapUser: ILDAPEntry, key: string): boolean { + return !_.isEmpty(ldapUser[key.trim()]); +} diff --git a/apps/meteor/server/lib/ldap/operations/executeOperation.ts b/apps/meteor/server/lib/ldap/operations/executeOperation.ts new file mode 100644 index 0000000000000..e3eb21323598a --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/executeOperation.ts @@ -0,0 +1,31 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { executeFallback, type LDAPVariableFallback } from './fallback'; +import { executeMatch, type LDAPVariableMatch } from './match'; +import { executeReplace, type LDAPVariableReplace } from './replace'; +import { executeSplit, type LDAPVariableSplit } from './split'; +import { executeSubstring, type LDAPVariableSubString } from './substring'; + +export type LDAPVariableOperation = + | LDAPVariableReplace + | LDAPVariableMatch + | LDAPVariableSubString + | LDAPVariableFallback + | LDAPVariableSplit; + +export function executeOperation(ldapUser: ILDAPEntry, input: string, operation?: LDAPVariableOperation): string | undefined { + switch (operation?.operation) { + case 'replace': + return executeReplace(input, operation); + case 'match': + return executeMatch(input, operation); + case 'substring': + return executeSubstring(input, operation); + case 'fallback': + return executeFallback(ldapUser, input, operation); + case 'split': + return executeSplit(input, operation); + } + + return input; +} diff --git a/apps/meteor/server/lib/ldap/operations/fallback.ts b/apps/meteor/server/lib/ldap/operations/fallback.ts new file mode 100644 index 0000000000000..ecd8c199fbdb3 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/fallback.ts @@ -0,0 +1,24 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { getLdapDynamicValue } from '../getLdapDynamicValue'; + +export type LDAPVariableFallback = { + operation: 'fallback'; + fallback: string; + + minLength?: number; +}; + +export function executeFallback(ldapUser: ILDAPEntry, input: string, operation: LDAPVariableFallback): string | undefined { + let valid = Boolean(input); + + if (valid && typeof operation.minLength === 'number') { + valid = input.length >= operation.minLength; + } + + if (valid) { + return input; + } + + return getLdapDynamicValue(ldapUser, operation.fallback); +} diff --git a/apps/meteor/server/lib/ldap/operations/match.ts b/apps/meteor/server/lib/ldap/operations/match.ts new file mode 100644 index 0000000000000..422b13446d365 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/match.ts @@ -0,0 +1,28 @@ +export type LDAPVariableMatch = { + operation: 'match'; + pattern: string; + regex?: boolean; + flags?: string; + indexToUse?: number; + valueIfTrue?: string; + valueIfFalse?: string; +}; + +export function executeMatch(input: string, operation: LDAPVariableMatch): string | undefined { + if (!operation.pattern || (typeof operation.valueIfTrue !== 'string' && typeof operation.indexToUse !== 'number')) { + throw new Error('Invalid MATCH operation.'); + } + + const pattern = operation.regex ? new RegExp(operation.pattern, operation.flags) : operation.pattern; + + const result = input.match(pattern); + if (!result) { + return operation.valueIfFalse; + } + + if (typeof operation.indexToUse === 'number' && result.length > operation.indexToUse) { + return result[operation.indexToUse]; + } + + return operation.valueIfTrue; +} diff --git a/apps/meteor/server/lib/ldap/operations/replace.ts b/apps/meteor/server/lib/ldap/operations/replace.ts new file mode 100644 index 0000000000000..49460a8dd17fc --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/replace.ts @@ -0,0 +1,23 @@ +export type LDAPVariableReplace = { + operation: 'replace'; + pattern: string; + regex?: boolean; + flags?: string; + all?: boolean; + replacement: string; +}; + +export function executeReplace(input: string, operation: LDAPVariableReplace): string { + if (!operation.pattern || typeof operation.replacement !== 'string') { + throw new Error('Invalid REPLACE operation.'); + } + + const flags = operation.regex && operation.all ? `${operation.flags || ''}${operation.flags?.includes('g') ? '' : 'g'}` : operation.flags; + const pattern = operation.regex ? new RegExp(operation.pattern, flags) : operation.pattern; + + if (operation.all) { + return input.replaceAll(pattern, operation.replacement); + } + + return input.replace(pattern, operation.replacement); +} diff --git a/apps/meteor/server/lib/ldap/operations/split.ts b/apps/meteor/server/lib/ldap/operations/split.ts new file mode 100644 index 0000000000000..dc97e71e71e55 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/split.ts @@ -0,0 +1,26 @@ +export type LDAPVariableSplit = { + operation: 'split'; + pattern: string; + indexToUse?: number; +}; + +export function executeSplit(input: string, operation: LDAPVariableSplit): string | undefined { + if (!operation.pattern) { + throw new Error('Invalid SPLIT operation.'); + } + + const result = input.split(operation.pattern); + if (!result) { + return; + } + + if (typeof operation.indexToUse === 'number') { + if (result.length > operation.indexToUse) { + return result[operation.indexToUse]; + } + + return; + } + + return result.shift(); +} diff --git a/apps/meteor/server/lib/ldap/operations/substring.ts b/apps/meteor/server/lib/ldap/operations/substring.ts new file mode 100644 index 0000000000000..3af2287c3120c --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/substring.ts @@ -0,0 +1,13 @@ +export type LDAPVariableSubString = { + operation: 'substring'; + start: number; + end?: number; +}; + +export function executeSubstring(input: string, operation: LDAPVariableSubString): string | undefined { + if (typeof operation.start !== 'number' || (operation.end !== undefined && typeof operation.end !== 'number')) { + throw new Error('Invalid SUBSTRING operation.'); + } + + return input.substring(operation.start, operation.end); +} diff --git a/apps/meteor/server/lib/ldap/processLdapVariables.ts b/apps/meteor/server/lib/ldap/processLdapVariables.ts new file mode 100644 index 0000000000000..4587bc6c7f902 --- /dev/null +++ b/apps/meteor/server/lib/ldap/processLdapVariables.ts @@ -0,0 +1,38 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { mapLogger } from './Logger'; +import { getLdapDynamicValue } from './getLdapDynamicValue'; +import { executeOperation, type LDAPVariableOperation } from './operations/executeOperation'; + +export type LDAPVariableConfiguration = { + input: string; + output?: LDAPVariableOperation; +}; +export type LDAPVariableMap = Record; + +export function processLdapVariables(entry: ILDAPEntry, variableMap: LDAPVariableMap): ILDAPEntry { + if (!variableMap || !Object.keys(variableMap).length) { + mapLogger.debug('No LDAP variables to process.'); + return entry; + } + + for (const variableName in variableMap) { + if (!variableMap.hasOwnProperty(variableName)) { + continue; + } + + const variableData = variableMap[variableName]; + if (!variableData?.input) { + continue; + } + + const input = getLdapDynamicValue(entry, variableData.input) || ''; + const output = executeOperation(entry, input, variableData.output) || ''; + + mapLogger.debug({ msg: 'Processed LDAP variable.', variableName, input, output }); + + entry[variableName] = output; + } + + return entry; +} diff --git a/apps/meteor/server/settings/ldap.ts b/apps/meteor/server/settings/ldap.ts index d0d77d4ec3456..646bcf31ec2d1 100644 --- a/apps/meteor/server/settings/ldap.ts +++ b/apps/meteor/server/settings/ldap.ts @@ -214,6 +214,24 @@ export const createLdapSettings = () => type: 'string', enableQuery, }); + + await this.add('LDAP_FederationHomeServer_Field', '', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_DataSync_UseVariables', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + await this.add('LDAP_DataSync_VariableMap', '{}', { + type: 'code', + multiline: true, + enableQuery: [enableQuery, { _id: 'LDAP_DataSync_UseVariables', value: true }], + invalidValue: '{}', + }); }); await this.section('LDAP_DataSync_Avatar', async function () { diff --git a/packages/core-typings/src/import/IImportUser.ts b/packages/core-typings/src/import/IImportUser.ts index de3b7806a300d..66841937cd90b 100644 --- a/packages/core-typings/src/import/IImportUser.ts +++ b/packages/core-typings/src/import/IImportUser.ts @@ -19,4 +19,5 @@ export interface IImportUser { password?: string; voipExtension?: string; + federated?: boolean; } diff --git a/packages/core-typings/src/ldap/ILDAPOptions.ts b/packages/core-typings/src/ldap/ILDAPOptions.ts index e7721dcd041d3..ad673f595efb5 100644 --- a/packages/core-typings/src/ldap/ILDAPOptions.ts +++ b/packages/core-typings/src/ldap/ILDAPOptions.ts @@ -28,4 +28,6 @@ export interface ILDAPConnectionOptions { authenticationUserDN: string; authenticationPassword: string; attributesToQuery: Array; + useVariables: boolean; + variableMap: string; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index eebaae1d817ec..d1f08dc7d31d5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3008,6 +3008,8 @@ "LDAP_Enable_Description": "Attempt to utilize LDAP for authentication.", "LDAP_Encryption": "Encryption", "LDAP_Encryption_Description": "The encryption method used to secure communications to the LDAP server. Examples include `plain` (no encryption), `SSL/LDAPS` (encrypted from the start), and `StartTLS` (upgrade to encrypted communication once connected).", + "LDAP_FederationHomeServer_Field": "Federation Home Server field", + "LDAP_FederationHomeServer_Field_Description": "The Home Server can only be assigned on user creation. Changing this will have no effect on users that were already synced.", "If_you_didnt_try_to_login_in_your_account_please_ignore_this_email": "If you didn't try to login in your account please ignore this email.", "LDAP_Find_User_After_Login": "Find user after login", "LDAP_Find_User_After_Login_Description": "Will perform a search of the user's DN after bind to ensure the bind was successful preventing login with empty passwords when allowed by the AD configuration.", @@ -3324,6 +3326,8 @@ "LDAP_UserSearch_GroupFilter": "Group Filter", "LDAP_DataSync": "Data Sync", "LDAP_DataSync_DataMap": "Mapping", + "LDAP_DataSync_UseVariables": "Use Variables", + "LDAP_DataSync_VariableMap": "Variables Configuration", "Members_List": "Members List", "mention-all": "Mention All", "LDAP_DataSync_Avatar": "Avatar",