snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const loggerStub = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() };
const { LDAPConnection } = proxyquire.noCallThru().load('./Connection', {
'../../settings': { settings: { get: sinon.stub() } },
'./getLDAPConditionalSetting': { getLDAPConditionalSetting: sinon.stub().returns('') },
'./Logger': {
logger: loggerStub,
connLogger: loggerStub,
bindLogger: loggerStub,
searchLogger: loggerStub,
authLogger: loggerStub,
mapLogger: loggerStub,
},
});
describe('LDAPConnection', () => {
describe('synchronous errors from client.search (e.g. invalid filters)', () => {
const parseError = new Error('invalid attribute name');
let connection: any;
beforeEach(() => {
connection = new LDAPConnection();
connection.options.userSearchField = 'uid';
connection.client = { search: sinon.stub().throws(parseError) };
});
it('should reject doCustomSearch with the error', async () => {
const error = await connection
.doCustomSearch('dc=test', { filter: '(&(=*))' }, () => undefined)
.then(
() => undefined,
(e: unknown) => e,
);
expect(error).to.equal(parseError);
});
it('should route the error to endCallback on paged searchAllUsers instead of leaking the throw', async () => {
const endCallback = sinon.stub();
await connection.searchAllUsers({ endCallback });
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
expect(endCallback.calledOnceWithExactly(parseError)).to.be.true;
});
it('should route the error to endCallback on non-paged searchAllUsers instead of leaking the throw', async () => {
connection.options.searchPageSize = 0;
const endCallback = sinon.stub();
await connection.searchAllUsers({ endCallback });
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
expect(endCallback.calledOnceWithExactly(parseError)).to.be.true;
});
});
describe('getUserFilter', () => {
let connection: any;
beforeEach(() => {
connection = new LDAPConnection();
});
it('should compose the filter for a single search field', () => {
connection.options.userSearchField = 'uid';
expect(connection.getUserFilter('john')).to.equal('(&(uid=john))');
});
it('should compose an OR filter for multiple search fields', () => {
connection.options.userSearchField = 'uid,sAMAccountName';
expect(connection.getUserFilter('john')).to.equal('(&(|(uid=john)(sAMAccountName=john)))');
});
it('should trim whitespace and ignore empty segments (e.g. trailing commas)', () => {
connection.options.userSearchField = ' uid , sAMAccountName ,';
expect(connection.getUserFilter('john')).to.equal('(&(|(uid=john)(sAMAccountName=john)))');
});
it('should include the user search filter when configured', () => {
connection.options.userSearchField = 'uid';
connection.options.userSearchFilter = '(objectclass=user)';
expect(connection.getUserFilter('*')).to.equal('(&(objectclass=user)(uid=*))');
});
it('should throw a configuration error when the search field is empty', () => {
connection.options.userSearchField = '';
expect(() => connection.getUserFilter('*')).to.throw('LDAP User Search Field is not configured');
});
it('should throw a configuration error when the search field only has empty segments', () => {
connection.options.userSearchField = ' , ,';
expect(() => connection.getUserFilter('*')).to.throw('LDAP User Search Field is not configured');
});
it('should reject searchAllUsers with the configuration error instead of composing an invalid filter', async () => {
connection.options.userSearchField = '';
connection.client = { search: sinon.stub() };
const endCallback = sinon.stub();
const error = await connection.searchAllUsers({ endCallback }).then(
() => undefined,
(e: unknown) => e,
);
expect(error).to.be.an('error').with.property('message', 'LDAP User Search Field is not configured');
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
expect(connection.client.search.called).to.be.false;
});
});
});

View File

@@ -0,0 +1,861 @@
import type {
ILDAPConnectionOptions,
LDAPEncryptionType,
LDAPSearchScope,
ILDAPEntry,
ILDAPCallback,
ILDAPPageCallback,
} from '@rocket.chat/core-typings';
import { wrapExceptions } from '@rocket.chat/tools';
import ldapjs from 'ldapjs';
import { logger, connLogger, searchLogger, authLogger, bindLogger, mapLogger } from './Logger';
import { getLDAPConditionalSetting } from './getLDAPConditionalSetting';
import { processLdapVariables, type LDAPVariableMap } from './processLdapVariables';
import { ensureArray } from '../../../lib/utils/arrayUtils';
import { settings } from '../../settings';
interface ILDAPEntryCallback<T> {
(entry: ldapjs.SearchEntry): T | undefined;
}
interface ILDAPSearchEndCallback {
(error?: any): void;
}
interface ILDAPSearchPageCallback {
(result: ldapjs.SearchEntry[]): void;
}
interface ILDAPSearchAllCallbacks<T> {
dataCallback?: ILDAPSearchPageCallback;
endCallback?: ILDAPSearchEndCallback;
entryCallback?: ILDAPEntryCallback<T>;
}
type ILDAPExtractedValue = string | Array<ILDAPExtractedValue>;
export class LDAPConnection {
public ldapjs: any;
public connected: boolean;
public options: ILDAPConnectionOptions;
public client: ldapjs.Client;
private _receivedResponse: boolean;
private _connectionTimedOut: boolean;
private _connectionCallback: ILDAPCallback;
private usingAuthentication: boolean;
private _variableMap: LDAPVariableMap;
constructor() {
this.ldapjs = ldapjs;
this.connected = false;
this._receivedResponse = false;
this._connectionTimedOut = false;
this.options = {
host: settings.get<string>('LDAP_Host') ?? '',
port: settings.get<number>('LDAP_Port') ?? 389,
reconnect: settings.get<boolean>('LDAP_Reconnect') ?? false,
timeout: settings.get<number>('LDAP_Timeout') ?? 60000,
connectionTimeout: settings.get<number>('LDAP_Connect_Timeout') ?? 1000,
idleTimeout: settings.get<number>('LDAP_Idle_Timeout') ?? 1000,
encryption: settings.get<LDAPEncryptionType>('LDAP_Encryption') ?? 'plain',
caCert: settings.get<string>('LDAP_CA_Cert'),
rejectUnauthorized: settings.get<boolean>('LDAP_Reject_Unauthorized') || false,
baseDN: settings.get<string>('LDAP_BaseDN') ?? '',
userSearchFilter: settings.get<string>('LDAP_User_Search_Filter') ?? '',
userSearchScope: settings.get<LDAPSearchScope>('LDAP_User_Search_Scope') ?? 'sub',
userSearchField: getLDAPConditionalSetting<string>('LDAP_User_Search_Field') ?? '',
searchPageSize: settings.get<number>('LDAP_Search_Page_Size') ?? 250,
searchSizeLimit: settings.get<number>('LDAP_Search_Size_Limit') ?? 1000,
uniqueIdentifierField: settings.get<string>('LDAP_Unique_Identifier_Field'),
groupFilterEnabled: settings.get<boolean>('LDAP_Group_Filter_Enable') ?? false,
groupFilterObjectClass: settings.get<string>('LDAP_Group_Filter_ObjectClass'),
groupFilterGroupIdAttribute: settings.get<string>('LDAP_Group_Filter_Group_Id_Attribute'),
groupFilterGroupMemberAttribute: settings.get<string>('LDAP_Group_Filter_Group_Member_Attribute'),
groupFilterGroupMemberFormat: settings.get<string>('LDAP_Group_Filter_Group_Member_Format'),
groupFilterGroupName: settings.get<string>('LDAP_Group_Filter_Group_Name'),
authentication: settings.get<boolean>('LDAP_Authentication') ?? false,
authenticationUserDN: settings.get<string>('LDAP_Authentication_UserDN') ?? '',
authenticationPassword: settings.get<string>('LDAP_Authentication_Password') ?? '',
useVariables: settings.get<boolean>('LDAP_DataSync_UseVariables') ?? false,
variableMap: settings.get<string>('LDAP_DataSync_VariableMap') ?? '{}',
attributesToQuery: this.parseAttributeList(settings.get<string>('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.');
}
if (!this.options.baseDN) {
logger.warn('LDAP Search BaseDN is not configured.');
}
}
public async connect(): Promise<any> {
return new Promise((resolve, reject) => {
this.initializeConnection((error, result) => {
if (error) {
return reject(error);
}
return resolve(result);
});
});
}
public disconnect(): void {
this.usingAuthentication = false;
this.connected = false;
connLogger.info('Disconnecting');
if (this.client) {
this.client.unbind();
}
}
public async testConnection(): Promise<void> {
try {
await this.connect();
await this.maybeBindDN();
} finally {
this.disconnect();
}
}
public async searchByUsername(escapedUsername: string): Promise<ILDAPEntry[]> {
const searchOptions: ldapjs.SearchOptions = {
filter: this.getUserFilter(escapedUsername),
scope: this.options.userSearchScope || 'sub',
sizeLimit: this.options.searchSizeLimit,
attributes: this.options.attributesToQuery,
};
if (this.options.searchPageSize > 0) {
searchOptions.paged = {
pageSize: this.options.searchPageSize,
pagePause: false,
};
}
searchLogger.info({
msg: 'Searching by username',
username: escapedUsername,
baseDN: this.options.baseDN,
searchOptions,
});
return this.search(this.options.baseDN, searchOptions);
}
public async findOneByUsername(username: string): Promise<ILDAPEntry | undefined> {
const results = await this.searchByUsername(username);
if (results.length === 1) {
return results[0];
}
}
public async searchById(id: string, attribute?: string): Promise<ILDAPEntry[]> {
const searchOptions: ldapjs.SearchOptions = {
scope: this.options.userSearchScope || 'sub',
attributes: this.options.attributesToQuery,
};
if (attribute) {
searchOptions.filter = new this.ldapjs.filters.EqualityFilter({
attribute,
value: Buffer.from(id, 'hex'),
});
} else if (this.options.uniqueIdentifierField) {
// If we don't know what attribute the id came from, we have to look for all of them.
const possibleFields = this.options.uniqueIdentifierField.split(',').concat(this.options.userSearchField.split(','));
const filters = [];
for (const field of possibleFields) {
if (!field) {
continue;
}
filters.push(
new this.ldapjs.filters.EqualityFilter({
attribute: field,
value: Buffer.from(id, 'hex'),
}),
);
}
searchOptions.filter = new this.ldapjs.filters.OrFilter({ filters });
} else {
throw new Error('Unique Identifier Field is not configured.');
}
searchLogger.info({ msg: 'Searching by id', id });
searchLogger.debug({ msg: 'search filter', searchOptions, baseDN: this.options.baseDN });
return this.search(this.options.baseDN, searchOptions);
}
public async findOneById(id: string, attribute?: string): Promise<ILDAPEntry | undefined> {
const results = await this.searchById(id, attribute);
if (results.length === 1) {
return results[0];
}
}
public async searchAllUsers<T = ldapjs.SearchEntry>({
dataCallback,
endCallback,
entryCallback,
}: ILDAPSearchAllCallbacks<T>): Promise<void> {
searchLogger.info('Searching all users');
const searchOptions: ldapjs.SearchOptions = {
filter: this.getUserFilter('*'),
scope: this.options.userSearchScope || 'sub',
sizeLimit: this.options.searchSizeLimit,
attributes: this.options.attributesToQuery,
};
if (this.options.searchPageSize > 0) {
let count = 0;
await this.doPagedSearch<T>(
this.options.baseDN,
searchOptions,
this.options.searchPageSize,
(error, entries: ldapjs.SearchEntry[], { end, next } = { end: false, next: undefined }) => {
if (error) {
endCallback?.(error);
return;
}
count += entries.length;
dataCallback?.(entries);
if (end) {
endCallback?.();
}
if (next) {
next(count);
}
},
entryCallback,
);
return;
}
await this.doAsyncSearch(
this.options.baseDN,
searchOptions,
(error, result) => {
dataCallback?.(result);
endCallback?.(error);
},
entryCallback,
);
}
public async authenticate(dn: string, password: string): Promise<boolean> {
authLogger.info({ msg: 'Authenticating', dn });
try {
await this.bindDN(dn, password);
authLogger.info({ msg: 'Authenticated', dn });
return true;
} catch (error) {
authLogger.info({ msg: 'Not authenticated', dn });
authLogger.debug({ msg: 'error', error });
return false;
}
}
public async search(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise<ILDAPEntry[]> {
return this.doCustomSearch<ILDAPEntry>(baseDN, searchOptions, (entry) => this.extractLdapEntryData(entry));
}
public async searchRaw(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise<ldapjs.SearchEntry[]> {
return this.doCustomSearch<ldapjs.SearchEntry>(baseDN, searchOptions, (entry) => entry);
}
public async searchAndCount(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise<number> {
let count = 0;
await this.doCustomSearch(baseDN, searchOptions, async () => {
count++;
});
return count;
}
public extractLdapAttribute(value: Buffer | Buffer[] | string): ILDAPExtractedValue {
if (Array.isArray(value)) {
return value.map((item) => this.extractLdapAttribute(item));
}
if (typeof value === 'string') {
return value;
}
return value.toString();
}
public extractLdapEntryData(entry: ldapjs.SearchEntry): ILDAPEntry {
const values: ILDAPEntry = {
_raw: entry.raw,
};
Object.keys(values._raw).forEach((key) => {
values[key] = this.extractLdapAttribute(values._raw[key]);
const dataType = typeof values[key];
// eslint-disable-next-line no-control-regex
if (dataType === 'string' && values[key].length > 100 && /[\x00-\x1F]/.test(values[key])) {
mapLogger.debug({
msg: 'Extracted Attribute',
key,
type: dataType,
length: values[key].length,
value: `${values[key].substr(0, 100)}...`,
});
return;
}
mapLogger.debug({ msg: 'Extracted Attribute', key, type: dataType, value: values[key] });
});
return processLdapVariables(values, this._variableMap);
}
public async doCustomSearch<T>(baseDN: string, searchOptions: ldapjs.SearchOptions, entryCallback: ILDAPEntryCallback<T>): Promise<T[]> {
await this.runBeforeSearch(searchOptions);
if (!searchOptions.scope) {
searchOptions.scope = this.options.userSearchScope || 'sub';
}
searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN });
let realEntries = 0;
return new Promise((resolve, reject) => {
this.clientSearch(baseDN, searchOptions, (err, res: ldapjs.SearchCallbackResponse) => {
if (err) {
searchLogger.error({ err });
reject(err);
return;
}
res.on('error', (err) => {
searchLogger.error({ err });
reject(err);
});
const entries: T[] = [];
res.on('searchEntry', (entry) => {
try {
const result = entryCallback(entry);
if (result) {
entries.push(result as T);
}
realEntries++;
} catch (err) {
searchLogger.error({ err });
}
});
res.on('end', () => {
searchLogger.info({
msg: 'LDAP search completed',
foundEntries: realEntries,
loadedEntries: entries.length,
});
resolve(entries);
});
});
});
}
/*
Create an LDAP search filter based on the username
*/
public getUserFilter(username: string): string {
const filter: string[] = [];
this.addUserFilters(filter, username);
const fields = this.options.userSearchField
.split(',')
.map((field) => field.trim())
.filter(Boolean);
if (!fields.length) {
throw new Error('LDAP User Search Field is not configured');
}
const usernameFilter = fields.map((field) => `(${field}=${username})`);
if (usernameFilter.length === 1) {
filter.push(`${usernameFilter[0]}`);
} else {
filter.push(`(|${usernameFilter.join('')})`);
}
return `(&${filter.join('')})`;
}
public async searchMembersOfGroupFilter(): Promise<string[]> {
if (!this.options.groupFilterEnabled) {
return [];
}
if (!this.options.groupFilterGroupMemberAttribute) {
return [];
}
if (!this.options.groupFilterGroupMemberFormat) {
searchLogger.debug('LDAP Group Filter is enabled but no group member format is set.');
return [];
}
const filter = ['(&'];
if (this.options.groupFilterObjectClass) {
filter.push(`(objectclass=${this.options.groupFilterObjectClass})`);
}
if (this.options.groupFilterGroupIdAttribute) {
filter.push(`(${this.options.groupFilterGroupIdAttribute}=${this.options.groupFilterGroupName})`);
}
filter.push(')');
const searchOptions: ldapjs.SearchOptions = {
filter: filter.join(''),
scope: 'sub',
};
searchLogger.debug({ msg: 'Group filter LDAP:', filter: searchOptions.filter });
const result = await this.searchRaw(this.options.baseDN, searchOptions);
if (!Array.isArray(result) || result.length === 0) {
searchLogger.debug({ msg: 'No groups found', result });
return [];
}
const members = this.extractLdapAttribute(result[0].raw[this.options.groupFilterGroupMemberAttribute]) as string | string[];
return ensureArray<string>(members);
}
public async isUserAcceptedByGroupFilter(username: string, userdn: string): Promise<boolean> {
if (!this.options.groupFilterEnabled) {
return true;
}
const filter = ['(&'];
if (this.options.groupFilterObjectClass) {
filter.push(`(objectclass=${this.options.groupFilterObjectClass})`);
}
if (this.options.groupFilterGroupMemberAttribute) {
filter.push(`(${this.options.groupFilterGroupMemberAttribute}=${this.options.groupFilterGroupMemberFormat})`);
}
if (this.options.groupFilterGroupIdAttribute) {
filter.push(`(${this.options.groupFilterGroupIdAttribute}=${this.options.groupFilterGroupName})`);
}
filter.push(')');
const searchOptions: ldapjs.SearchOptions = {
filter: filter
.join('')
.replace(/#{username}/g, username)
.replace(/#{userdn}/g, userdn),
scope: 'sub',
};
searchLogger.debug({ msg: 'Group filter LDAP:', filter: searchOptions.filter });
const result = await this.searchAndCount(this.options.baseDN, searchOptions);
if (result === 0) {
return false;
}
return true;
}
protected addUserFilters(filters: string[], _username: string): void {
const { userSearchFilter } = this.options;
if (userSearchFilter !== '') {
if (userSearchFilter[0] === '(') {
filters.push(`${userSearchFilter}`);
} else {
filters.push(`(${userSearchFilter})`);
}
}
}
public async bindDN(dn: string, password: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
this.client.bind(dn, password, (error) => {
if (error) {
return reject(error);
}
resolve();
});
} catch (error) {
reject(error);
}
});
}
private clientSearch(baseDN: string, searchOptions: ldapjs.SearchOptions, callback: ldapjs.SearchCallBack): void {
try {
this.client.search(baseDN, searchOptions, callback);
} catch (err) {
callback(err as ldapjs.Error, undefined as unknown as ldapjs.SearchCallbackResponse);
}
}
private async doAsyncSearch<T = ldapjs.SearchEntry>(
baseDN: string,
searchOptions: ldapjs.SearchOptions,
callback: ILDAPCallback,
entryCallback?: ILDAPEntryCallback<T>,
): Promise<void> {
await this.runBeforeSearch(searchOptions);
searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN });
this.clientSearch(baseDN, searchOptions, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse): void => {
if (err) {
searchLogger.error({ err });
callback(err);
return;
}
res.on('error', (err) => {
searchLogger.error({ err });
callback(err);
});
const entries: T[] = [];
res.on('searchEntry', (entry) => {
try {
const result = entryCallback ? entryCallback(entry) : entry;
entries.push(result as T);
} catch (err) {
searchLogger.error({ err });
}
});
res.on('end', () => {
searchLogger.info({ msg: 'Search result count', count: entries.length });
callback(null, entries);
});
});
}
private processSearchPage<T>(
{ entries, title, end, next }: { entries: T[]; title: string; end: boolean; next?: () => void },
callback: ILDAPPageCallback,
): void {
searchLogger.info(title);
// Force LDAP idle to wait the record processing
this._updateIdle(true);
callback(null, entries, {
end,
next: () => {
// Reset idle timer
this._updateIdle();
next?.();
},
});
}
private async doPagedSearch<T = ldapjs.SearchEntry>(
baseDN: string,
searchOptions: ldapjs.SearchOptions,
pageSize: number,
callback: ILDAPPageCallback,
entryCallback?: ILDAPEntryCallback<T>,
): Promise<void> {
searchOptions.paged = {
pageSize,
pagePause: true,
};
await this.runBeforeSearch(searchOptions);
searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN });
this.clientSearch(baseDN, searchOptions, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse): void => {
if (err) {
searchLogger.error({ err });
callback(err);
return;
}
res.on('error', (err) => {
searchLogger.error({ err });
callback(err);
});
let entries: T[] = [];
const internalPageSize = pageSize * 2;
res.on('searchEntry', (entry) => {
try {
const result = entryCallback ? entryCallback(entry) : entry;
entries.push(result as T);
if (entries.length >= internalPageSize) {
this.processSearchPage<T>(
{
entries,
title: 'Internal Page',
end: false,
},
callback,
);
entries = [];
}
} catch (err) {
searchLogger.error({ err });
}
});
res.on('page', (_result, next) => {
if (!next) {
this._updateIdle(true);
this.processSearchPage<T>(
{
entries,
title: 'Final Page',
end: true,
},
callback,
);
entries = [];
} else if (entries.length) {
this.processSearchPage<T>(
{
entries,
title: 'Page',
end: false,
next,
},
callback,
);
entries = [];
}
});
res.on('end', () => {
if (entries.length) {
this.processSearchPage<T>(
{
entries,
title: 'Final Page',
end: true,
},
callback,
);
entries = [];
}
});
});
}
private _updateIdle(override?: boolean): void {
// @ts-expect-error use a private function to signal to the lib that we're still working
this.client._updateIdle(override);
}
protected async maybeBindDN({ forceBindAuthenticationUser = false } = {}): Promise<void> {
if (!forceBindAuthenticationUser && (this.usingAuthentication || !this.options.authentication)) {
return;
}
if (!this.options.authenticationUserDN) {
logger.error('Invalid UserDN for authentication');
return;
}
bindLogger.info({ msg: 'Binding UserDN', userDN: this.options.authenticationUserDN });
try {
await this.bindDN(this.options.authenticationUserDN, this.options.authenticationPassword);
this.usingAuthentication = true;
} catch (error) {
authLogger.error({
msg: 'Base Authentication Issue',
err: error,
dn: this.options.authenticationUserDN,
});
this.usingAuthentication = false;
}
}
protected async runBeforeSearch(_searchOptions: ldapjs.SearchOptions): Promise<void> {
return this.maybeBindDN();
}
public async bindAuthenticationUser(): Promise<void> {
return this.maybeBindDN({ forceBindAuthenticationUser: true });
}
/*
Get list of options to initialize a new ldapjs Client
*/
private getClientOptions(): {
clientOptions: ldapjs.ClientOptions;
tlsOptions: Record<string, any>;
} {
const clientOptions: ldapjs.ClientOptions = {
url: `${this.options.host}:${this.options.port}`,
timeout: this.options.timeout,
connectTimeout: this.options.connectionTimeout,
idleTimeout: this.options.idleTimeout,
reconnect: this.options.reconnect,
};
const tlsOptions: Record<string, any> = {
rejectUnauthorized: this.options.rejectUnauthorized,
};
if (this.options.caCert) {
// Split CA cert into array of strings
const chainLines = this.options.caCert.split('\n');
let cert: string[] = [];
const ca: string[] = [];
chainLines.forEach((line) => {
cert.push(line);
if (line.match(/-END CERTIFICATE-/)) {
ca.push(cert.join('\n'));
cert = [];
}
});
tlsOptions.ca = ca;
}
if (this.options.encryption === 'ssl') {
clientOptions.url = `ldaps://${clientOptions.url}`;
clientOptions.tlsOptions = tlsOptions;
} else {
clientOptions.url = `ldap://${clientOptions.url}`;
}
return {
clientOptions,
tlsOptions,
};
}
private handleConnectionResponse(err: any, response?: any): void {
if (!this._receivedResponse) {
this._receivedResponse = true;
this._connectionCallback(err, response);
return;
}
if (this._connectionTimedOut && !err) {
connLogger.info('Received a response after the connection timedout.');
} else {
logger.debug('Ignored error/response:');
}
connLogger.debug({ err, response });
}
private initializeConnection(callback: ILDAPCallback): void {
connLogger.info('Init Setup');
this._receivedResponse = false;
this._connectionTimedOut = false;
this._connectionCallback = callback;
const { clientOptions, tlsOptions } = this.getClientOptions();
connLogger.info({ msg: 'Connecting', url: clientOptions.url });
connLogger.debug({ msg: 'clientOptions', clientOptions });
this.client = ldapjs.createClient(clientOptions);
this.client.on('error', (error) => {
connLogger.error({ err: error });
this.handleConnectionResponse(error, null);
});
this.client.on('idle', () => {
searchLogger.info('Idle');
this.disconnect();
});
this.client.on('close', () => {
searchLogger.info('Closed');
});
if (this.options.encryption === 'tls') {
// Set host parameter for tls.connect which is used by ldapjs starttls. This may not be needed anymore
// https://github.com/RocketChat/Rocket.Chat/issues/2035
// https://github.com/mcavage/node-ldapjs/issues/349
tlsOptions.host = this.options.host;
connLogger.info('Starting TLS');
connLogger.debug({ msg: 'tlsOptions', tlsOptions });
this.client.starttls(tlsOptions, null, (err, response) => {
if (err) {
connLogger.error({ msg: 'TLS connection', err });
return this.handleConnectionResponse(err, null);
}
connLogger.info('TLS connected');
this.connected = true;
this.handleConnectionResponse(null, response);
});
} else {
this.client.on('connect', (response) => {
connLogger.info('LDAP connected');
this.connected = true;
this.handleConnectionResponse(null, response);
});
}
setTimeout(() => {
if (!this._receivedResponse) {
connLogger.error({ msg: 'connection time out', timeout: clientOptions.connectTimeout });
this.handleConnectionResponse(new Error('Timeout'));
this._connectionTimedOut = true;
}
}, clientOptions.connectTimeout);
}
private parseAttributeList(csv: string | undefined): Array<string> {
if (!csv) {
return ['*', '+'];
}
const list = csv.split(',').map((item) => item.trim());
if (!list?.length) {
return ['*', '+'];
}
return list;
}
}

