mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-21 17:40:48 -08:00
1c6178759c
Ensure all errors in `cli` inherit from `ApplicationError` to continue normalizing all the errors we report to Sentry Follow-up to: https://github.com/n8n-io/n8n/pull/7820
481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-use-before-define */
|
|
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';
|
|
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 { 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
|
|
*/
|
|
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
|
|
*/
|
|
export const getLdapLoginLabel = (): string => config.getEnv(LDAP_LOGIN_LABEL);
|
|
|
|
/**
|
|
* Retrieve the LDAP login enabled from the configuration object
|
|
*/
|
|
export const isLdapLoginEnabled = (): boolean => config.getEnv(LDAP_LOGIN_ENABLED);
|
|
|
|
/**
|
|
* Return a random password to be assigned to the LDAP users
|
|
*/
|
|
export const randomPassword = (): string => {
|
|
return Math.random().toString(36).slice(-8);
|
|
};
|
|
|
|
/**
|
|
* Return the user role to be assigned to LDAP users
|
|
*/
|
|
export const getLdapUserRole = async (): Promise<Role> => {
|
|
return Container.get(RoleService).findGlobalMemberRole();
|
|
};
|
|
|
|
/**
|
|
* Validate the structure of the LDAP configuration schema
|
|
*/
|
|
export const validateLdapConfigurationSchema = (
|
|
ldapConfig: LdapConfig,
|
|
): { valid: boolean; message: string } => {
|
|
const { valid, errors } = validate(ldapConfig, LDAP_CONFIG_SCHEMA, { nestedErrors: true });
|
|
|
|
let message = '';
|
|
if (!valid) {
|
|
message = errors.map((error) => `request.body.${error.path[0]} ${error.message}`).join(',');
|
|
}
|
|
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 => {
|
|
Object.entries(entry)
|
|
.filter(([k]) => BINARY_AD_ATTRIBUTES.includes(k))
|
|
.forEach(([k]) => {
|
|
entry[k] = (entry[k] as Buffer).toString('hex');
|
|
});
|
|
return entry;
|
|
};
|
|
|
|
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 Error(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) {
|
|
_filter = `(&${userFilter}${filter}`;
|
|
}
|
|
return _filter;
|
|
};
|
|
|
|
export const escapeFilter = (filter: string): string => {
|
|
//@ts-ignore
|
|
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
|
|
*/
|
|
export const getAuthIdentityByLdapId = async (
|
|
idAttributeValue: string,
|
|
): Promise<AuthIdentity | null> => {
|
|
return Container.get(AuthIdentityRepository).findOne({
|
|
relations: ['user', 'user.globalRole'],
|
|
where: {
|
|
providerId: idAttributeValue,
|
|
providerType: 'ldap',
|
|
},
|
|
});
|
|
};
|
|
|
|
export const getUserByEmail = async (email: string): Promise<User | null> => {
|
|
return Container.get(UserRepository).findOne({
|
|
where: { email },
|
|
relations: ['globalRole'],
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Map attributes from the LDAP server to the proper columns in the database
|
|
* e.g. mail => email | uid => ldapId
|
|
*/
|
|
export const mapLdapAttributesToUser = (
|
|
ldapUser: LdapUser,
|
|
ldapConfig: LdapConfig,
|
|
): [AuthIdentity['providerId'], Pick<User, 'email' | 'firstName' | 'lastName'>] => {
|
|
return [
|
|
ldapUser[ldapConfig.ldapIdAttribute] as string,
|
|
{
|
|
email: ldapUser[ldapConfig.emailAttribute] as string,
|
|
firstName: ldapUser[ldapConfig.firstNameAttribute] as string,
|
|
lastName: ldapUser[ldapConfig.lastNameAttribute] as string,
|
|
},
|
|
];
|
|
};
|
|
|
|
/**
|
|
* Retrieve LDAP ID of all LDAP users in the database
|
|
*/
|
|
export const getLdapIds = async (): Promise<string[]> => {
|
|
const identities = await Container.get(AuthIdentityRepository).find({
|
|
select: ['providerId'],
|
|
where: {
|
|
providerType: 'ldap',
|
|
},
|
|
});
|
|
return identities.map((i) => i.providerId);
|
|
};
|
|
|
|
export const getLdapUsers = async (): Promise<User[]> => {
|
|
const identities = await Container.get(AuthIdentityRepository).find({
|
|
relations: ['user'],
|
|
where: {
|
|
providerType: 'ldap',
|
|
},
|
|
});
|
|
return identities.map((i) => i.user);
|
|
};
|
|
|
|
/**
|
|
* Map a LDAP user to database user
|
|
*/
|
|
export const mapLdapUserToDbUser = (
|
|
ldapUser: LdapUser,
|
|
ldapConfig: LdapConfig,
|
|
role?: Role,
|
|
): [string, User] => {
|
|
const user = new User();
|
|
const [ldapId, data] = mapLdapAttributesToUser(ldapUser, ldapConfig);
|
|
Object.assign(user, data);
|
|
if (role) {
|
|
user.globalRole = role;
|
|
user.password = randomPassword();
|
|
user.disabled = false;
|
|
} else {
|
|
user.disabled = true;
|
|
}
|
|
return [ldapId, user];
|
|
};
|
|
|
|
/**
|
|
* Save "toCreateUsers" in the database
|
|
* Update "toUpdateUsers" in the database
|
|
* Update "ToDisableUsers" in the database
|
|
*/
|
|
export const processUsers = async (
|
|
toCreateUsers: Array<[string, User]>,
|
|
toUpdateUsers: Array<[string, User]>,
|
|
toDisableUsers: string[],
|
|
): Promise<void> => {
|
|
await Db.transaction(async (transactionManager) => {
|
|
return Promise.all([
|
|
...toCreateUsers.map(async ([ldapId, user]) => {
|
|
const authIdentity = AuthIdentity.create(await transactionManager.save(user), ldapId);
|
|
return transactionManager.save(authIdentity);
|
|
}),
|
|
...toUpdateUsers.map(async ([ldapId, user]) => {
|
|
const authIdentity = await transactionManager.findOneBy(AuthIdentity, {
|
|
providerId: ldapId,
|
|
});
|
|
if (authIdentity?.userId) {
|
|
await transactionManager.update(
|
|
User,
|
|
{ id: authIdentity.userId },
|
|
{ email: user.email, firstName: user.firstName, lastName: user.lastName },
|
|
);
|
|
}
|
|
}),
|
|
...toDisableUsers.map(async (ldapId) => {
|
|
const authIdentity = await transactionManager.findOneBy(AuthIdentity, {
|
|
providerId: ldapId,
|
|
});
|
|
if (authIdentity?.userId) {
|
|
await transactionManager.update(User, { id: authIdentity?.userId }, { disabled: true });
|
|
await transactionManager.delete(AuthIdentity, { userId: authIdentity?.userId });
|
|
}
|
|
}),
|
|
]);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Save a LDAP synchronization data to the database
|
|
*/
|
|
export const saveLdapSynchronization = async (
|
|
data: Omit<AuthProviderSyncHistory, 'id' | 'providerType'>,
|
|
): Promise<void> => {
|
|
await Container.get(AuthProviderSyncHistoryRepository).save({
|
|
...data,
|
|
providerType: 'ldap',
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Retrieve all LDAP synchronizations in the database
|
|
*/
|
|
export const getLdapSynchronizations = async (
|
|
page: number,
|
|
perPage: number,
|
|
): Promise<AuthProviderSyncHistory[]> => {
|
|
const _page = Math.abs(page);
|
|
return Container.get(AuthProviderSyncHistoryRepository).find({
|
|
where: { providerType: 'ldap' },
|
|
order: { id: 'DESC' },
|
|
take: perPage,
|
|
skip: _page * perPage,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Format the LDAP connection URL to conform with LDAP client library
|
|
*/
|
|
export const formatUrl = (url: string, port: number, security: ConnectionSecurity) => {
|
|
const protocol = ['tls'].includes(security) ? 'ldaps' : 'ldap';
|
|
return `${protocol}://${url}:${port}`;
|
|
};
|
|
|
|
export const getMappingAttributes = (ldapConfig: LdapConfig): string[] => {
|
|
return [
|
|
ldapConfig.emailAttribute,
|
|
ldapConfig.ldapIdAttribute,
|
|
ldapConfig.firstNameAttribute,
|
|
ldapConfig.lastNameAttribute,
|
|
ldapConfig.emailAttribute,
|
|
];
|
|
};
|
|
|
|
export const createLdapAuthIdentity = async (user: User, ldapId: string) => {
|
|
return Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, ldapId));
|
|
};
|
|
|
|
export const createLdapUserOnLocalDb = async (role: Role, data: Partial<User>, ldapId: string) => {
|
|
const user = await Container.get(UserRepository).save({
|
|
password: randomPassword(),
|
|
globalRole: role,
|
|
...data,
|
|
});
|
|
await createLdapAuthIdentity(user, ldapId);
|
|
return user;
|
|
};
|
|
|
|
export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial<User>) => {
|
|
const userId = identity?.user?.id;
|
|
if (userId) {
|
|
await Container.get(UserRepository).update({ id: userId }, data);
|
|
}
|
|
};
|
|
|
|
const deleteAllLdapIdentities = async () => {
|
|
return Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' });
|
|
};
|