apps/meteor/server/lib/ldap/Manager.ts
import type { ILDAPEntry, LDAPLoginResult, ILDAPUniqueIdentifierField, IUser, LoginUsername, IImportUser } from '@rocket.chat/core-typings';
import { Users as UsersRaw } from '@rocket.chat/models';
import { SHA256 } from '@rocket.chat/sha256';
import ldapEscape from 'ldap-escape';
import limax from 'limax';
// #ToDo: #TODO: Remove Meteor dependencies
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import type { IConverterOptions } from '../../../app/importer/server/classes/ImportDataConverter';
import { setUserAvatar } from '../../../app/lib/server/functions/setUserAvatar';
import { settings } from '../../../app/settings/server';
import { callbacks } from '../../../lib/callbacks';
import { omit } from '../../../lib/utils/omit';
import { LDAPConnection } from './Connection';
import { LDAPDataConverter } from './DataConverter';
import { logger, authLogger, connLogger } from './Logger';
import { getLDAPConditionalSetting } from './getLDAPConditionalSetting';
export class LDAPManager {
public static async login(username: string, password: string): Promise<LDAPLoginResult> {
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);
// Bind connection to the admin user so that RC has full access to groups in the next steps
await ldap.bindAuthenticationUser();
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<LDAPLoginResult> {
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<void> {
try {
const ldap = new LDAPConnection();
await ldap.testConnection();
} catch (error) {
connLogger.error(error);
throw error;
}
}
public static async testSearch(username: string): Promise<void> {
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<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 });
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<IUser | undefined> {
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<boolean>('Accounts_Verify_Email_For_External_Accounts') ?? false,
skipExistingUsers: false,
skipUserCallbacks: 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).map((email) => email.trim());
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<ILDAPEntry | undefined> {
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.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) {
throw new Error('User not found');
}
if (!(await ldap.authenticate(ldapUser.dn, password))) {
logger.debug(`Wrong password for ${escapedUsername}`);
throw new Error('Invalid user or wrong password');
}
if (settings.get<boolean>('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`);
}
}
return ldapUser;
} catch (error) {
logger.error(error);
}
}
private static async findAuthenticatedUser(ldap: LDAPConnection, username: string): Promise<ILDAPEntry | undefined> {
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<boolean>('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<LDAPLoginResult> {
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<LDAPLoginResult> {
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<void> {
logger.debug('running onLDAPLogin');
if (settings.get<boolean>('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<LDAPLoginResult> {
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<boolean>('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<IUser | undefined> {
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<string>('LDAP_Unique_Identifier_Field');
if (uniqueIdentifierField) {
uniqueIdentifierField = uniqueIdentifierField.replace(/\s/g, '').split(',');
} else {
uniqueIdentifierField = [];
}
let userSearchField: string | string[] | undefined = getLDAPConditionalSetting<string>('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<string | undefined>('LDAP_Name_Field');
return this.getLdapDynamicValue(ldapUser, nameAttributes);
}
private static getLdapEmails(ldapUser: ILDAPEntry, username?: string): string[] {
const emailAttributes = getLDAPConditionalSetting<string>('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);
}
protected 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<IUser | undefined> {
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;
}
}
}