View File

@@ -0,0 +1,8 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('LDAP');
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');

View File

@@ -0,0 +1,562 @@
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 { 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 { omit } from '../../../lib/utils/omit';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import type { UserConverterOptions } from '../import/classes/converters/UserConverter';
import { setUserAvatar } from '../users/setUserAvatar';
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 (err) {
logger.error({ err });
}
if (ldapUser === undefined) {
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);
// 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 (err) {
logger.error({ err });
}
if (ldapUser === undefined) {
return;
}
const homeServer = this.getFederationHomeServer(ldapUser);
if (homeServer) {
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 (err) {
connLogger.error({ err });
throw err;
}
}
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({ msg: 'Search results', count: users.length, username: escapedUsername });
throw new Error('User not found');
}
} catch (err) {
logger.error({ err });
throw err;
}
}
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 | null> {
const uniqueIdentifierField = this.getLdapUserUniqueID(ldapUser);
if (uniqueIdentifierField) {
logger.debug({ msg: 'Querying user', uniqueId: uniqueIdentifierField.value });
return UsersRaw.findOneByLDAPId<IUser>(uniqueIdentifierField.value, uniqueIdentifierField.attribute);
}
}
protected static getConverterOptions(): UserConverterOptions {
return {
flagEmailsAsVerified: settings.get<boolean>('Accounts_Verify_Email_For_External_Accounts') ?? false,
skipExistingUsers: false,
skipUserCallbacks: false,
syncVoipExtension: Boolean(settings.get<string>('LDAP_Extension_Field')),
};
}
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 homeServer = this.getFederationHomeServer(ldapUser);
const emails = homeServer ? [] : this.getLdapEmails(ldapUser, username).map((email) => email.trim());
const name = this.getLdapName(ldapUser) || undefined;
const voipExtension = this.getLdapExtension(ldapUser);
const userData: IImportUser = {
type: 'user',
emails,
importIds: [ldapUser.dn],
username,
name,
voipExtension,
services: {
ldap: {
idAttribute,
id,
},
},
...(homeServer && {
username: `@${username}:${homeServer}`,
federated: true,
federation: {
version: 1,
mui: `@${username}:${homeServer}`,
origin: homeServer,
},
}),
};
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({ msg: 'Search results', count: users.length, username: 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({ msg: 'Wrong password', username: 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({ msg: 'Bind successful but user was not found via search', dn: ldapUser.dn });
}
}
return ldapUser;
} catch (err) {
logger.error({ err });
}
}
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({ msg: 'Search results', count: users.length, username: 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({ msg: 'Bind successful but user was not found via search', dn: ldapUser.dn });
}
}
if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) {
throw new Error('User not in a valid group');
}
return ldapUser;
} catch (err) {
logger.error({ err });
}
}
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 | null> {
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 LDAPUserConverter.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 getLdapName(ldapUser: ILDAPEntry): string | undefined {
const nameAttributes = getLDAPConditionalSetting<string | undefined>('LDAP_Name_Field');
return getLdapDynamicValue(ldapUser, nameAttributes);
}
private static getLdapExtension(ldapUser: ILDAPEntry): string | undefined {
const extensionAttribute = settings.get<string>('LDAP_Extension_Field');
if (!extensionAttribute) {
return;
}
return getLdapString(ldapUser, extensionAttribute);
}
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) => 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 getLdapDynamicValue(ldapUser, usernameField);
}
protected static getFederationHomeServer(ldapUser: ILDAPEntry): string | undefined {
if (!settings.get<boolean>('Federation_Service_Enabled')) {
return;
}
const homeServerField = settings.get<string>('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<string>('Federation_Service_Domain');
if (localServer === homeServer) {
return;
}
return homeServer;
}
// This method will find existing users by LDAP id or by username.
private static async findExistingUser(ldapUser: ILDAPEntry, slugifiedUsername: string): Promise<IUser | undefined | null> {
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<IUser>(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;
}
}
}

