mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(core): Use DI for LDAP code (no-changelog) (#8248)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
parent
a6a5372b5f
commit
3c2a4000ae
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,7 +2,6 @@
|
||||||
import type { Entry as LdapUser } from 'ldapts';
|
import type { Entry as LdapUser } from 'ldapts';
|
||||||
import { Filter } from 'ldapts/filters/Filter';
|
import { Filter } from 'ldapts/filters/Filter';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Cipher } from 'n8n-core';
|
|
||||||
import { validate } from 'jsonschema';
|
import { validate } from 'jsonschema';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
@ -10,33 +9,19 @@ import type { Role } from '@db/entities/Role';
|
||||||
import { User } from '@db/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
||||||
import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory';
|
import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory';
|
||||||
import { LdapManager } from './LdapManager.ee';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BINARY_AD_ATTRIBUTES,
|
BINARY_AD_ATTRIBUTES,
|
||||||
LDAP_CONFIG_SCHEMA,
|
LDAP_CONFIG_SCHEMA,
|
||||||
LDAP_FEATURE_NAME,
|
|
||||||
LDAP_LOGIN_ENABLED,
|
LDAP_LOGIN_ENABLED,
|
||||||
LDAP_LOGIN_LABEL,
|
LDAP_LOGIN_LABEL,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import type { ConnectionSecurity, LdapConfig } from './types';
|
import type { ConnectionSecurity, LdapConfig } from './types';
|
||||||
import { ApplicationError, jsonParse } from 'n8n-workflow';
|
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
|
||||||
import {
|
|
||||||
getCurrentAuthenticationMethod,
|
|
||||||
isEmailCurrentAuthenticationMethod,
|
|
||||||
isLdapCurrentAuthenticationMethod,
|
|
||||||
setCurrentAuthenticationMethod,
|
|
||||||
} from '@/sso/ssoHelpers';
|
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
import { Logger } from '@/Logger';
|
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
import { UserRepository } from '@db/repositories/user.repository';
|
||||||
import { SettingsRepository } from '@db/repositories/settings.repository';
|
|
||||||
import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository';
|
import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository';
|
||||||
import { AuthIdentityRepository } from '@db/repositories/authIdentity.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
|
* Check whether the LDAP feature is disabled in the instance
|
||||||
|
@ -45,37 +30,6 @@ export const isLdapEnabled = () => {
|
||||||
return Container.get(License).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
|
* Retrieve the LDAP login label from the configuration object
|
||||||
*/
|
*/
|
||||||
|
@ -115,29 +69,7 @@ export const validateLdapConfigurationSchema = (
|
||||||
return { valid, message };
|
return { valid, message };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const resolveEntryBinaryAttributes = (entry: LdapUser): LdapUser => {
|
||||||
* 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 => {
|
|
||||||
Object.entries(entry)
|
Object.entries(entry)
|
||||||
.filter(([k]) => BINARY_AD_ATTRIBUTES.includes(k))
|
.filter(([k]) => BINARY_AD_ATTRIBUTES.includes(k))
|
||||||
.forEach(([k]) => {
|
.forEach(([k]) => {
|
||||||
|
@ -150,63 +82,6 @@ export const resolveBinaryAttributes = (entries: LdapUser[]): void => {
|
||||||
entries.forEach((entry) => resolveEntryBinaryAttributes(entry));
|
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) => {
|
export const createFilter = (filter: string, userFilter: string) => {
|
||||||
let _filter = `(&(|(objectClass=person)(objectClass=user))${filter})`;
|
let _filter = `(&(|(objectClass=person)(objectClass=user))${filter})`;
|
||||||
if (userFilter) {
|
if (userFilter) {
|
||||||
|
@ -220,69 +95,6 @@ export const escapeFilter = (filter: string): string => {
|
||||||
return new Filter().escape(filter); /* eslint-disable-line */
|
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
|
* 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' });
|
return Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' });
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,31 +1,25 @@
|
||||||
import pick from 'lodash/pick';
|
import pick from 'lodash/pick';
|
||||||
import { Authorized, Get, Post, Put, RestController, RequireGlobalScope } from '@/decorators';
|
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 { InternalHooks } from '@/InternalHooks';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
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()
|
@Authorized()
|
||||||
@RestController('/ldap')
|
@RestController('/ldap')
|
||||||
export class LdapController {
|
export class LdapController {
|
||||||
private ldapService: LdapService;
|
constructor(
|
||||||
|
private readonly internalHooks: InternalHooks,
|
||||||
private ldapSync: LdapSync;
|
private readonly ldapService: LdapService,
|
||||||
|
) {}
|
||||||
constructor(private readonly internalHooks: InternalHooks) {
|
|
||||||
const { service, sync } = LdapManager.getInstance();
|
|
||||||
this.ldapService = service;
|
|
||||||
this.ldapSync = sync;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/config')
|
@Get('/config')
|
||||||
@RequireGlobalScope('ldap:manage')
|
@RequireGlobalScope('ldap:manage')
|
||||||
async getConfig() {
|
async getConfig() {
|
||||||
return getLdapConfig();
|
return this.ldapService.loadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/test-connection')
|
@Post('/test-connection')
|
||||||
|
@ -42,12 +36,12 @@ export class LdapController {
|
||||||
@RequireGlobalScope('ldap:manage')
|
@RequireGlobalScope('ldap:manage')
|
||||||
async updateConfig(req: LdapConfiguration.Update) {
|
async updateConfig(req: LdapConfiguration.Update) {
|
||||||
try {
|
try {
|
||||||
await updateLdapConfig(req.body);
|
await this.ldapService.updateConfig(req.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadRequestError((error as { message: string }).message);
|
throw new BadRequestError((error as { message: string }).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getLdapConfig();
|
const data = await this.ldapService.loadConfig();
|
||||||
|
|
||||||
void this.internalHooks.onUserUpdatedLdapSettings({
|
void this.internalHooks.onUserUpdatedLdapSettings({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
|
@ -68,7 +62,7 @@ export class LdapController {
|
||||||
@RequireGlobalScope('ldap:sync')
|
@RequireGlobalScope('ldap:sync')
|
||||||
async syncLdap(req: LdapConfiguration.Sync) {
|
async syncLdap(req: LdapConfiguration.Sync) {
|
||||||
try {
|
try {
|
||||||
await this.ldapSync.run(req.body.type);
|
await this.ldapService.runSync(req.body.type);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadRequestError((error as { message: string }).message);
|
throw new BadRequestError((error as { message: string }).message);
|
||||||
}
|
}
|
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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -70,7 +70,7 @@ import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee';
|
||||||
import { LicenseController } from '@/license/license.controller';
|
import { LicenseController } from '@/license/license.controller';
|
||||||
import { setupPushServer, setupPushHandler } from '@/push';
|
import { setupPushServer, setupPushHandler } from '@/push';
|
||||||
import { setupAuthMiddlewares } from './middlewares';
|
import { setupAuthMiddlewares } from './middlewares';
|
||||||
import { handleLdapInit, isLdapEnabled } from './Ldap/helpers';
|
import { isLdapEnabled } from './Ldap/helpers';
|
||||||
import { AbstractServer } from './AbstractServer';
|
import { AbstractServer } from './AbstractServer';
|
||||||
import { PostHogClient } from './posthog';
|
import { PostHogClient } from './posthog';
|
||||||
import { eventBus } from './eventbus';
|
import { eventBus } from './eventbus';
|
||||||
|
@ -255,7 +255,9 @@ export class Server extends AbstractServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLdapEnabled()) {
|
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);
|
controllers.push(LdapController);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -351,8 +353,6 @@ export class Server extends AbstractServer {
|
||||||
await Container.get(Queue).init();
|
await Container.get(Queue).init();
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleLdapInit();
|
|
||||||
|
|
||||||
await handleMfaDisable();
|
await handleMfaDisable();
|
||||||
|
|
||||||
await this.registerControllers(ignoredEndpoints);
|
await this.registerControllers(ignoredEndpoints);
|
||||||
|
|
|
@ -1,32 +1,32 @@
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
import { LdapService } from '@/Ldap/ldap.service';
|
||||||
import {
|
import {
|
||||||
createLdapUserOnLocalDb,
|
createLdapUserOnLocalDb,
|
||||||
findAndAuthenticateLdapUser,
|
|
||||||
getLdapConfig,
|
|
||||||
getLdapUserRole,
|
getLdapUserRole,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getAuthIdentityByLdapId,
|
getAuthIdentityByLdapId,
|
||||||
isLdapDisabled,
|
isLdapEnabled,
|
||||||
mapLdapAttributesToUser,
|
mapLdapAttributesToUser,
|
||||||
createLdapAuthIdentity,
|
createLdapAuthIdentity,
|
||||||
updateLdapUserOnLocalDb,
|
updateLdapUserOnLocalDb,
|
||||||
} from '@/Ldap/helpers';
|
} from '@/Ldap/helpers';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { Container } from 'typedi';
|
|
||||||
|
|
||||||
export const handleLdapLogin = async (
|
export const handleLdapLogin = async (
|
||||||
loginId: string,
|
loginId: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<User | undefined> => {
|
): 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,
|
loginId,
|
||||||
password,
|
password,
|
||||||
loginIdAttribute,
|
loginIdAttribute,
|
||||||
|
@ -35,7 +35,7 @@ export const handleLdapLogin = async (
|
||||||
|
|
||||||
if (!ldapUser) return undefined;
|
if (!ldapUser) return undefined;
|
||||||
|
|
||||||
const [ldapId, ldapAttributesValues] = mapLdapAttributesToUser(ldapUser, ldapConfig);
|
const [ldapId, ldapAttributesValues] = mapLdapAttributesToUser(ldapUser, ldapService.config);
|
||||||
|
|
||||||
const { email: emailAttributeValue } = ldapAttributesValues;
|
const { email: emailAttributeValue } = ldapAttributesValues;
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { PasswordUtility } from '@/services/password.utility';
|
||||||
import { UserManagementMailer } from '@/UserManagement/email';
|
import { UserManagementMailer } from '@/UserManagement/email';
|
||||||
import { PasswordResetRequest } from '@/requests';
|
import { PasswordResetRequest } from '@/requests';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
import { issueCookie } from '@/auth/jwt';
|
||||||
import { isLdapEnabled } from '@/Ldap/helpers';
|
|
||||||
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
@ -111,7 +110,7 @@ export class PasswordResetController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLdapEnabled() && ldapIdentity) {
|
if (this.license.isLdapEnabled() && ldapIdentity) {
|
||||||
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
|
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
|
import Container from 'typedi';
|
||||||
import type { SuperAgentTest } from 'supertest';
|
import type { SuperAgentTest } from 'supertest';
|
||||||
import type { Entry as LdapUser } from 'ldapts';
|
import type { Entry as LdapUser } from 'ldapts';
|
||||||
import { Not } from 'typeorm';
|
import { Not } from 'typeorm';
|
||||||
import { jsonParse } from 'n8n-workflow';
|
import { jsonParse } from 'n8n-workflow';
|
||||||
|
import { Cipher } from 'n8n-core';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants';
|
import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants';
|
||||||
import { LdapManager } from '@/Ldap/LdapManager.ee';
|
import { LdapService } from '@/Ldap/ldap.service';
|
||||||
import { LdapService } from '@/Ldap/LdapService.ee';
|
|
||||||
import { saveLdapSynchronization } from '@/Ldap/helpers';
|
import { saveLdapSynchronization } from '@/Ldap/helpers';
|
||||||
import type { LdapConfig } from '@/Ldap/types';
|
import type { LdapConfig } from '@/Ldap/types';
|
||||||
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
|
@ -16,8 +17,7 @@ import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '
|
||||||
import { randomEmail, randomName, uniqueId } from './../shared/random';
|
import { randomEmail, randomName, uniqueId } from './../shared/random';
|
||||||
import * as testDb from './../shared/testDb';
|
import * as testDb from './../shared/testDb';
|
||||||
import * as utils from '../shared/utils/';
|
import * as utils from '../shared/utils/';
|
||||||
import Container from 'typedi';
|
|
||||||
import { Cipher } from 'n8n-core';
|
|
||||||
import { getGlobalMemberRole, getGlobalOwnerRole } from '../shared/db/roles';
|
import { getGlobalMemberRole, getGlobalOwnerRole } from '../shared/db/roles';
|
||||||
import { createLdapUser, createUser, getAllUsers, getLdapIdentities } from '../shared/db/users';
|
import { createLdapUser, createUser, getAllUsers, getLdapIdentities } from '../shared/db/users';
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
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 () => {
|
test('should apply "Convert all LDAP users to email users" strategy when LDAP login disabled', async () => {
|
||||||
const ldapConfig = await createLdapConfig();
|
const ldapConfig = await createLdapConfig();
|
||||||
LdapManager.updateConfig(ldapConfig);
|
Container.get(LdapService).setConfig(ldapConfig);
|
||||||
|
|
||||||
const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
||||||
|
|
||||||
|
@ -230,7 +230,7 @@ describe('POST /ldap/sync', () => {
|
||||||
lastNameAttribute: 'sn',
|
lastNameAttribute: 'sn',
|
||||||
emailAttribute: 'mail',
|
emailAttribute: 'mail',
|
||||||
});
|
});
|
||||||
LdapManager.updateConfig(ldapConfig);
|
Container.get(LdapService).setConfig(ldapConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dry mode', () => {
|
describe('dry mode', () => {
|
||||||
|
@ -493,7 +493,7 @@ test('GET /ldap/sync should return paginated synchronizations', async () => {
|
||||||
describe('POST /login', () => {
|
describe('POST /login', () => {
|
||||||
const runTest = async (ldapUser: LdapUser) => {
|
const runTest = async (ldapUser: LdapUser) => {
|
||||||
const ldapConfig = await createLdapConfig();
|
const ldapConfig = await createLdapConfig();
|
||||||
LdapManager.updateConfig(ldapConfig);
|
Container.get(LdapService).setConfig(ldapConfig);
|
||||||
|
|
||||||
await setCurrentAuthenticationMethod('ldap');
|
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 () => {
|
test('should allow instance owner to sign in with email/password when LDAP is enabled', async () => {
|
||||||
const ldapConfig = await createLdapConfig();
|
const ldapConfig = await createLdapConfig();
|
||||||
LdapManager.updateConfig(ldapConfig);
|
Container.get(LdapService).setConfig(ldapConfig);
|
||||||
|
|
||||||
const response = await testServer.authlessAgent
|
const response = await testServer.authlessAgent
|
||||||
.post('/login')
|
.post('/login')
|
||||||
|
@ -590,7 +590,7 @@ describe('POST /login', () => {
|
||||||
describe('Instance owner should able to delete LDAP users', () => {
|
describe('Instance owner should able to delete LDAP users', () => {
|
||||||
test("don't transfer workflows", async () => {
|
test("don't transfer workflows", async () => {
|
||||||
const ldapConfig = await createLdapConfig();
|
const ldapConfig = await createLdapConfig();
|
||||||
LdapManager.updateConfig(ldapConfig);
|
Container.get(LdapService).setConfig(ldapConfig);
|
||||||
|
|
||||||
const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
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 () => {
|
test('transfer workflows and credentials', async () => {
|
||||||
const ldapConfig = await createLdapConfig();
|
const ldapConfig = await createLdapConfig();
|
||||||
LdapManager.updateConfig(ldapConfig);
|
Container.get(LdapService).setConfig(ldapConfig);
|
||||||
|
|
||||||
const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
const member = await createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
||||||
|
|
||||||
|
|
|
@ -170,10 +170,10 @@ export const setupTestServer = ({
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ldap':
|
case 'ldap':
|
||||||
const { handleLdapInit } = await import('@/Ldap/helpers');
|
const { LdapService } = await import('@/Ldap/ldap.service');
|
||||||
const { LdapController } = await import('@/controllers/ldap.controller');
|
const { LdapController } = await import('@/Ldap/ldap.controller');
|
||||||
testServer.license.enable('feat:ldap');
|
testServer.license.enable('feat:ldap');
|
||||||
await handleLdapInit();
|
await Container.get(LdapService).init();
|
||||||
registerController(app, LdapController);
|
registerController(app, LdapController);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue