n8n/packages/cli/src/ldap/ldap.service.ee.ts
Iván Ovejero 3a9c65e1cb
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
refactor(core): Modernize logger service (#11031)
2024-10-01 12:16:09 +02:00

441 lines
13 KiB
TypeScript

// 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<LdapConfig>(value);
ldapConfig.bindingAdminPassword = this.cipher.decrypt(ldapConfig.bindingAdminPassword);
return ldapConfig;
}
async updateConfig(ldapConfig: LdapConfig): Promise<void> {
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<void> {
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<void> {
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<void> {
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<LdapUser[]> {
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<void> {
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<LdapUser | undefined> {
// 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<void> {
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<void> {
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));
}
}