View File

@@ -0,0 +1,67 @@
import type { IImportUser, IUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import type { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { logger } from './Logger';
import { settings } from '../../settings';
import type { ConverterCache } from '../import/classes/converters/ConverterCache';
import type { RecordConverterOptions } from '../import/classes/converters/RecordConverter';
import { UserConverter, type UserConverterOptions } from '../import/classes/converters/UserConverter';
export class LDAPUserConverter extends UserConverter {
private mergeExistingUsers: boolean;
constructor(options?: UserConverterOptions & RecordConverterOptions, logger?: Logger, cache?: ConverterCache) {
const ldapOptions = {
workInMemory: true,
...(options || {}),
};
super(ldapOptions, logger, cache);
this.mergeExistingUsers = settings.get<boolean>('LDAP_Merge_Existing_Users') ?? true;
}
override async findExistingUser(data: IImportUser): Promise<IUser | undefined | null> {
if (data.services?.ldap?.id) {
const importedUser = await Users.findOneByLDAPId(data.services.ldap.id, data.services.ldap.idAttribute);
if (importedUser) {
return importedUser;
}
}
if (!this.mergeExistingUsers) {
return;
}
if (data.emails.length) {
const emailUser = await Users.findOneWithoutLDAPByEmailAddress(data.emails[0], {});
if (emailUser) {
return emailUser;
}
}
if (data.username) {
return Users.findOneWithoutLDAPByUsernameIgnoringCase<IUser>(data.username);
}
}
override async insertUser(userData: IImportUser): Promise<IUser['_id']> {
if (!userData.deleted) {
// #TODO: Change the LDAP sync process to split the inserts and updates into two stages so that we can validate this only once for all insertions
if (await License.shouldPreventAction('activeUsers')) {
logger.warn({ msg: 'Max users allowed reached, creating new LDAP users in inactive state ', username: userData.username });
userData.deleted = true;
}
}
return super.insertUser(userData);
}
static async convertSingleUser(userData: IImportUser, options?: UserConverterOptions): Promise<void> {
const converter = new LDAPUserConverter(options);
await converter.addObject(userData);
await converter.convertData();
}
}

View File

@@ -0,0 +1,9 @@
import type { SettingValue } from '@rocket.chat/core-typings';
import { settings } from '../../settings';
export function getLDAPConditionalSetting<T extends SettingValue = SettingValue>(settingName: string): T | undefined {
const isActiveDirectory = settings.get<string>('LDAP_Server_Type') === 'ad';
const key = isActiveDirectory ? settingName.replace('LDAP_', 'LDAP_AD_') : settingName;
return settings.get<T>(key);
}

View File

@@ -0,0 +1,65 @@
import type { ILDAPEntry } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import 'mocha';
import { getLdapDynamicValue } from './getLdapDynamicValue';
describe('getLdapDynamicValue', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
displayName: 'John Doe',
email: 'john.doe@example.com',
uid: 'johndoe',
emptyField: '',
};
it('should return undefined if attributeSetting is undefined', () => {
const result = getLdapDynamicValue(ldapUser, undefined);
expect(result).to.be.undefined;
});
it('should return the correct value from a single valid attribute', () => {
const result = getLdapDynamicValue(ldapUser, 'displayName');
expect(result).to.equal('John Doe');
});
it('should return the correct value from a template attribute', () => {
const result = getLdapDynamicValue(ldapUser, 'Hello, #{displayName}!');
expect(result).to.equal('Hello, John Doe!');
});
it('should replace missing keys with an empty string in a template', () => {
const result = getLdapDynamicValue(ldapUser, 'Hello, #{nonExistentField}!');
expect(result).to.equal('Hello, !');
});
it('should return the first valid key from a CSV list of attributes', () => {
const result = getLdapDynamicValue(ldapUser, 'nonExistentField,email,uid');
expect(result).to.equal('john.doe@example.com');
});
it('should return undefined if none of the keys in CSV list exist', () => {
const result = getLdapDynamicValue(ldapUser, 'nonExistentField,anotherNonExistentField');
expect(result).to.be.undefined;
});
it('should handle attribute keys with surrounding whitespace correctly', () => {
const result = getLdapDynamicValue(ldapUser, ' email ');
expect(result).to.equal('john.doe@example.com');
});
it('should correctly resolve multiple variables in a template', () => {
const result = getLdapDynamicValue(ldapUser, 'User: #{displayName}, Email: #{email}, UID: #{uid}');
expect(result).to.equal('User: John Doe, Email: john.doe@example.com, UID: johndoe');
});
it('should return undefined if the attribute has an empty value', () => {
const result = getLdapDynamicValue(ldapUser, 'emptyField');
expect(result).to.be.undefined;
});
it('should return an empty string if using only a template attribute that has an empty value', () => {
const result = getLdapDynamicValue(ldapUser, '#{emptyField}');
expect(result).to.be.equal('');
});
});

