// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { QueryFailedError } from '@n8n/typeorm'; import type { Entry as LdapUser, ClientOptions } from 'ldapts'; import { Client } from 'ldapts'; import { Cipher } from 'n8n-core'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import type { ConnectionOptions } from 'tls'; import { Service } from 'typedi'; import config from '@/config'; import type { RunningMode, SyncStatus } from '@/databases/entities/auth-provider-sync-history'; import type { User } from '@/databases/entities/user'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; import { Logger } from '@/logging/logger.service'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '@/sso/sso-helpers'; import { BINARY_AD_ATTRIBUTES, LDAP_FEATURE_NAME, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL, } from './constants'; import { createFilter, deleteAllLdapIdentities, escapeFilter, formatUrl, getLdapIds, getLdapUsers, getMappingAttributes, mapLdapUserToDbUser, processUsers, resolveBinaryAttributes, resolveEntryBinaryAttributes, saveLdapSynchronization, validateLdapConfigurationSchema, } from './helpers.ee'; import type { LdapConfig } from './types'; @Service() export class LdapService { private client: Client | undefined; private syncTimer: NodeJS.Timeout | undefined = undefined; config: LdapConfig; constructor( private readonly logger: Logger, private readonly settingsRepository: SettingsRepository, private readonly cipher: Cipher, private readonly eventService: EventService, ) {} 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) { this.eventService.emit('ldap-login-sync-failed', { 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 { usersToCreate, usersToUpdate, usersToDisable } = this.getUsersToProcess( adUsers, localAdUsers, ); this.logger.debug('LDAP - Users to process', { 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, }); this.eventService.emit('ldap-general-sync-finished', { type: !this.syncTimer ? 'scheduled' : `manual_${mode}`, succeeded: true, usersSynced: 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[], ): { usersToCreate: Array<[string, User]>; usersToUpdate: Array<[string, User]>; usersToDisable: string[]; } { return { usersToCreate: this.getUsersToCreate(adUsers, localAdUsers), 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[], ): Array<[string, User]> { return remoteAdUsers .filter((adUser) => !localLdapIds.includes(adUser[this.config.ldapIdAttribute] as string)) .map((adUser) => mapLdapUserToDbUser(adUser, this.config, true)); } /** 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)); } }