From 3c2a4000aedc3442542fa655f6be9c8d1e4048aa Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 15 Jan 2024 09:01:48 -0500 Subject: [PATCH] refactor(core): Use DI for LDAP code (no-changelog) (#8248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- packages/cli/src/Ldap/LdapManager.ee.ts | 39 -- packages/cli/src/Ldap/LdapService.ee.ts | 105 ---- packages/cli/src/Ldap/LdapSync.ee.ts | 216 --------- packages/cli/src/Ldap/helpers.ts | 192 +------- .../{controllers => Ldap}/ldap.controller.ts | 32 +- packages/cli/src/Ldap/ldap.service.ts | 449 ++++++++++++++++++ packages/cli/src/Server.ts | 8 +- packages/cli/src/auth/methods/ldap.ts | 20 +- .../controllers/passwordReset.controller.ts | 3 +- .../test/integration/ldap/ldap.api.test.ts | 20 +- .../integration/shared/utils/testServer.ts | 6 +- 11 files changed, 492 insertions(+), 598 deletions(-) delete mode 100644 packages/cli/src/Ldap/LdapManager.ee.ts delete mode 100644 packages/cli/src/Ldap/LdapService.ee.ts delete mode 100644 packages/cli/src/Ldap/LdapSync.ee.ts rename packages/cli/src/{controllers => Ldap}/ldap.controller.ts (65%) create mode 100644 packages/cli/src/Ldap/ldap.service.ts diff --git a/packages/cli/src/Ldap/LdapManager.ee.ts b/packages/cli/src/Ldap/LdapManager.ee.ts deleted file mode 100644 index 8dd337e1d2..0000000000 --- a/packages/cli/src/Ldap/LdapManager.ee.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ApplicationError } from 'n8n-workflow'; -import { LdapService } from './LdapService.ee'; -import { LdapSync } from './LdapSync.ee'; -import type { LdapConfig } from './types'; - -export class LdapManager { - private static ldap: { - service: LdapService; - sync: LdapSync; - }; - - private static initialized: boolean; - - static getInstance(): { - service: LdapService; - sync: LdapSync; - } { - if (!this.initialized) { - throw new ApplicationError('LDAP Manager has not been initialized'); - } - return this.ldap; - } - - static init(config: LdapConfig): void { - this.ldap = { - service: new LdapService(), - sync: new LdapSync(), - }; - this.ldap.service.config = config; - this.ldap.sync.config = config; - this.ldap.sync.ldapService = this.ldap.service; - this.initialized = true; - } - - static updateConfig(config: LdapConfig): void { - this.ldap.service.config = config; - this.ldap.sync.config = config; - } -} diff --git a/packages/cli/src/Ldap/LdapService.ee.ts b/packages/cli/src/Ldap/LdapService.ee.ts deleted file mode 100644 index 81ca653a03..0000000000 --- a/packages/cli/src/Ldap/LdapService.ee.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Entry as LdapUser, ClientOptions } from 'ldapts'; -import { Client } from 'ldapts'; -import type { LdapConfig } from './types'; -import { formatUrl, getMappingAttributes } from './helpers'; -import { BINARY_AD_ATTRIBUTES } from './constants'; -import type { ConnectionOptions } from 'tls'; -import { ApplicationError } from 'n8n-workflow'; - -export class LdapService { - private client: Client | undefined; - - private _config: LdapConfig; - - /** - * Set the LDAP configuration and expire the current client - */ - set config(config: LdapConfig) { - this._config = config; - this.client = undefined; - } - - /** - * Get new/existing LDAP client, - * depending on whether the credentials - * were updated or not - */ - private async getClient() { - if (this._config === undefined) { - throw new ApplicationError('Service cannot be used without setting the property config'); - } - if (this.client === undefined) { - const url = formatUrl( - this._config.connectionUrl, - this._config.connectionPort, - this._config.connectionSecurity, - ); - const ldapOptions: ClientOptions = { url }; - const tlsOptions: ConnectionOptions = {}; - - if (this._config.connectionSecurity !== 'none') { - Object.assign(tlsOptions, { - rejectUnauthorized: !this._config.allowUnauthorizedCerts, - }); - if (this._config.connectionSecurity === 'tls') { - ldapOptions.tlsOptions = tlsOptions; - } - } - - this.client = new Client(ldapOptions); - if (this._config.connectionSecurity === 'startTls') { - await this.client.startTLS(tlsOptions); - } - } - } - - /** - * Attempt a binding with the admin credentials - */ - private async bindAdmin(): Promise { - await this.getClient(); - if (this.client) { - await this.client.bind(this._config.bindingAdminDn, this._config.bindingAdminPassword); - } - } - - /** - * Search the LDAP server using the administrator binding - * (if any, else a anonymous binding will be attempted) - */ - async searchWithAdminBinding(filter: string): Promise { - await this.bindAdmin(); - if (this.client) { - const { searchEntries } = await this.client.search(this._config.baseDn, { - attributes: getMappingAttributes(this._config), - explicitBufferAttributes: BINARY_AD_ATTRIBUTES, - filter, - timeLimit: this._config.searchTimeout, - paged: { pageSize: this._config.searchPageSize }, - ...(this._config.searchPageSize === 0 && { paged: true }), - }); - - await this.client.unbind(); - return searchEntries; - } - return []; - } - - /** - * Attempt binding with the user's credentials - */ - async validUser(dn: string, password: string): Promise { - await this.getClient(); - if (this.client) { - await this.client.bind(dn, password); - await this.client.unbind(); - } - } - - /** - * Attempt binding with the administrator credentials, to test the connection - */ - async testConnection(): Promise { - await this.bindAdmin(); - } -} diff --git a/packages/cli/src/Ldap/LdapSync.ee.ts b/packages/cli/src/Ldap/LdapSync.ee.ts deleted file mode 100644 index bdd22559a9..0000000000 --- a/packages/cli/src/Ldap/LdapSync.ee.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { Entry as LdapUser } from 'ldapts'; -import { QueryFailedError } from 'typeorm/error/QueryFailedError'; -import type { LdapService } from './LdapService.ee'; -import type { LdapConfig } from './types'; -import { - getLdapUserRole, - mapLdapUserToDbUser, - processUsers, - saveLdapSynchronization, - createFilter, - resolveBinaryAttributes, - getLdapIds, -} from './helpers'; -import type { User } from '@db/entities/User'; -import type { Role } from '@db/entities/Role'; -import type { RunningMode, SyncStatus } from '@db/entities/AuthProviderSyncHistory'; -import { Container } from 'typedi'; -import { InternalHooks } from '@/InternalHooks'; -import { Logger } from '@/Logger'; -import { ApplicationError } from 'n8n-workflow'; - -export class LdapSync { - private intervalId: NodeJS.Timeout | undefined = undefined; - - private _config: LdapConfig; - - private _ldapService: LdapService; - - private readonly logger: Logger; - - constructor() { - this.logger = Container.get(Logger); - } - - /** - * Updates the LDAP configuration - */ - set config(config: LdapConfig) { - this._config = config; - // If user disabled synchronization in the UI and there a job schedule, - // stop it - if (this.intervalId && !this._config.synchronizationEnabled) { - this.stop(); - // If instance crashed with a job scheduled, once the server starts - // again, reschedule it. - } else if (!this.intervalId && this._config.synchronizationEnabled) { - this.scheduleRun(); - // If job scheduled and the run interval got updated in the UI - // stop the current one and schedule a new one with the new internal - } else if (this.intervalId && this._config.synchronizationEnabled) { - this.stop(); - this.scheduleRun(); - } - } - - /** - * Set the LDAP service instance - */ - set ldapService(service: LdapService) { - this._ldapService = service; - } - - /** - * Schedule a synchronization job based on the interval set in the LDAP config - */ - scheduleRun(): void { - if (!this._config.synchronizationInterval) { - throw new ApplicationError('Interval variable has to be defined'); - } - this.intervalId = setInterval(async () => { - await this.run('live'); - }, this._config.synchronizationInterval * 60000); - } - - /** - * Run the synchronization job. - * If the job runs in "live" mode, changes to LDAP users are persisted in the database, - * else the users are not modified - */ - async run(mode: RunningMode): Promise { - this.logger.debug(`LDAP - Starting a synchronization run in ${mode} mode`); - - let adUsers: LdapUser[] = []; - - try { - adUsers = await this._ldapService.searchWithAdminBinding( - createFilter(`(${this._config.loginIdAttribute}=*)`, this._config.userFilter), - ); - - this.logger.debug('LDAP - Users return by the query', { - users: adUsers, - }); - - resolveBinaryAttributes(adUsers); - } catch (e) { - if (e instanceof Error) { - this.logger.error(`LDAP - ${e.message}`); - throw e; - } - } - - const startedAt = new Date(); - - const localAdUsers = await getLdapIds(); - - const role = await getLdapUserRole(); - - const { usersToCreate, usersToUpdate, usersToDisable } = this.getUsersToProcess( - adUsers, - localAdUsers, - role, - ); - - this.logger.debug('LDAP - Users processed', { - created: usersToCreate.length, - updated: usersToUpdate.length, - disabled: usersToDisable.length, - }); - - const endedAt = new Date(); - let status: SyncStatus = 'success'; - let errorMessage = ''; - - try { - if (mode === 'live') { - await processUsers(usersToCreate, usersToUpdate, usersToDisable); - } - } catch (error) { - if (error instanceof QueryFailedError) { - status = 'error'; - errorMessage = `${error.message}`; - } - } - - await saveLdapSynchronization({ - startedAt, - endedAt, - created: usersToCreate.length, - updated: usersToUpdate.length, - disabled: usersToDisable.length, - scanned: adUsers.length, - runMode: mode, - status, - error: errorMessage, - }); - - void Container.get(InternalHooks).onLdapSyncFinished({ - type: !this.intervalId ? 'scheduled' : `manual_${mode}`, - succeeded: true, - users_synced: usersToCreate.length + usersToUpdate.length + usersToDisable.length, - error: errorMessage, - }); - - this.logger.debug('LDAP - Synchronization finished successfully'); - } - - /** - * Stop the current job scheduled, if any - */ - stop(): void { - clearInterval(this.intervalId); - this.intervalId = undefined; - } - - /** - * Get all the user that will be changed (created, updated, disabled), in the database - */ - private getUsersToProcess( - adUsers: LdapUser[], - localAdUsers: string[], - role: Role, - ): { - usersToCreate: Array<[string, User]>; - usersToUpdate: Array<[string, User]>; - usersToDisable: string[]; - } { - return { - usersToCreate: this.getUsersToCreate(adUsers, localAdUsers, role), - usersToUpdate: this.getUsersToUpdate(adUsers, localAdUsers), - usersToDisable: this.getUsersToDisable(adUsers, localAdUsers), - }; - } - - /** - * Get users in LDAP that are not in the database yet - */ - private getUsersToCreate( - remoteAdUsers: LdapUser[], - localLdapIds: string[], - role: Role, - ): Array<[string, User]> { - return remoteAdUsers - .filter((adUser) => !localLdapIds.includes(adUser[this._config.ldapIdAttribute] as string)) - .map((adUser) => mapLdapUserToDbUser(adUser, this._config, role)); - } - - /** - * Get users in LDAP that are already in the database - */ - private getUsersToUpdate( - remoteAdUsers: LdapUser[], - localLdapIds: string[], - ): Array<[string, User]> { - return remoteAdUsers - .filter((adUser) => localLdapIds.includes(adUser[this._config.ldapIdAttribute] as string)) - .map((adUser) => mapLdapUserToDbUser(adUser, this._config)); - } - - /** - * Get users that are in the database but not in the LDAP server - */ - private getUsersToDisable(remoteAdUsers: LdapUser[], localLdapIds: string[]): string[] { - const remoteAdUserIds = remoteAdUsers.map((adUser) => adUser[this._config.ldapIdAttribute]); - return localLdapIds.filter((user) => !remoteAdUserIds.includes(user)); - } -} diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index f3e5e5a9a3..cd618600ed 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -2,7 +2,6 @@ import type { Entry as LdapUser } from 'ldapts'; import { Filter } from 'ldapts/filters/Filter'; import { Container } from 'typedi'; -import { Cipher } from 'n8n-core'; import { validate } from 'jsonschema'; import * as Db from '@/Db'; import config from '@/config'; @@ -10,33 +9,19 @@ import type { Role } from '@db/entities/Role'; import { User } from '@db/entities/User'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; -import { LdapManager } from './LdapManager.ee'; import { BINARY_AD_ATTRIBUTES, LDAP_CONFIG_SCHEMA, - LDAP_FEATURE_NAME, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL, } from './constants'; import type { ConnectionSecurity, LdapConfig } from './types'; -import { ApplicationError, jsonParse } from 'n8n-workflow'; import { License } from '@/License'; -import { InternalHooks } from '@/InternalHooks'; -import { - getCurrentAuthenticationMethod, - isEmailCurrentAuthenticationMethod, - isLdapCurrentAuthenticationMethod, - setCurrentAuthenticationMethod, -} from '@/sso/ssoHelpers'; import { RoleService } from '@/services/role.service'; -import { Logger } from '@/Logger'; import { UserRepository } from '@db/repositories/user.repository'; -import { SettingsRepository } from '@db/repositories/settings.repository'; import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; /** * Check whether the LDAP feature is disabled in the instance @@ -45,37 +30,6 @@ export const isLdapEnabled = () => { return Container.get(License).isLdapEnabled(); }; -/** - * Check whether the LDAP feature is enabled in the instance - */ -export const isLdapDisabled = (): boolean => !isLdapEnabled(); - -/** - * Set the LDAP login label to the configuration object - */ -export const setLdapLoginLabel = (value: string): void => { - config.set(LDAP_LOGIN_LABEL, value); -}; - -/** - * Set the LDAP login enabled to the configuration object - */ -export async function setLdapLoginEnabled(enabled: boolean): Promise { - if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) { - if (enabled) { - config.set(LDAP_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('ldap'); - } else if (!enabled) { - config.set(LDAP_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); - } - } else { - throw new InternalServerError( - `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, - ); - } -} - /** * Retrieve the LDAP login label from the configuration object */ @@ -115,29 +69,7 @@ export const validateLdapConfigurationSchema = ( return { valid, message }; }; -/** - * Retrieve the LDAP configuration (decrypted) form the database - */ -export const getLdapConfig = async (): Promise => { - const configuration = await Container.get(SettingsRepository).findOneByOrFail({ - key: LDAP_FEATURE_NAME, - }); - const configurationData = jsonParse(configuration.value); - configurationData.bindingAdminPassword = Container.get(Cipher).decrypt( - configurationData.bindingAdminPassword, - ); - return configurationData; -}; - -/** - * Take the LDAP configuration and set login enabled and login label to the config object - */ -export const setGlobalLdapConfigVariables = async (ldapConfig: LdapConfig): Promise => { - await setLdapLoginEnabled(ldapConfig.loginEnabled); - setLdapLoginLabel(ldapConfig.loginLabel); -}; - -const resolveEntryBinaryAttributes = (entry: LdapUser): LdapUser => { +export const resolveEntryBinaryAttributes = (entry: LdapUser): LdapUser => { Object.entries(entry) .filter(([k]) => BINARY_AD_ATTRIBUTES.includes(k)) .forEach(([k]) => { @@ -150,63 +82,6 @@ export const resolveBinaryAttributes = (entries: LdapUser[]): void => { entries.forEach((entry) => resolveEntryBinaryAttributes(entry)); }; -/** - * Update the LDAP configuration in the database - */ -export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise => { - const { valid, message } = validateLdapConfigurationSchema(ldapConfig); - - if (!valid) { - throw new ApplicationError(message); - } - - if (ldapConfig.loginEnabled && getCurrentAuthenticationMethod() === 'saml') { - throw new BadRequestError('LDAP cannot be enabled if SSO in enabled'); - } - - LdapManager.updateConfig({ ...ldapConfig }); - - ldapConfig.bindingAdminPassword = Container.get(Cipher).encrypt(ldapConfig.bindingAdminPassword); - - if (!ldapConfig.loginEnabled) { - ldapConfig.synchronizationEnabled = false; - const ldapUsers = await getLdapUsers(); - if (ldapUsers.length) { - await deleteAllLdapIdentities(); - } - } - - await Container.get(SettingsRepository).update( - { key: LDAP_FEATURE_NAME }, - { value: JSON.stringify(ldapConfig), loadOnStartup: true }, - ); - await setGlobalLdapConfigVariables(ldapConfig); -}; - -/** - * Handle the LDAP initialization. - * If it's the first run of this feature, all the default data is created in the database - */ -export const handleLdapInit = async (): Promise => { - if (!isLdapEnabled()) return; - - const ldapConfig = await getLdapConfig(); - - try { - await setGlobalLdapConfigVariables(ldapConfig); - } catch (error) { - Container.get(Logger).warn( - `Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - error, - ); - } - - // init LDAP manager with the current - // configuration - LdapManager.init(ldapConfig); -}; - export const createFilter = (filter: string, userFilter: string) => { let _filter = `(&(|(objectClass=person)(objectClass=user))${filter})`; if (userFilter) { @@ -220,69 +95,6 @@ export const escapeFilter = (filter: string): string => { return new Filter().escape(filter); /* eslint-disable-line */ }; -/** - * Find and authenticate user in the LDAP server. - */ -export const findAndAuthenticateLdapUser = async ( - loginId: string, - password: string, - loginIdAttribute: string, - userFilter: string, -): Promise => { - const ldapService = LdapManager.getInstance().service; - - // Search for the user with the administrator binding using the - // the Login ID attribute and whatever was inputted in the UI's - // email input. - let searchResult: LdapUser[] = []; - - try { - searchResult = await ldapService.searchWithAdminBinding( - createFilter(`(${loginIdAttribute}=${escapeFilter(loginId)})`, userFilter), - ); - } catch (e) { - if (e instanceof Error) { - void Container.get(InternalHooks).onLdapLoginSyncFailed({ - error: e.message, - }); - Container.get(Logger).error('LDAP - Error during search', { message: e.message }); - } - return undefined; - } - - if (!searchResult.length) { - return undefined; - } - - // In the unlikely scenario that more than one user is found ( - // can happen depending on how the LDAP database is structured - // and the LDAP configuration), return the last one found as it - // should be the less important in the hierarchy. - let user = searchResult.pop(); - - if (user === undefined) { - user = { dn: '' }; - } - - try { - // Now with the user distinguished name (unique identifier - // for the user) and the password, attempt to validate the - // user by binding - await ldapService.validUser(user.dn, password); - } catch (e) { - if (e instanceof Error) { - Container.get(Logger).error('LDAP - Error validating user against LDAP server', { - message: e.message, - }); - } - return undefined; - } - - resolveEntryBinaryAttributes(user); - - return user; -}; - /** * Retrieve auth identity by LDAP ID from database */ @@ -475,6 +287,6 @@ export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Part } }; -const deleteAllLdapIdentities = async () => { +export const deleteAllLdapIdentities = async () => { return Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' }); }; diff --git a/packages/cli/src/controllers/ldap.controller.ts b/packages/cli/src/Ldap/ldap.controller.ts similarity index 65% rename from packages/cli/src/controllers/ldap.controller.ts rename to packages/cli/src/Ldap/ldap.controller.ts index c0cb9eae1c..441c5c993b 100644 --- a/packages/cli/src/controllers/ldap.controller.ts +++ b/packages/cli/src/Ldap/ldap.controller.ts @@ -1,31 +1,25 @@ import pick from 'lodash/pick'; import { Authorized, Get, Post, Put, RestController, RequireGlobalScope } from '@/decorators'; -import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers'; -import { LdapManager } from '@/Ldap/LdapManager.ee'; -import type { LdapService } from '@/Ldap/LdapService.ee'; -import type { LdapSync } from '@/Ldap/LdapSync.ee'; -import { LdapConfiguration } from '@/Ldap/types'; -import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '@/Ldap/constants'; import { InternalHooks } from '@/InternalHooks'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from './constants'; +import { getLdapSynchronizations } from './helpers'; +import { LdapConfiguration } from './types'; +import { LdapService } from './ldap.service'; + @Authorized() @RestController('/ldap') export class LdapController { - private ldapService: LdapService; - - private ldapSync: LdapSync; - - constructor(private readonly internalHooks: InternalHooks) { - const { service, sync } = LdapManager.getInstance(); - this.ldapService = service; - this.ldapSync = sync; - } + constructor( + private readonly internalHooks: InternalHooks, + private readonly ldapService: LdapService, + ) {} @Get('/config') @RequireGlobalScope('ldap:manage') async getConfig() { - return getLdapConfig(); + return this.ldapService.loadConfig(); } @Post('/test-connection') @@ -42,12 +36,12 @@ export class LdapController { @RequireGlobalScope('ldap:manage') async updateConfig(req: LdapConfiguration.Update) { try { - await updateLdapConfig(req.body); + await this.ldapService.updateConfig(req.body); } catch (error) { throw new BadRequestError((error as { message: string }).message); } - const data = await getLdapConfig(); + const data = await this.ldapService.loadConfig(); void this.internalHooks.onUserUpdatedLdapSettings({ user_id: req.user.id, @@ -68,7 +62,7 @@ export class LdapController { @RequireGlobalScope('ldap:sync') async syncLdap(req: LdapConfiguration.Sync) { try { - await this.ldapSync.run(req.body.type); + await this.ldapService.runSync(req.body.type); } catch (error) { throw new BadRequestError((error as { message: string }).message); } diff --git a/packages/cli/src/Ldap/ldap.service.ts b/packages/cli/src/Ldap/ldap.service.ts new file mode 100644 index 0000000000..5d3af3fb86 --- /dev/null +++ b/packages/cli/src/Ldap/ldap.service.ts @@ -0,0 +1,449 @@ +import { Service } from 'typedi'; +import { QueryFailedError } from 'typeorm'; +import type { Entry as LdapUser, ClientOptions } from 'ldapts'; +import { Client } from 'ldapts'; +import type { ConnectionOptions } from 'tls'; +import { ApplicationError, jsonParse } from 'n8n-workflow'; +import { Cipher } from 'n8n-core'; + +import config from '@/config'; +import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; +import type { RunningMode, SyncStatus } from '@db/entities/AuthProviderSyncHistory'; +import { SettingsRepository } from '@db/repositories/settings.repository'; +import { InternalHooks } from '@/InternalHooks'; +import { Logger } from '@/Logger'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { + getCurrentAuthenticationMethod, + isEmailCurrentAuthenticationMethod, + isLdapCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '@/sso/ssoHelpers'; + +import type { LdapConfig } from './types'; +import { + createFilter, + deleteAllLdapIdentities, + escapeFilter, + formatUrl, + getLdapIds, + getLdapUserRole, + getLdapUsers, + getMappingAttributes, + mapLdapUserToDbUser, + processUsers, + resolveBinaryAttributes, + resolveEntryBinaryAttributes, + saveLdapSynchronization, + validateLdapConfigurationSchema, +} from './helpers'; +import { + BINARY_AD_ATTRIBUTES, + LDAP_FEATURE_NAME, + LDAP_LOGIN_ENABLED, + LDAP_LOGIN_LABEL, +} from './constants'; + +@Service() +export class LdapService { + private client: Client | undefined; + + private syncTimer: NodeJS.Timeout | undefined = undefined; + + config: LdapConfig; + + constructor( + private readonly logger: Logger, + private readonly internalHooks: InternalHooks, + private readonly settingsRepository: SettingsRepository, + private readonly cipher: Cipher, + ) {} + + async init() { + const ldapConfig = await this.loadConfig(); + + try { + await this.setGlobalLdapConfigVariables(ldapConfig); + } catch (error) { + this.logger.warn( + `Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + error, + ); + } + + this.setConfig(ldapConfig); + } + + /** Retrieve the LDAP configuration (decrypted) from the database */ + async loadConfig() { + const { value } = await this.settingsRepository.findOneByOrFail({ + key: LDAP_FEATURE_NAME, + }); + const ldapConfig = jsonParse(value); + ldapConfig.bindingAdminPassword = this.cipher.decrypt(ldapConfig.bindingAdminPassword); + return ldapConfig; + } + + async updateConfig(ldapConfig: LdapConfig): Promise { + const { valid, message } = validateLdapConfigurationSchema(ldapConfig); + + if (!valid) { + throw new ApplicationError(message); + } + + if (ldapConfig.loginEnabled && getCurrentAuthenticationMethod() === 'saml') { + throw new BadRequestError('LDAP cannot be enabled if SSO in enabled'); + } + + this.setConfig({ ...ldapConfig }); + + ldapConfig.bindingAdminPassword = this.cipher.encrypt(ldapConfig.bindingAdminPassword); + + if (!ldapConfig.loginEnabled) { + ldapConfig.synchronizationEnabled = false; + const ldapUsers = await getLdapUsers(); + if (ldapUsers.length) { + await deleteAllLdapIdentities(); + } + } + + await this.settingsRepository.update( + { key: LDAP_FEATURE_NAME }, + { value: JSON.stringify(ldapConfig), loadOnStartup: true }, + ); + await this.setGlobalLdapConfigVariables(ldapConfig); + } + + /** Set the LDAP configuration and expire the current client */ + setConfig(ldapConfig: LdapConfig) { + this.config = ldapConfig; + this.client = undefined; + // If user disabled synchronization in the UI and there a job schedule, + // stop it + if (this.syncTimer && !this.config.synchronizationEnabled) { + this.stopSync(); + // If instance crashed with a job scheduled, once the server starts + // again, reschedule it. + } else if (!this.syncTimer && this.config.synchronizationEnabled) { + this.scheduleSync(); + // If job scheduled and the run interval got updated in the UI + // stop the current one and schedule a new one with the new internal + } else if (this.syncTimer && this.config.synchronizationEnabled) { + this.stopSync(); + this.scheduleSync(); + } + } + + /** Take the LDAP configuration and set login enabled and login label to the config object */ + private async setGlobalLdapConfigVariables(ldapConfig: LdapConfig): Promise { + await this.setLdapLoginEnabled(ldapConfig.loginEnabled); + config.set(LDAP_LOGIN_LABEL, ldapConfig.loginLabel); + } + + /** Set the LDAP login enabled to the configuration object */ + private async setLdapLoginEnabled(enabled: boolean): Promise { + if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) { + if (enabled) { + config.set(LDAP_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('ldap'); + } else if (!enabled) { + config.set(LDAP_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } + } else { + throw new InternalServerError( + `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, + ); + } + } + + /** + * Get new/existing LDAP client, + * depending on whether the credentials + * were updated or not + */ + private async getClient() { + if (this.config === undefined) { + throw new ApplicationError('Service cannot be used without setting the property config'); + } + if (this.client === undefined) { + const url = formatUrl( + this.config.connectionUrl, + this.config.connectionPort, + this.config.connectionSecurity, + ); + const ldapOptions: ClientOptions = { url }; + const tlsOptions: ConnectionOptions = {}; + + if (this.config.connectionSecurity !== 'none') { + Object.assign(tlsOptions, { + rejectUnauthorized: !this.config.allowUnauthorizedCerts, + }); + if (this.config.connectionSecurity === 'tls') { + ldapOptions.tlsOptions = tlsOptions; + } + } + + this.client = new Client(ldapOptions); + if (this.config.connectionSecurity === 'startTls') { + await this.client.startTLS(tlsOptions); + } + } + } + + /** + * Attempt a binding with the admin credentials + */ + private async bindAdmin(): Promise { + await this.getClient(); + if (this.client) { + await this.client.bind(this.config.bindingAdminDn, this.config.bindingAdminPassword); + } + } + + /** + * Search the LDAP server using the administrator binding + * (if any, else a anonymous binding will be attempted) + */ + async searchWithAdminBinding(filter: string): Promise { + await this.bindAdmin(); + if (this.client) { + const { searchEntries } = await this.client.search(this.config.baseDn, { + attributes: getMappingAttributes(this.config), + explicitBufferAttributes: BINARY_AD_ATTRIBUTES, + filter, + timeLimit: this.config.searchTimeout, + paged: { pageSize: this.config.searchPageSize }, + ...(this.config.searchPageSize === 0 && { paged: true }), + }); + + await this.client.unbind(); + return searchEntries; + } + return []; + } + + /** + * Attempt binding with the user's credentials + */ + async validUser(dn: string, password: string): Promise { + await this.getClient(); + if (this.client) { + await this.client.bind(dn, password); + await this.client.unbind(); + } + } + + /** + * Find and authenticate user in the LDAP server. + */ + async findAndAuthenticateLdapUser( + loginId: string, + password: string, + loginIdAttribute: string, + userFilter: string, + ): Promise { + // Search for the user with the administrator binding using the + // the Login ID attribute and whatever was inputted in the UI's + // email input. + let searchResult: LdapUser[] = []; + + try { + searchResult = await this.searchWithAdminBinding( + createFilter(`(${loginIdAttribute}=${escapeFilter(loginId)})`, userFilter), + ); + } catch (e) { + if (e instanceof Error) { + void this.internalHooks.onLdapLoginSyncFailed({ + error: e.message, + }); + this.logger.error('LDAP - Error during search', { message: e.message }); + } + return undefined; + } + + if (!searchResult.length) { + return undefined; + } + + // In the unlikely scenario that more than one user is found ( + // can happen depending on how the LDAP database is structured + // and the LDAP configuration), return the last one found as it + // should be the less important in the hierarchy. + let user = searchResult.pop(); + + if (user === undefined) { + user = { dn: '' }; + } + + try { + // Now with the user distinguished name (unique identifier + // for the user) and the password, attempt to validate the + // user by binding + await this.validUser(user.dn, password); + } catch (e) { + if (e instanceof Error) { + this.logger.error('LDAP - Error validating user against LDAP server', { + message: e.message, + }); + } + return undefined; + } + + resolveEntryBinaryAttributes(user); + + return user; + } + + /** + * Attempt binding with the administrator credentials, to test the connection + */ + async testConnection(): Promise { + await this.bindAdmin(); + } + + /** Schedule a synchronization job based on the interval set in the LDAP config */ + private scheduleSync(): void { + if (!this.config.synchronizationInterval) { + throw new ApplicationError('Interval variable has to be defined'); + } + this.syncTimer = setInterval(async () => { + await this.runSync('live'); + }, this.config.synchronizationInterval * 60000); + } + + /** + * Run the synchronization job. + * If the job runs in "live" mode, changes to LDAP users are persisted in the database, else the users are not modified + */ + async runSync(mode: RunningMode): Promise { + this.logger.debug(`LDAP - Starting a synchronization run in ${mode} mode`); + + let adUsers: LdapUser[] = []; + + try { + adUsers = await this.searchWithAdminBinding( + createFilter(`(${this.config.loginIdAttribute}=*)`, this.config.userFilter), + ); + + this.logger.debug('LDAP - Users return by the query', { + users: adUsers, + }); + + resolveBinaryAttributes(adUsers); + } catch (e) { + if (e instanceof Error) { + this.logger.error(`LDAP - ${e.message}`); + throw e; + } + } + + const startedAt = new Date(); + + const localAdUsers = await getLdapIds(); + + const role = await getLdapUserRole(); + + const { usersToCreate, usersToUpdate, usersToDisable } = this.getUsersToProcess( + adUsers, + localAdUsers, + role, + ); + + this.logger.debug('LDAP - Users processed', { + created: usersToCreate.length, + updated: usersToUpdate.length, + disabled: usersToDisable.length, + }); + + const endedAt = new Date(); + let status: SyncStatus = 'success'; + let errorMessage = ''; + + try { + if (mode === 'live') { + await processUsers(usersToCreate, usersToUpdate, usersToDisable); + } + } catch (error) { + if (error instanceof QueryFailedError) { + status = 'error'; + errorMessage = `${error.message}`; + } + } + + await saveLdapSynchronization({ + startedAt, + endedAt, + created: usersToCreate.length, + updated: usersToUpdate.length, + disabled: usersToDisable.length, + scanned: adUsers.length, + runMode: mode, + status, + error: errorMessage, + }); + + void this.internalHooks.onLdapSyncFinished({ + type: !this.syncTimer ? 'scheduled' : `manual_${mode}`, + succeeded: true, + users_synced: usersToCreate.length + usersToUpdate.length + usersToDisable.length, + error: errorMessage, + }); + + this.logger.debug('LDAP - Synchronization finished successfully'); + } + + /** Stop the current job scheduled, if any */ + stopSync(): void { + clearInterval(this.syncTimer); + this.syncTimer = undefined; + } + + /** Get all the user that will be changed (created, updated, disabled), in the database */ + private getUsersToProcess( + adUsers: LdapUser[], + localAdUsers: string[], + role: Role, + ): { + usersToCreate: Array<[string, User]>; + usersToUpdate: Array<[string, User]>; + usersToDisable: string[]; + } { + return { + usersToCreate: this.getUsersToCreate(adUsers, localAdUsers, role), + usersToUpdate: this.getUsersToUpdate(adUsers, localAdUsers), + usersToDisable: this.getUsersToDisable(adUsers, localAdUsers), + }; + } + + /** Get users in LDAP that are not in the database yet */ + private getUsersToCreate( + remoteAdUsers: LdapUser[], + localLdapIds: string[], + role: Role, + ): Array<[string, User]> { + return remoteAdUsers + .filter((adUser) => !localLdapIds.includes(adUser[this.config.ldapIdAttribute] as string)) + .map((adUser) => mapLdapUserToDbUser(adUser, this.config, role)); + } + + /** Get users in LDAP that are already in the database */ + private getUsersToUpdate( + remoteAdUsers: LdapUser[], + localLdapIds: string[], + ): Array<[string, User]> { + return remoteAdUsers + .filter((adUser) => localLdapIds.includes(adUser[this.config.ldapIdAttribute] as string)) + .map((adUser) => mapLdapUserToDbUser(adUser, this.config)); + } + + /** Get users that are in the database but not in the LDAP server */ + private getUsersToDisable(remoteAdUsers: LdapUser[], localLdapIds: string[]): string[] { + const remoteAdUserIds = remoteAdUsers.map((adUser) => adUser[this.config.ldapIdAttribute]); + return localLdapIds.filter((user) => !remoteAdUserIds.includes(user)); + } +} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 597bbbc17c..1de23822f3 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -70,7 +70,7 @@ import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; import { LicenseController } from '@/license/license.controller'; import { setupPushServer, setupPushHandler } from '@/push'; import { setupAuthMiddlewares } from './middlewares'; -import { handleLdapInit, isLdapEnabled } from './Ldap/helpers'; +import { isLdapEnabled } from './Ldap/helpers'; import { AbstractServer } from './AbstractServer'; import { PostHogClient } from './posthog'; import { eventBus } from './eventbus'; @@ -255,7 +255,9 @@ export class Server extends AbstractServer { } if (isLdapEnabled()) { - const { LdapController } = await require('@/controllers/ldap.controller'); + const { LdapService } = await import('@/Ldap/ldap.service'); + const { LdapController } = await require('@/Ldap/ldap.controller'); + await Container.get(LdapService).init(); controllers.push(LdapController); } @@ -351,8 +353,6 @@ export class Server extends AbstractServer { await Container.get(Queue).init(); } - await handleLdapInit(); - await handleMfaDisable(); await this.registerControllers(ignoredEndpoints); diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index 826c33bbb4..c2e38cff02 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -1,32 +1,32 @@ +import { Container } from 'typedi'; + import { InternalHooks } from '@/InternalHooks'; +import { LdapService } from '@/Ldap/ldap.service'; import { createLdapUserOnLocalDb, - findAndAuthenticateLdapUser, - getLdapConfig, getLdapUserRole, getUserByEmail, getAuthIdentityByLdapId, - isLdapDisabled, + isLdapEnabled, mapLdapAttributesToUser, createLdapAuthIdentity, updateLdapUserOnLocalDb, } from '@/Ldap/helpers'; import type { User } from '@db/entities/User'; -import { Container } from 'typedi'; export const handleLdapLogin = async ( loginId: string, password: string, ): Promise => { - if (isLdapDisabled()) return undefined; + if (!isLdapEnabled()) return undefined; - const ldapConfig = await getLdapConfig(); + const ldapService = Container.get(LdapService); - if (!ldapConfig.loginEnabled) return undefined; + if (!ldapService.config.loginEnabled) return undefined; - const { loginIdAttribute, userFilter } = ldapConfig; + const { loginIdAttribute, userFilter } = ldapService.config; - const ldapUser = await findAndAuthenticateLdapUser( + const ldapUser = await ldapService.findAndAuthenticateLdapUser( loginId, password, loginIdAttribute, @@ -35,7 +35,7 @@ export const handleLdapLogin = async ( if (!ldapUser) return undefined; - const [ldapId, ldapAttributesValues] = mapLdapAttributesToUser(ldapUser, ldapConfig); + const [ldapId, ldapAttributesValues] = mapLdapAttributesToUser(ldapUser, ldapService.config); const { email: emailAttributeValue } = ldapAttributesValues; diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 45d23f3c25..806d892d26 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -7,7 +7,6 @@ import { PasswordUtility } from '@/services/password.utility'; import { UserManagementMailer } from '@/UserManagement/email'; import { PasswordResetRequest } from '@/requests'; import { issueCookie } from '@/auth/jwt'; -import { isLdapEnabled } from '@/Ldap/helpers'; import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { UserService } from '@/services/user.service'; import { License } from '@/License'; @@ -111,7 +110,7 @@ export class PasswordResetController { return; } - if (isLdapEnabled() && ldapIdentity) { + if (this.license.isLdapEnabled() && ldapIdentity) { throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable'); } diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index d4b32f0080..0a1ce8f437 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -1,14 +1,15 @@ +import Container from 'typedi'; import type { SuperAgentTest } from 'supertest'; import type { Entry as LdapUser } from 'ldapts'; import { Not } from 'typeorm'; import { jsonParse } from 'n8n-workflow'; +import { Cipher } from 'n8n-core'; import config from '@/config'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; -import { LdapManager } from '@/Ldap/LdapManager.ee'; -import { LdapService } from '@/Ldap/LdapService.ee'; +import { LdapService } from '@/Ldap/ldap.service'; import { saveLdapSynchronization } from '@/Ldap/helpers'; import type { LdapConfig } from '@/Ldap/types'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; @@ -16,8 +17,7 @@ import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from ' import { randomEmail, randomName, uniqueId } from './../shared/random'; import * as testDb from './../shared/testDb'; import * as utils from '../shared/utils/'; -import Container from 'typedi'; -import { Cipher } from 'n8n-core'; + import { getGlobalMemberRole, getGlobalOwnerRole } from '../shared/db/roles'; import { createLdapUser, createUser, getAllUsers, getLdapIdentities } from '../shared/db/users'; import { UserRepository } from '@db/repositories/user.repository'; @@ -167,7 +167,7 @@ describe('PUT /ldap/config', () => { test('should apply "Convert all LDAP users to email users" strategy when LDAP login disabled', async () => { const ldapConfig = await createLdapConfig(); - LdapManager.updateConfig(ldapConfig); + Container.get(LdapService).setConfig(ldapConfig); const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); @@ -230,7 +230,7 @@ describe('POST /ldap/sync', () => { lastNameAttribute: 'sn', emailAttribute: 'mail', }); - LdapManager.updateConfig(ldapConfig); + Container.get(LdapService).setConfig(ldapConfig); }); describe('dry mode', () => { @@ -493,7 +493,7 @@ test('GET /ldap/sync should return paginated synchronizations', async () => { describe('POST /login', () => { const runTest = async (ldapUser: LdapUser) => { const ldapConfig = await createLdapConfig(); - LdapManager.updateConfig(ldapConfig); + Container.get(LdapService).setConfig(ldapConfig); await setCurrentAuthenticationMethod('ldap'); @@ -556,7 +556,7 @@ describe('POST /login', () => { test('should allow instance owner to sign in with email/password when LDAP is enabled', async () => { const ldapConfig = await createLdapConfig(); - LdapManager.updateConfig(ldapConfig); + Container.get(LdapService).setConfig(ldapConfig); const response = await testServer.authlessAgent .post('/login') @@ -590,7 +590,7 @@ describe('POST /login', () => { describe('Instance owner should able to delete LDAP users', () => { test("don't transfer workflows", async () => { const ldapConfig = await createLdapConfig(); - LdapManager.updateConfig(ldapConfig); + Container.get(LdapService).setConfig(ldapConfig); const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); @@ -599,7 +599,7 @@ describe('Instance owner should able to delete LDAP users', () => { test('transfer workflows and credentials', async () => { const ldapConfig = await createLdapConfig(); - LdapManager.updateConfig(ldapConfig); + Container.get(LdapService).setConfig(ldapConfig); const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId()); diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index d77eca67ce..cb6dc1355f 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -170,10 +170,10 @@ export const setupTestServer = ({ break; case 'ldap': - const { handleLdapInit } = await import('@/Ldap/helpers'); - const { LdapController } = await import('@/controllers/ldap.controller'); + const { LdapService } = await import('@/Ldap/ldap.service'); + const { LdapController } = await import('@/Ldap/ldap.controller'); testServer.license.enable('feat:ldap'); - await handleLdapInit(); + await Container.get(LdapService).init(); registerController(app, LdapController); break;