View File

@@ -0,0 +1,31 @@
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)) {
// We've already validated so it won't ever return undefined, but add a fallback to ensure it doesn't break if something gets changed
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);
}
}

View File

@@ -0,0 +1,47 @@
import type { ILDAPEntry } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import 'mocha';
import { getLdapString } from './getLdapString';
const ldapUser: ILDAPEntry = {
_raw: {},
username: 'john_doe',
email: 'john.doe@example.com',
phoneNumber: '123-456-7890',
memberOf: 'group1,group2',
};
describe('getLdapString', () => {
it('should return the correct value for a given key', () => {
expect(getLdapString(ldapUser, 'username')).to.equal('john_doe');
expect(getLdapString(ldapUser, 'email')).to.equal('john.doe@example.com');
expect(getLdapString(ldapUser, 'phoneNumber')).to.equal('123-456-7890');
expect(getLdapString(ldapUser, 'memberOf')).to.equal('group1,group2');
});
it('should trim the key and return the correct value', () => {
expect(getLdapString(ldapUser, ' username ')).to.equal('john_doe');
expect(getLdapString(ldapUser, ' email ')).to.equal('john.doe@example.com');
});
it('should return undefined for non-existing keys', () => {
expect(getLdapString(ldapUser, 'nonExistingKey')).to.be.undefined;
expect(getLdapString(ldapUser, 'foo')).to.be.undefined;
});
it('should handle empty keys and return an empty string', () => {
expect(getLdapString(ldapUser, '')).to.be.undefined;
expect(getLdapString(ldapUser, ' ')).to.be.undefined;
});
it('should handle keys with only whitespace', () => {
expect(getLdapString(ldapUser, ' ')).to.be.undefined;
expect(getLdapString(ldapUser, ' ')).to.be.undefined;
});
it('should handle case-sensitive keys accurately', () => {
expect(getLdapString(ldapUser, 'Username')).to.be.undefined;
expect(getLdapString(ldapUser, 'EMAIL')).to.be.undefined;
});
});

