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
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:
109
apps/meteor/server/lib/ldap/Connection.spec.ts
Normal file
109
apps/meteor/server/lib/ldap/Connection.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
861
apps/meteor/server/lib/ldap/Connection.ts
Normal file
861
apps/meteor/server/lib/ldap/Connection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
apps/meteor/server/lib/ldap/Logger.ts
Normal file
8
apps/meteor/server/lib/ldap/Logger.ts
Normal 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');
|
||||
562
apps/meteor/server/lib/ldap/Manager.ts
Normal file
562
apps/meteor/server/lib/ldap/Manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
apps/meteor/server/lib/ldap/UserConverter.ts
Normal file
67
apps/meteor/server/lib/ldap/UserConverter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
apps/meteor/server/lib/ldap/getLDAPConditionalSetting.ts
Normal file
9
apps/meteor/server/lib/ldap/getLDAPConditionalSetting.ts
Normal 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);
|
||||
}
|
||||
65
apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts
Normal file
65
apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
31
apps/meteor/server/lib/ldap/getLdapDynamicValue.ts
Normal file
31
apps/meteor/server/lib/ldap/getLdapDynamicValue.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
apps/meteor/server/lib/ldap/getLdapString.spec.ts
Normal file
47
apps/meteor/server/lib/ldap/getLdapString.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
5
apps/meteor/server/lib/ldap/getLdapString.ts
Normal file
5
apps/meteor/server/lib/ldap/getLdapString.ts
Normal 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()];
|
||||
}
|
||||
94
apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts
Normal file
94
apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
6
apps/meteor/server/lib/ldap/ldapKeyExists.ts
Normal file
6
apps/meteor/server/lib/ldap/ldapKeyExists.ts
Normal 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()]);
|
||||
}
|
||||
27
apps/meteor/server/lib/ldap/operations/executeOperation.ts
Normal file
27
apps/meteor/server/lib/ldap/operations/executeOperation.ts
Normal 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;
|
||||
}
|
||||
61
apps/meteor/server/lib/ldap/operations/fallback.spec.ts
Normal file
61
apps/meteor/server/lib/ldap/operations/fallback.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
24
apps/meteor/server/lib/ldap/operations/fallback.ts
Normal file
24
apps/meteor/server/lib/ldap/operations/fallback.ts
Normal 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);
|
||||
}
|
||||
106
apps/meteor/server/lib/ldap/operations/match.spec.ts
Normal file
106
apps/meteor/server/lib/ldap/operations/match.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
28
apps/meteor/server/lib/ldap/operations/match.ts
Normal file
28
apps/meteor/server/lib/ldap/operations/match.ts
Normal 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;
|
||||
}
|
||||
105
apps/meteor/server/lib/ldap/operations/replace.spec.ts
Normal file
105
apps/meteor/server/lib/ldap/operations/replace.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
23
apps/meteor/server/lib/ldap/operations/replace.ts
Normal file
23
apps/meteor/server/lib/ldap/operations/replace.ts
Normal 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);
|
||||
}
|
||||
48
apps/meteor/server/lib/ldap/operations/split.spec.ts
Normal file
48
apps/meteor/server/lib/ldap/operations/split.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
30
apps/meteor/server/lib/ldap/operations/split.ts
Normal file
30
apps/meteor/server/lib/ldap/operations/split.ts
Normal 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();
|
||||
}
|
||||
48
apps/meteor/server/lib/ldap/operations/substring.spec.ts
Normal file
48
apps/meteor/server/lib/ldap/operations/substring.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
13
apps/meteor/server/lib/ldap/operations/substring.ts
Normal file
13
apps/meteor/server/lib/ldap/operations/substring.ts
Normal 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);
|
||||
}
|
||||
38
apps/meteor/server/lib/ldap/processLdapVariables.ts
Normal file
38
apps/meteor/server/lib/ldap/processLdapVariables.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user