mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
refactor(core): Use DI for LDAP code (no-changelog) (#8248)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
449
packages/cli/src/Ldap/ldap.service.ts
Normal file
449
packages/cli/src/Ldap/ldap.service.ts
Normal file
@@ -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<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) {
|
||||
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<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 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user