View File

@@ -0,0 +1,5 @@
import type { ILDAPEntry } from '@rocket.chat/core-typings';
export function getLdapString(ldapUser: ILDAPEntry, key: string): string | undefined {
return ldapUser[key.trim()];
}

View File

@@ -0,0 +1,94 @@
import type { ILDAPEntry } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { ldapKeyExists } from './ldapKeyExists';
describe('ldapKeyExists', () => {
it('should return true when key exists and is not empty', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
cn: 'John Doe',
mail: 'john.doe@example.com',
};
expect(ldapKeyExists(ldapUser, 'cn')).to.be.true;
expect(ldapKeyExists(ldapUser, 'mail')).to.be.true;
});
it('should return false when key exists but is empty', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
cn: '',
mail: '',
};
expect(ldapKeyExists(ldapUser, 'cn')).to.be.false;
expect(ldapKeyExists(ldapUser, 'mail')).to.be.false;
});
it('should return false when key does not exist', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
cn: 'John Doe',
};
expect(ldapKeyExists(ldapUser, 'mail')).to.be.false;
});
it('should trim the key before checking', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
cn: 'John Doe',
};
expect(ldapKeyExists(ldapUser, ' cn ')).to.be.true;
expect(ldapKeyExists(ldapUser, ' mail ')).to.be.false;
});
it('should return false for empty keys', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
cn: 'John Doe',
};
expect(ldapKeyExists(ldapUser, '')).to.be.false;
expect(ldapKeyExists(ldapUser, ' ')).to.be.false;
});
it('should handle keys with different casing', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
CN: 'John Doe',
};
expect(ldapKeyExists(ldapUser, 'CN')).to.be.true;
expect(ldapKeyExists(ldapUser, 'cn')).to.be.false;
});
// #TODO: We only work with strings so this doesn't matter, but why are numbers and booleans being considered "empty"?
it('should treat primitive non-string values as empty', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
numberValue: 123,
booleanValue: true,
anotherBooleanValue: false,
};
expect(ldapKeyExists(ldapUser, 'numberValue')).to.be.false;
expect(ldapKeyExists(ldapUser, 'booleanValue')).to.be.false;
expect(ldapKeyExists(ldapUser, 'anotherBooleanValue')).to.be.false;
});
it('should treat non-string values as empty', () => {
const ldapUser: ILDAPEntry = {
_raw: {},
nullValue: null,
undefinedValue: undefined,
objectValue: {},
arrayValue: [],
};
expect(ldapKeyExists(ldapUser, 'nullValue')).to.be.false;
expect(ldapKeyExists(ldapUser, 'undefinedValue')).to.be.false;
expect(ldapKeyExists(ldapUser, 'objectValue')).to.be.false;
expect(ldapKeyExists(ldapUser, 'arrayValue')).to.be.false;
});
});

