import limax from 'limax'; import { SHA256 } from '@rocket.chat/sha256'; // #ToDo: #TODO: Remove Meteor dependencies import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import ldapEscape from 'ldap-escape'; import _ from 'underscore'; import type { ILDAPEntry, LDAPLoginResult, ILDAPUniqueIdentifierField, IUser, LoginUsername, IImportUser } from '@rocket.chat/core-typings'; import { Users as UsersRaw } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; import { LDAPConnection } from './Connection'; import { LDAPDataConverter } from './DataConverter'; import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; import { logger, authLogger, connLogger } from './Logger'; import type { IConverterOptions } from '../../../app/importer/server/classes/ImportDataConverter'; import { callbacks } from '../../../lib/callbacks'; import { setUserAvatar } from '../../../app/lib/server/functions'; import { omit } from '../../../lib/utils/omit'; export class LDAPManager { public static async login(username: string, password: string): Promise { logger.debug({ msg: 'Init LDAP login', username }); if (settings.get('LDAP_Enable') !== true) { return this.fallbackToDefaultLogin(username, password); } let ldapUser: ILDAPEntry | undefined; const ldap = new LDAPConnection(); try { try { await ldap.connect(); ldapUser = await this.findUser(ldap, username, password); } catch (error) { logger.error(error); } if (ldapUser === undefined) { return this.fallbackToDefaultLogin(username, password); } const slugifiedUsername = this.slugifyUsername(ldapUser, username); const user = await this.findExistingUser(ldapUser, slugifiedUsername); if (user) { return await this.loginExistingUser(ldap, user, ldapUser, password); } return await this.loginNewUserFromLDAP(slugifiedUsername, ldap, ldapUser, password); } finally { ldap.disconnect(); } } public static async loginAuthenticatedUser(username: string): Promise { logger.debug({ msg: 'Init LDAP login', username }); if (settings.get('LDAP_Enable') !== true) { return; } let ldapUser: ILDAPEntry | undefined; const ldap = new LDAPConnection(); try { try { await ldap.connect(); ldapUser = await this.findAuthenticatedUser(ldap, username); } catch (error) { logger.error(error); } if (ldapUser === undefined) { return; } const slugifiedUsername = this.slugifyUsername(ldapUser, username); const user = await this.findExistingUser(ldapUser, slugifiedUsername); if (user) { return await this.loginExistingUser(ldap, user, ldapUser); } return await this.loginNewUserFromLDAP(slugifiedUsername, ldap, ldapUser); } finally { ldap.disconnect(); } } public static async testConnection(): Promise { try { const ldap = new LDAPConnection(); await ldap.testConnection(); } catch (error) { connLogger.error(error); throw error; } } public static async testSearch(username: string): Promise { const escapedUsername = ldapEscape.filter`${username}`; const ldap = new LDAPConnection(); try { await ldap.connect(); const users = await ldap.searchByUsername(escapedUsername); if (users.length !== 1) { logger.debug(`Search returned ${users.length} records for ${escapedUsername}`); throw new Error('User not found'); } } catch (error) { logger.error(error); throw error; } } public static async syncUserAvatar(user: IUser, ldapUser: ILDAPEntry): Promise { 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 }); await 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); if (uniqueIdentifierField) { logger.debug({ msg: 'Querying user', uniqueId: uniqueIdentifierField.value }); return UsersRaw.findOneByLDAPId(uniqueIdentifierField.value, uniqueIdentifierField.attribute); } } protected static getConverterOptions(): IConverterOptions { return { flagEmailsAsVerified: settings.get('Accounts_Verify_Email_For_External_Accounts') ?? false, skipExistingUsers: false, }; } protected static mapUserData(ldapUser: ILDAPEntry, usedUsername?: string | undefined): IImportUser { const uniqueId = this.getLdapUserUniqueID(ldapUser); if (!uniqueId) { throw new Error('Failed to generate unique identifier for ldap entry'); } const { attribute: idAttribute, value: id } = uniqueId; const username = this.slugifyUsername(ldapUser, usedUsername || id || '') || undefined; const emails = this.getLdapEmails(ldapUser, username); const name = this.getLdapName(ldapUser) || undefined; const userData: IImportUser = { type: 'user', emails, importIds: [ldapUser.dn], username, name, services: { ldap: { idAttribute, id, }, }, }; this.onMapUserData(ldapUser, userData); return userData; } private static onMapUserData(ldapUser: ILDAPEntry, userData: IImportUser): void { void callbacks.run('mapLDAPUserData', userData, ldapUser); } private static async findUser(ldap: LDAPConnection, username: string, password: string): Promise { const escapedUsername = ldapEscape.filter`${username}`; try { const users = await ldap.searchByUsername(escapedUsername); if (users.length !== 1) { logger.debug(`Search returned ${users.length} records for ${escapedUsername}`); throw new Error('User not found'); } const [ldapUser] = users; if (!(await ldap.authenticate(ldapUser.dn, password))) { logger.debug(`Wrong password for ${escapedUsername}`); throw new Error('Invalid user or wrong password'); } if (settings.get('LDAP_Find_User_After_Login')) { // Do a search as the user and check if they have any result authLogger.debug('User authenticated successfully, performing additional search.'); if ((await ldap.searchAndCount(ldapUser.dn, {})) === 0) { authLogger.debug(`Bind successful but user ${ldapUser.dn} was not found via search`); } } if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) { throw new Error('User not in a valid group'); } return ldapUser; } catch (error) { logger.error(error); } } private static async findAuthenticatedUser(ldap: LDAPConnection, username: string): Promise { const escapedUsername = ldapEscape.filter`${username}`; try { const users = await ldap.searchByUsername(escapedUsername); if (users.length !== 1) { logger.debug(`Search returned ${users.length} records for ${escapedUsername}`); return; } const [ldapUser] = users; if (settings.get('LDAP_Find_User_After_Login')) { // Do a search as the user and check if they have any result authLogger.debug('User authenticated successfully, performing additional search.'); if ((await ldap.searchAndCount(ldapUser.dn, {})) === 0) { authLogger.debug(`Bind successful but user ${ldapUser.dn} was not found via search`); } } if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) { throw new Error('User not in a valid group'); } return ldapUser; } catch (error) { logger.error(error); } } private static async loginNewUserFromLDAP( slugifiedUsername: string, ldap: LDAPConnection, ldapUser: ILDAPEntry, ldapPass?: string, ): Promise { logger.debug({ msg: 'User does not exist, creating', username: slugifiedUsername }); let username: string | undefined; if (getLDAPConditionalSetting('LDAP_Username_Field') !== '') { username = slugifiedUsername; } // Create new user return this.addLdapUser(ldapUser, username, ldapPass, ldap); } private static async addLdapUser( ldapUser: ILDAPEntry, username: string | undefined, password: string | undefined, ldap: LDAPConnection, ): Promise { const user = await this.syncUserForLogin(ldapUser, undefined, username); if (!user) { return; } await this.onLogin(ldapUser, user, password, ldap, true); return { userId: user._id, }; } private static async onLogin( ldapUser: ILDAPEntry, user: IUser, password: string | undefined, ldap: LDAPConnection, isNewUser: boolean, ): Promise { logger.debug('running onLDAPLogin'); if (settings.get('LDAP_Login_Fallback') && typeof password === 'string' && password.trim() !== '') { await Accounts.setPasswordAsync(user._id, password, { logout: false }); } await this.syncUserAvatar(user, ldapUser); await callbacks.run('onLDAPLogin', { user, ldapUser, isNewUser }, ldap); } private static async loginExistingUser( ldap: LDAPConnection, user: IUser, ldapUser: ILDAPEntry, password?: string, ): Promise { if (user.ldap !== true && settings.get('LDAP_Merge_Existing_Users') !== true) { logger.debug('User exists without "ldap: true"'); throw new Meteor.Error( 'LDAP-login-error', `LDAP Authentication succeeded, but there's already an existing user with provided username [${user.username}] in Mongo.`, ); } // If we're merging an ldap user with a local user, then we need to sync the data even if 'update data on login' is off. const forceUserSync = !user.ldap; const syncData = forceUserSync || (settings.get('LDAP_Update_Data_On_Login') ?? true); logger.debug({ msg: 'Logging user in', syncData }); const updatedUser = (syncData && (await this.syncUserForLogin(ldapUser, user))) || user; await this.onLogin(ldapUser, updatedUser, password, ldap, false); return { userId: user._id, }; } private static async syncUserForLogin( ldapUser: ILDAPEntry, existingUser?: IUser, usedUsername?: string | undefined, ): Promise { logger.debug({ msg: 'Syncing user data', ldapUser: omit(ldapUser, '_raw'), user: { ...(existingUser && { email: existingUser.emails, _id: existingUser._id }) }, }); const userData = this.mapUserData(ldapUser, usedUsername); // make sure to persist existing user data when passing to sync/convert // TODO this is only needed because ImporterDataConverter assigns a default role and type if nothing is set. we might need to figure out a better way and stop doing that there if (existingUser) { if (!userData.roles && existingUser.roles) { userData.roles = existingUser.roles; } if (!userData.type && existingUser.type) { userData.type = existingUser.type as IImportUser['type']; } } const options = this.getConverterOptions(); await LDAPDataConverter.convertSingleUser(userData, options); return existingUser || this.findExistingLDAPUser(ldapUser); } private static getLdapUserUniqueID(ldapUser: ILDAPEntry): ILDAPUniqueIdentifierField | undefined { let uniqueIdentifierField: string | string[] | undefined = settings.get('LDAP_Unique_Identifier_Field'); if (uniqueIdentifierField) { uniqueIdentifierField = uniqueIdentifierField.replace(/\s/g, '').split(','); } else { uniqueIdentifierField = []; } let userSearchField: string | string[] | undefined = getLDAPConditionalSetting('LDAP_User_Search_Field'); if (userSearchField) { userSearchField = userSearchField.replace(/\s/g, '').split(','); } else { userSearchField = []; } uniqueIdentifierField = uniqueIdentifierField.concat(userSearchField); if (!uniqueIdentifierField.length) { uniqueIdentifierField.push('dn'); } const key = uniqueIdentifierField.find((field) => !_.isEmpty(ldapUser._raw[field])); if (key) { return { attribute: key, value: ldapUser._raw[key].toString('hex'), }; } connLogger.warn('Failed to generate unique identifier for ldap entry'); 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); } 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 emails: string[] = [].concat(key ? ldapUser[key.trim()] : []); const filteredEmails = emails.filter((email) => email.includes('@')); if (filteredEmails.length) { return filteredEmails; } } if (settings.get('LDAP_Default_Domain') !== '' && username) { return [`${username}@${settings.get('LDAP_Default_Domain')}`]; } if (ldapUser.mail?.includes('@')) { return [ldapUser.mail]; } logger.debug(ldapUser); throw new Error('Failed to get email address from LDAP user'); } private static slugify(text: string): string { if (settings.get('UTF8_Names_Slugify') !== true) { return text; } text = limax(text, { replacement: '.' }); return text.replace(/[^0-9a-z-_.]/g, ''); } private static slugifyUsername(ldapUser: ILDAPEntry, requestUsername: string): string { if (getLDAPConditionalSetting('LDAP_Username_Field') !== '') { const username = this.getLdapUsername(ldapUser); if (username) { return this.slugify(username); } } return this.slugify(requestUsername); } private static getLdapUsername(ldapUser: ILDAPEntry): string | undefined { const usernameField = getLDAPConditionalSetting('LDAP_Username_Field') as string; return this.getLdapDynamicValue(ldapUser, usernameField); } // This method will find existing users by LDAP id or by username. private static async findExistingUser(ldapUser: ILDAPEntry, slugifiedUsername: string): Promise { const user = await this.findExistingLDAPUser(ldapUser); if (user) { return user; } // If we don't have that ldap user linked yet, check if there's any non-ldap user with the same username return UsersRaw.findOneWithoutLDAPByUsernameIgnoringCase(slugifiedUsername); } private static fallbackToDefaultLogin(username: LoginUsername, password: string): LDAPLoginResult { if (typeof username === 'string') { if (username.indexOf('@') === -1) { username = { username }; } else { username = { email: username }; } } logger.debug({ msg: 'Fallback to default account system', username }); const loginRequest = { user: username, password: { digest: SHA256(password), algorithm: 'sha-256', }, }; return Accounts._runLoginHandlers(this, loginRequest); } private static getAvatarFromUser(ldapUser: ILDAPEntry): any | undefined { const avatarField = String(settings.get('LDAP_Avatar_Field') || '').trim(); if (avatarField && ldapUser._raw[avatarField]) { return ldapUser._raw[avatarField]; } if (ldapUser._raw.thumbnailPhoto) { return ldapUser._raw.thumbnailPhoto; } if (ldapUser._raw.jpegPhoto) { return ldapUser._raw.jpegPhoto; } } }