refactor(core): Use DI for LDAP code (no-changelog) (#8248)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Ricardo Espinoza 2024-01-15 09:01:48 -05:00 committed by GitHub
parent a6a5372b5f
commit 3c2a4000ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 492 additions and 598 deletions

View file

@ -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;
}
}

View file

@ -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<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();
}
}
/**
* Attempt binding with the administrator credentials, to test the connection
*/
async testConnection(): Promise<void> {
await this.bindAdmin();
}
}

View file

@ -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<void> {
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));
}
}

View file

@ -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<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()})`,
);
}
}
/**
* 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<LdapConfig> => {
const configuration = await Container.get(SettingsRepository).findOneByOrFail({
key: LDAP_FEATURE_NAME,
});
const configurationData = jsonParse<LdapConfig>(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<void> => {
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<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');
}
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<void> => {
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<LdapUser | undefined> => {
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' });
};

View file

@ -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);
}

View 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));
}
}

View file

@ -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);

View file

@ -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<User | undefined> => {
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;

View file

@ -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');
}

View file

@ -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());

View file

@ -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;