View File

@@ -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()]);
}

View File

@@ -0,0 +1,27 @@
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;
}

View File

@@ -0,0 +1,61 @@
import type { ILDAPEntry } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import 'mocha';
import type { LDAPVariableFallback } from './fallback';
import { executeFallback } from './fallback';
describe('executeFallback function', () => {
const mockUser: ILDAPEntry = {
_raw: {},
defaultFallback: 'defaultFallbackValue',
};
it('should return the input value when it is valid and no minLength is provided', () => {
const input = 'validInput';
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' };
const result = executeFallback(mockUser, input, operation);
expect(result).to.equal(input);
});
it('should return the input value when it is valid and meets minLength requirement', () => {
const input = 'validInput';
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 5 };
const result = executeFallback(mockUser, input, operation);
expect(result).to.equal(input);
});
it('should return fallback when input is invalid', () => {
const input = '';
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' };
const result = executeFallback(mockUser, input, operation);
expect(result).to.equal('defaultFallbackValue');
});
it('should return fallback when input is too short', () => {
const input = 'short';
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 10 };
const result = executeFallback(mockUser, input, operation);
expect(result).to.equal('defaultFallbackValue');
});
it('should return fallback when input is undefined', () => {
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' };
const result = executeFallback(mockUser, undefined as any, operation);
expect(result).to.equal('defaultFallbackValue');
});
it('should return fallback when input is an empty string and minLength is zero', () => {
const input = '';
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 0 };
const result = executeFallback(mockUser, input, operation);
expect(result).to.equal('defaultFallbackValue');
});
it('should return fallback when input is an empty string and minLength is undefined', () => {
const input = '';
const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: undefined };
const result = executeFallback(mockUser, input, operation);
expect(result).to.equal('defaultFallbackValue');
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,106 @@
import { expect } from 'chai';
import 'mocha';
import type { LDAPVariableMatch } from './match';
import { executeMatch } from './match';
describe('executeMatch function', () => {
describe('Validation', () => {
it('throws an error when pattern is missing', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: '',
regex: true,
};
expect(() => executeMatch('input', operation)).to.throw('Invalid MATCH operation.');
});
it('throws an error when neither valueIfTrue nor indexToUse is provided', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: 'pattern',
};
expect(() => executeMatch('input', operation)).to.throw('Invalid MATCH operation.');
});
});
describe('Non-Regex Matching', () => {
it('returns valueIfTrue when input matches pattern', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: 'hello',
valueIfTrue: 'matched',
valueIfFalse: 'not matched',
};
expect(executeMatch('hello', operation)).to.be.equal('matched');
});
it('returns valueIfFalse when input does not match pattern', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: 'hello',
valueIfTrue: 'matched',
valueIfFalse: 'not matched',
};
expect(executeMatch('world', operation)).to.be.equal('not matched');
});
});
describe('Regex Matching', () => {
it('returns valueIfTrue when input matches regex pattern', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: '^hello$',
regex: true,
valueIfTrue: 'matched',
valueIfFalse: 'not matched',
};
expect(executeMatch('hello', operation)).to.be.equal('matched');
});
it('returns valueIfFalse when input does not match regex pattern', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: '^hello$',
regex: true,
valueIfTrue: 'matched',
valueIfFalse: 'not matched',
};
expect(executeMatch('world', operation)).to.be.equal('not matched');
});
it('uses flags when provided', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: '^HELLO$',
regex: true,
flags: 'i',
valueIfTrue: 'matched',
valueIfFalse: 'not matched',
};
expect(executeMatch('hello', operation)).to.be.equal('matched');
});
});
describe('IndexToUse', () => {
it('returns the matched group at indexToUse', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: '(hello) (world)',
regex: true,
indexToUse: 1,
};
expect(executeMatch('hello world', operation)).to.be.equal('hello');
});
it('returns undefined when indexToUse is out of range', () => {
const operation: LDAPVariableMatch = {
operation: 'match',
pattern: '(hello)',
regex: true,
indexToUse: 2,
};
expect(executeMatch('hello', operation)).to.be.undefined;
});
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,105 @@
import { expect } from 'chai';
import 'mocha';
import type { LDAPVariableReplace } from './replace';
import { executeReplace } from './replace';
describe('executeReplace', () => {
describe('Validation', () => {
it('throws an error when pattern is missing', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: '',
replacement: 'new-value',
};
expect(() => executeReplace('input', operation)).to.throw('Invalid REPLACE operation.');
});
it('throws an error when replacement is not a string', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old-value',
replacement: 123 as any,
};
expect(() => executeReplace('input', operation)).to.throw('Invalid REPLACE operation.');
});
});
describe('String Replacement', () => {
it('replaces the first occurrence of the pattern', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old',
replacement: 'new',
};
expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value old-value');
});
it('replaces all occurrences of the pattern when `all` is true', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old',
replacement: 'new',
all: true,
};
expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value');
});
});
describe('Regex Replacement', () => {
it('replaces the first occurrence of the pattern', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old',
replacement: 'new',
regex: true,
};
expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value old-value');
});
it('replaces all occurrences of the pattern when `regex` and `all` are true', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old',
replacement: 'new',
regex: true,
all: true,
};
expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value');
});
it('uses the provided flags', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'OLD',
replacement: 'new',
regex: true,
flags: 'i',
};
expect(executeReplace('OLD-value old-value', operation)).to.be.equal('new-value old-value');
});
it('adds the `g` flag when `all` is true', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old',
replacement: 'new',
regex: true,
all: true,
};
expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value');
});
it('does not duplicate the `g` flag when already present', () => {
const operation: LDAPVariableReplace = {
operation: 'replace',
pattern: 'old',
replacement: 'new',
regex: true,
all: true,
flags: 'g',
};
expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value');
});
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,48 @@
import { expect } from 'chai';
import 'mocha';
import type { LDAPVariableSplit } from './split';
import { executeSplit } from './split';
describe('executeSplit function', () => {
it('should throw an error if the pattern is empty', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: '' };
expect(() => executeSplit('input', operation)).to.throw('Invalid SPLIT operation.');
});
it('should split the input string by the pattern and return the first element by default', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' };
const result = executeSplit('hello,world', operation);
expect(result).to.be.equal('hello');
});
it('should split the input string by the pattern and return the element at the specified index', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: ',', indexToUse: 1 };
const result = executeSplit('hello,world', operation);
expect(result).to.be.equal('world');
});
it('should return undefined if the index is out of range', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: ',', indexToUse: 2 };
const result = executeSplit('hello,world', operation);
expect(result).to.be.undefined;
});
it('should return undefined if the input string does not contain the pattern', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' };
const result = executeSplit('helloworld', operation);
expect(result).to.be.equal('helloworld');
});
it('should return the first element if the input string is empty', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' };
const result = executeSplit('', operation);
expect(result).to.be.equal('');
});
it('should return undefined if the input string is undefined', () => {
const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' };
const result = executeSplit(undefined as any, operation);
expect(result).to.be.undefined;
});
});

View File

@@ -0,0 +1,30 @@
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.');
}
if (!input) {
return input;
}
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();
}

View File

@@ -0,0 +1,48 @@
import { expect } from 'chai';
import 'mocha';
import type { LDAPVariableSubString } from './substring';
import { executeSubstring } from './substring';
describe('executeSubstring function', () => {
it('should throw an error if the start is missing', () => {
const operation: LDAPVariableSubString = { operation: 'substring' } as unknown as LDAPVariableSubString;
expect(() => executeSubstring('input', operation)).to.throw('Invalid SUBSTRING operation.');
});
it('should throw an error if the start is invalid', () => {
const operation: LDAPVariableSubString = { operation: 'substring', start: 0, end: null } as unknown as LDAPVariableSubString;
expect(() => executeSubstring('input', operation)).to.throw('Invalid SUBSTRING operation.');
});
it('should get the substring of the input, using the start param', () => {
const result = executeSubstring('hello world', { operation: 'substring', start: 6 });
expect(result).to.be.equal('world');
});
it('should get the whole string when the start is zero', () => {
const result = executeSubstring('hello world', { operation: 'substring', start: 0 });
expect(result).to.be.equal('hello world');
});
it('should get the substring of the input, using the start and end param', () => {
const result = executeSubstring('hello world', { operation: 'substring', start: 6, end: 8 });
expect(result).to.be.equal('wo');
});
it('should work backwards if end is smaller than start', () => {
const result = executeSubstring('hello world', { operation: 'substring', start: 5, end: 0 });
expect(result).to.be.equal('hello');
});
it('should get an empty string if start and end are the same', () => {
expect(executeSubstring('hello world', { operation: 'substring', start: 0, end: 0 })).to.be.equal('');
expect(executeSubstring('hello world', { operation: 'substring', start: 5, end: 5 })).to.be.equal('');
});
it('should treat negative values as zero', () => {
expect(executeSubstring('hello world', { operation: 'substring', start: -4, end: 5 })).to.be.equal('hello');
expect(executeSubstring('hello world', { operation: 'substring', start: 5, end: -4 })).to.be.equal('hello');
expect(executeSubstring('hello world', { operation: 'substring', start: -5, end: -4 })).to.be.equal('');
});
});

View File

@@ -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);
}

View File

@@ -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<string, LDAPVariableConfiguration>;
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;
}