refactor(core): Switch over all user-management routes to use decorators (#5115)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-01-27 11:19:47 +01:00 committed by GitHub
parent 08a90d7e09
commit 845f0f9d20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 1803 additions and 1667 deletions

View file

@ -1,5 +1,5 @@
import { NodeCreator } from '../pages/features/node-creator'; import { NodeCreator } from '../pages/features/node-creator';
import { INodeTypeDescription } from '../../packages/workflow'; import { INodeTypeDescription } from 'n8n-workflow';
import CustomNodeFixture from '../fixtures/Custom_node.json'; import CustomNodeFixture from '../fixtures/Custom_node.json';
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants'; import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
import { randFirstName, randLastName } from '@ngneat/falso'; import { randFirstName, randLastName } from '@ngneat/falso';

View file

@ -1,5 +1,5 @@
import { BasePage } from '../base'; import { BasePage } from '../base';
import { INodeTypeDescription } from '../../packages/workflow'; import { INodeTypeDescription } from 'n8n-workflow';
export class NodeCreator extends BasePage { export class NodeCreator extends BasePage {
url = '/workflow/new'; url = '/workflow/new';

View file

@ -25,7 +25,7 @@ import {
sendSuccessResponse, sendSuccessResponse,
ServiceUnavailableError, ServiceUnavailableError,
} from '@/ResponseHelper'; } from '@/ResponseHelper';
import { corsMiddleware } from '@/middlewares/cors'; import { corsMiddleware } from '@/middlewares';
import * as TestWebhooks from '@/TestWebhooks'; import * as TestWebhooks from '@/TestWebhooks';
import { WaitingWebhooks } from '@/WaitingWebhooks'; import { WaitingWebhooks } from '@/WaitingWebhooks';
import { WEBHOOK_METHODS } from '@/WebhookHelpers'; import { WEBHOOK_METHODS } from '@/WebhookHelpers';

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import type { Application } from 'express';
import type { import type {
ExecutionError, ExecutionError,
ICredentialDataDecryptedObject, ICredentialDataDecryptedObject,
@ -21,9 +22,11 @@ import type {
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { WorkflowExecute } from 'n8n-core'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import PCancelable from 'p-cancelable'; import type { WorkflowExecute } from 'n8n-core';
import type PCancelable from 'p-cancelable';
import type { FindOperator, Repository } from 'typeorm'; import type { FindOperator, Repository } from 'typeorm';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
@ -365,6 +368,7 @@ export interface IInternalHooksClass {
user: User; user: User;
target_user_id: string[]; target_user_id: string[];
public_api: boolean; public_api: boolean;
email_sent: boolean;
}): Promise<void>; }): Promise<void>;
onUserReinvite(userReinviteData: { onUserReinvite(userReinviteData: {
user: User; user: User;
@ -378,6 +382,7 @@ export interface IInternalHooksClass {
userTransactionalEmailData: { userTransactionalEmailData: {
user_id: string; user_id: string;
message_type: 'Reset password' | 'New user invite' | 'Resend invite'; message_type: 'Reset password' | 'New user invite' | 'Resend invite';
public_api: boolean;
}, },
user?: User, user?: User,
): Promise<void>; ): Promise<void>;
@ -841,3 +846,37 @@ export interface ILicenseReadResponse {
export interface ILicensePostResponse extends ILicenseReadResponse { export interface ILicensePostResponse extends ILicenseReadResponse {
managementToken: string; managementToken: string;
} }
export interface JwtToken {
token: string;
expiresIn: number;
}
export interface JwtPayload {
id: string;
email: string | null;
password: string | null;
}
export interface PublicUser {
id: string;
email?: string;
firstName?: string;
lastName?: string;
personalizationAnswers?: IPersonalizationSurveyAnswers | null;
password?: string;
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
globalRole?: Role;
signInType: AuthProviderType;
disabled: boolean;
inviteAcceptUrl?: string;
}
export interface N8nApp {
app: Application;
restEndpoint: string;
externalHooks: IExternalHooksClass;
activeWorkflowRunner: ActiveWorkflowRunner;
}

View file

@ -8,7 +8,7 @@ import * as Db from '@/Db';
import config from '@/config'; import config from '@/config';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import { AuthIdentity } from '@/databases/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory';
import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper';
import { LdapManager } from './LdapManager.ee'; import { LdapManager } from './LdapManager.ee';

View file

@ -63,9 +63,6 @@ import {
WorkflowExecuteMode, WorkflowExecuteMode,
INodeTypes, INodeTypes,
ICredentialTypes, ICredentialTypes,
INode,
IWorkflowBase,
IRun,
} from 'n8n-workflow'; } from 'n8n-workflow';
import basicAuth from 'basic-auth'; import basicAuth from 'basic-auth';
@ -103,8 +100,15 @@ import type {
OAuthRequest, OAuthRequest,
WorkflowRequest, WorkflowRequest,
} from '@/requests'; } from '@/requests';
import { userManagementRouter } from '@/UserManagement'; import { registerController } from '@/decorators';
import { resolveJwt } from '@/UserManagement/auth/jwt'; import {
AuthController,
MeController,
OwnerController,
PasswordResetController,
UsersController,
} from '@/controllers';
import { resolveJwt } from '@/auth/jwt';
import { executionsController } from '@/executions/executions.controller'; import { executionsController } from '@/executions/executions.controller';
import { nodeTypesController } from '@/api/nodeTypes.api'; import { nodeTypesController } from '@/api/nodeTypes.api';
@ -118,6 +122,7 @@ import {
isUserManagementEnabled, isUserManagementEnabled,
whereClause, whereClause,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import { getInstance as getMailerInstance } from '@/UserManagement/email';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { import {
DatabaseType, DatabaseType,
@ -151,7 +156,7 @@ import { eventBusRouter } from '@/eventbus/eventBusRoutes';
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper'; import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
import { getLicense } from '@/License'; import { getLicense } from '@/License';
import { licenseController } from './license/license.controller'; import { licenseController } from './license/license.controller';
import { corsMiddleware } from './middlewares/cors'; import { corsMiddleware, setupAuthMiddlewares } from './middlewares';
import { initEvents } from './events'; import { initEvents } from './events';
import { ldapController } from './Ldap/routes/ldap.controller.ee'; import { ldapController } from './Ldap/routes/ldap.controller.ee';
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers'; import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
@ -336,6 +341,33 @@ class Server extends AbstractServer {
} }
} }
private registerControllers(ignoredEndpoints: Readonly<string[]>) {
const { app, externalHooks, activeWorkflowRunner } = this;
const repositories = Db.collections;
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint, repositories.User);
const logger = LoggerProxy;
const internalHooks = InternalHooksManager.getInstance();
const mailer = getMailerInstance();
const controllers = [
new AuthController({ config, internalHooks, repositories, logger }),
new OwnerController({ config, internalHooks, repositories, logger }),
new MeController({ externalHooks, internalHooks, repositories, logger }),
new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }),
new UsersController({
config,
mailer,
externalHooks,
internalHooks,
repositories,
activeWorkflowRunner,
logger,
}),
];
controllers.forEach((controller) => registerController(app, config, controller));
}
async configure(): Promise<void> { async configure(): Promise<void> {
configureMetrics(this.app); configureMetrics(this.app);
@ -354,7 +386,7 @@ class Server extends AbstractServer {
const publicApiEndpoint = config.getEnv('publicApi.path'); const publicApiEndpoint = config.getEnv('publicApi.path');
const excludeEndpoints = config.getEnv('security.excludeEndpoints'); const excludeEndpoints = config.getEnv('security.excludeEndpoints');
const ignoredEndpoints = [ const ignoredEndpoints: Readonly<string[]> = [
'assets', 'assets',
'healthz', 'healthz',
'metrics', 'metrics',
@ -587,7 +619,7 @@ class Server extends AbstractServer {
// ---------------------------------------- // ----------------------------------------
// User Management // User Management
// ---------------------------------------- // ----------------------------------------
await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]); this.registerControllers(ignoredEndpoints);
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);

View file

@ -1,39 +0,0 @@
import type { Application } from 'express';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthProviderType } from '@/databases/entities/AuthIdentity';
import type { Role } from '@/databases/entities/Role';
export interface JwtToken {
token: string;
expiresIn: number;
}
export interface JwtPayload {
id: string;
email: string | null;
password: string | null;
}
export interface PublicUser {
id: string;
email?: string;
firstName?: string;
lastName?: string;
personalizationAnswers?: IPersonalizationSurveyAnswers | null;
password?: string;
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
globalRole?: Role;
signInType: AuthProviderType;
disabled: boolean;
inviteAcceptUrl?: string;
}
export interface N8nApp {
app: Application;
restEndpoint: string;
externalHooks: IExternalHooksClass;
activeWorkflowRunner: ActiveWorkflowRunner;
}

View file

@ -6,14 +6,13 @@ import { compare, genSaltSync, hash } from 'bcryptjs';
import * as Db from '@/Db'; import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { PublicUser } from './Interfaces'; import type { PublicUser, WhereClause } from '@/Interfaces';
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '@db/entities/User'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '@db/entities/User';
import { Role } from '@db/entities/Role'; import { Role } from '@db/entities/Role';
import { AuthenticatedRequest } from '@/requests'; import { AuthenticatedRequest } from '@/requests';
import config from '@/config'; import config from '@/config';
import { getWebhookBaseUrl } from '../WebhookHelpers'; import { getWebhookBaseUrl } from '@/WebhookHelpers';
import { getLicense } from '@/License'; import { getLicense } from '@/License';
import { WhereClause } from '@/Interfaces';
import { RoleService } from '@/role/role.service'; import { RoleService } from '@/role/role.service';
export async function getWorkflowOwner(workflowId: string): Promise<User> { export async function getWorkflowOwner(workflowId: string): Promise<User> {
@ -177,7 +176,7 @@ export async function getUserById(userId: string): Promise<User> {
/** /**
* Check if a URL contains an auth-excluded endpoint. * Check if a URL contains an auth-excluded endpoint.
*/ */
export function isAuthExcluded(url: string, ignoredEndpoints: string[]): boolean { export function isAuthExcluded(url: string, ignoredEndpoints: Readonly<string[]>): boolean {
return !!ignoredEndpoints return !!ignoredEndpoints
.filter(Boolean) // skip empty paths .filter(Boolean) // skip empty paths
.find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`)); .find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`));

View file

@ -1,3 +0,0 @@
import { addRoutes } from './routes';
export const userManagementRouter = { addRoutes };

View file

@ -1,55 +0,0 @@
import { Request, RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
import passport from 'passport';
import { Strategy } from 'passport-jwt';
import { LoggerProxy as Logger } from 'n8n-workflow';
import { JwtPayload } from '../Interfaces';
import type { AuthenticatedRequest } from '@/requests';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import { issueCookie, resolveJwtContent } from '../auth/jwt';
const jwtFromRequest = (req: Request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null;
};
export const jwtAuth = (): RequestHandler => {
const jwtStrategy = new Strategy(
{
jwtFromRequest,
secretOrKey: config.getEnv('userManagement.jwtSecret'),
},
async (jwtPayload: JwtPayload, done) => {
try {
const user = await resolveJwtContent(jwtPayload);
return done(null, user);
} catch (error) {
Logger.debug('Failed to extract user from JWT payload', { jwtPayload });
return done(null, false, { message: 'User not found' });
}
},
);
passport.use(jwtStrategy);
return passport.initialize();
};
/**
* middleware to refresh cookie before it expires
*/
export const refreshExpiringCookie: RequestHandler = async (
req: AuthenticatedRequest,
res,
next,
) => {
const cookieAuth = jwtFromRequest(req);
if (cookieAuth && req.user) {
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
if (cookieContents.exp * 1000 - Date.now() < 259200000) {
// if cookie expires in < 3 days, renew it.
await issueCookie(res, req.user);
}
}
next();
};

View file

@ -1,118 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Request, Response } from 'express';
import { IDataObject } from 'n8n-workflow';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import { AUTH_COOKIE_NAME } from '@/constants';
import { issueCookie, resolveJwt } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
import { sanitizeUser } from '../UserManagementHelper';
import { User } from '@db/entities/User';
import type { LoginRequest } from '@/requests';
import config from '@/config';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
export function authenticationMethods(this: N8nApp): void {
/**
* Log in a user.
*
* Authless endpoint.
*/
this.app.post(
`/${this.restEndpoint}/login`,
ResponseHelper.send(async (req: LoginRequest, res: Response): Promise<PublicUser> => {
const { email, password } = req.body;
if (!email) {
throw new Error('Email is required to log in');
}
if (!password) {
throw new Error('Password is required to log in');
}
const adUser = await handleLdapLogin(email, password);
if (adUser) {
await issueCookie(res, adUser);
return sanitizeUser(adUser);
}
const localUser = await handleEmailLogin(email, password);
if (localUser) {
await issueCookie(res, localUser);
return sanitizeUser(localUser);
}
throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?');
}),
);
/**
* Manually check the `n8n-auth` cookie.
*/
this.app.get(
`/${this.restEndpoint}/login`,
ResponseHelper.send(async (req: Request, res: Response): Promise<PublicUser> => {
// Manually check the existing cookie.
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
let user: User;
if (cookieContents) {
// If logged in, return user
try {
user = await resolveJwt(cookieContents);
if (!config.get('userManagement.isInstanceOwnerSetUp')) {
res.cookie(AUTH_COOKIE_NAME, cookieContents);
}
return sanitizeUser(user);
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
}
if (config.get('userManagement.isInstanceOwnerSetUp')) {
throw new ResponseHelper.AuthError('Not logged in');
}
try {
user = await Db.collections.User.findOneOrFail({ relations: ['globalRole'], where: {} });
} catch (error) {
throw new ResponseHelper.InternalServerError(
'No users found in database - did you wipe the users table? Create at least one user.',
);
}
if (user.email || user.password) {
throw new ResponseHelper.InternalServerError(
'Invalid database state - user has password set.',
);
}
await issueCookie(res, user);
return sanitizeUser(user);
}),
);
/**
* Log out a user.
*
* Authless endpoint.
*/
this.app.post(
`/${this.restEndpoint}/logout`,
ResponseHelper.send(async (_, res: Response): Promise<IDataObject> => {
res.clearCookie(AUTH_COOKIE_NAME);
return {
loggedOut: true,
};
}),
);
}

View file

@ -1,202 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import express from 'express';
import validator from 'validator';
import { randomBytes } from 'crypto';
import { LoggerProxy as Logger } from 'n8n-workflow';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import { InternalHooksManager } from '@/InternalHooksManager';
import { issueCookie } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
import { validatePassword, sanitizeUser, compareHash, hashPassword } from '../UserManagementHelper';
import type { AuthenticatedRequest, MeRequest } from '@/requests';
import { validateEntity } from '@/GenericHelpers';
import { User } from '@db/entities/User';
export function meNamespace(this: N8nApp): void {
/**
* Return the logged-in user.
*/
this.app.get(
`/${this.restEndpoint}/me`,
ResponseHelper.send(async (req: AuthenticatedRequest): Promise<PublicUser> => {
return sanitizeUser(req.user);
}),
);
/**
* Update the logged-in user's settings, except password.
*/
this.app.patch(
`/${this.restEndpoint}/me`,
ResponseHelper.send(
async (req: MeRequest.Settings, res: express.Response): Promise<PublicUser> => {
const { email } = req.body;
if (!email) {
Logger.debug('Request to update user email failed because of missing email in payload', {
userId: req.user.id,
payload: req.body,
});
throw new ResponseHelper.BadRequestError('Email is mandatory');
}
if (!validator.isEmail(email)) {
Logger.debug('Request to update user email failed because of invalid email in payload', {
userId: req.user.id,
invalidEmail: email,
});
throw new ResponseHelper.BadRequestError('Invalid email address');
}
const { email: currentEmail } = req.user;
const newUser = new User();
Object.assign(newUser, req.user, req.body);
await validateEntity(newUser);
const user = await Db.collections.User.save(newUser);
Logger.info('User updated successfully', { userId: user.id });
await issueCookie(res, user);
const updatedkeys = Object.keys(req.body);
void InternalHooksManager.getInstance().onUserUpdate({
user,
fields_changed: updatedkeys,
});
await this.externalHooks.run('user.profile.update', [currentEmail, sanitizeUser(user)]);
return sanitizeUser(user);
},
),
);
/**
* Update the logged-in user's password.
*/
this.app.patch(
`/${this.restEndpoint}/me/password`,
ResponseHelper.send(async (req: MeRequest.Password, res: express.Response) => {
const { currentPassword, newPassword } = req.body;
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
throw new ResponseHelper.BadRequestError('Invalid payload.');
}
if (!req.user.password) {
throw new ResponseHelper.BadRequestError('Requesting user not set up.');
}
const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password);
if (!isCurrentPwCorrect) {
throw new ResponseHelper.BadRequestError('Provided current password is incorrect.');
}
const validPassword = validatePassword(newPassword);
req.user.password = await hashPassword(validPassword);
const user = await Db.collections.User.save(req.user);
Logger.info('Password updated successfully', { userId: user.id });
await issueCookie(res, user);
void InternalHooksManager.getInstance().onUserUpdate({
user,
fields_changed: ['password'],
});
await this.externalHooks.run('user.password.update', [user.email, req.user.password]);
return { success: true };
}),
);
/**
* Store the logged-in user's survey answers.
*/
this.app.post(
`/${this.restEndpoint}/me/survey`,
ResponseHelper.send(async (req: MeRequest.SurveyAnswers) => {
const { body: personalizationAnswers } = req;
if (!personalizationAnswers) {
Logger.debug(
'Request to store user personalization survey failed because of empty payload',
{
userId: req.user.id,
},
);
throw new ResponseHelper.BadRequestError('Personalization answers are mandatory');
}
await Db.collections.User.save({
id: req.user.id,
personalizationAnswers,
});
Logger.info('User survey updated successfully', { userId: req.user.id });
void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted(
req.user.id,
personalizationAnswers,
);
return { success: true };
}),
);
/**
* Creates an API Key
*/
this.app.post(
`/${this.restEndpoint}/me/api-key`,
ResponseHelper.send(async (req: AuthenticatedRequest) => {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;
await Db.collections.User.update(req.user.id, {
apiKey,
});
void InternalHooksManager.getInstance().onApiKeyCreated({
user: req.user,
public_api: false,
});
return { apiKey };
}),
);
/**
* Deletes an API Key
*/
this.app.delete(
`/${this.restEndpoint}/me/api-key`,
ResponseHelper.send(async (req: AuthenticatedRequest) => {
await Db.collections.User.update(req.user.id, {
apiKey: null,
});
void InternalHooksManager.getInstance().onApiKeyDeleted({
user: req.user,
public_api: false,
});
return { success: true };
}),
);
/**
* Get an API Key
*/
this.app.get(
`/${this.restEndpoint}/me/api-key`,
ResponseHelper.send(async (req: AuthenticatedRequest) => {
return { apiKey: req.user.apiKey };
}),
);
}

View file

@ -1,119 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import express from 'express';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import { InternalHooksManager } from '@/InternalHooksManager';
import config from '@/config';
import { validateEntity } from '@/GenericHelpers';
import { AuthenticatedRequest, OwnerRequest } from '@/requests';
import { issueCookie } from '../auth/jwt';
import { N8nApp } from '../Interfaces';
import { hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper';
export function ownerNamespace(this: N8nApp): void {
/**
* Promote a shell into the owner of the n8n instance,
* and enable `isInstanceOwnerSetUp` setting.
*/
this.app.post(
`/${this.restEndpoint}/owner`,
ResponseHelper.send(async (req: OwnerRequest.Post, res: express.Response) => {
const { email, firstName, lastName, password } = req.body;
const { id: userId } = req.user;
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
Logger.debug(
'Request to claim instance ownership failed because instance owner already exists',
{
userId,
},
);
throw new ResponseHelper.BadRequestError('Invalid request');
}
if (!email || !validator.isEmail(email)) {
Logger.debug('Request to claim instance ownership failed because of invalid email', {
userId,
invalidEmail: email,
});
throw new ResponseHelper.BadRequestError('Invalid email address');
}
const validPassword = validatePassword(password);
if (!firstName || !lastName) {
Logger.debug(
'Request to claim instance ownership failed because of missing first name or last name in payload',
{ userId, payload: req.body },
);
throw new ResponseHelper.BadRequestError('First and last names are mandatory');
}
let owner = await Db.collections.User.findOne({
relations: ['globalRole'],
where: { id: userId },
});
if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) {
Logger.debug(
'Request to claim instance ownership failed because user shell does not exist or has wrong role!',
{
userId,
},
);
throw new ResponseHelper.BadRequestError('Invalid request');
}
owner = Object.assign(owner, {
email,
firstName,
lastName,
password: await hashPassword(validPassword),
});
await validateEntity(owner);
owner = await Db.collections.User.save(owner);
Logger.info('Owner was set up successfully', { userId: req.user.id });
await Db.collections.Settings.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
config.set('userManagement.isInstanceOwnerSetUp', true);
Logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id });
await issueCookie(res, owner);
void InternalHooksManager.getInstance().onInstanceOwnerSetup({
user_id: userId,
});
return sanitizeUser(owner);
}),
);
/**
* Persist that the instance owner setup has been skipped
*/
this.app.post(
`/${this.restEndpoint}/owner/skip-setup`,
// eslint-disable-next-line @typescript-eslint/naming-convention
ResponseHelper.send(async (_req: AuthenticatedRequest, _res: express.Response) => {
await Db.collections.Settings.update(
{ key: 'userManagement.skipInstanceOwnerSetup' },
{ value: JSON.stringify(true) },
);
config.set('userManagement.skipInstanceOwnerSetup', true);
return { success: true };
}),
);
}

View file

@ -1,248 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import express from 'express';
import { v4 as uuid } from 'uuid';
import { URL } from 'url';
import validator from 'validator';
import { IsNull, MoreThanOrEqual, Not } from 'typeorm';
import { LoggerProxy as Logger } from 'n8n-workflow';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import { InternalHooksManager } from '@/InternalHooksManager';
import { N8nApp } from '../Interfaces';
import { getInstanceBaseUrl, hashPassword, validatePassword } from '../UserManagementHelper';
import * as UserManagementMailer from '../email';
import type { PasswordResetRequest } from '@/requests';
import { issueCookie } from '../auth/jwt';
import config from '@/config';
import { isLdapEnabled } from '@/Ldap/helpers';
export function passwordResetNamespace(this: N8nApp): void {
/**
* Send a password reset email.
*
* Authless endpoint.
*/
this.app.post(
`/${this.restEndpoint}/forgot-password`,
ResponseHelper.send(async (req: PasswordResetRequest.Email) => {
if (config.getEnv('userManagement.emails.mode') === '') {
Logger.debug('Request to send password reset email failed because emailing was not set up');
throw new ResponseHelper.InternalServerError(
'Email sending must be set up in order to request a password reset email',
);
}
const { email } = req.body;
if (!email) {
Logger.debug(
'Request to send password reset email failed because of missing email in payload',
{ payload: req.body },
);
throw new ResponseHelper.BadRequestError('Email is mandatory');
}
if (!validator.isEmail(email)) {
Logger.debug(
'Request to send password reset email failed because of invalid email in payload',
{ invalidEmail: email },
);
throw new ResponseHelper.BadRequestError('Invalid email address');
}
// User should just be able to reset password if one is already present
const user = await Db.collections.User.findOne({
where: {
email,
password: Not(IsNull()),
},
relations: ['authIdentities'],
});
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
if (!user?.password || (ldapIdentity && user.disabled)) {
Logger.debug(
'Request to send password reset email failed because no user was found for the provided email',
{ invalidEmail: email },
);
return;
}
if (isLdapEnabled() && ldapIdentity) {
throw new ResponseHelper.UnprocessableRequestError(
'forgotPassword.ldapUserPasswordResetUnavailable',
);
}
user.resetPasswordToken = uuid();
const { id, firstName, lastName, resetPasswordToken } = user;
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;
await Db.collections.User.update(id, { resetPasswordToken, resetPasswordTokenExpiration });
const baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`);
url.searchParams.append('userId', id);
url.searchParams.append('token', resetPasswordToken);
try {
const mailer = UserManagementMailer.getInstance();
await mailer.passwordReset({
email,
firstName,
lastName,
passwordResetUrl: url.toString(),
domain: baseUrl,
});
} catch (error) {
void InternalHooksManager.getInstance().onEmailFailed({
user,
message_type: 'Reset password',
public_api: false,
});
if (error instanceof Error) {
throw new ResponseHelper.InternalServerError(
`Please contact your administrator: ${error.message}`,
);
}
}
Logger.info('Sent password reset email successfully', { userId: user.id, email });
void InternalHooksManager.getInstance().onUserTransactionalEmail({
user_id: id,
message_type: 'Reset password',
public_api: false,
});
void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({
user,
});
}),
);
/**
* Verify password reset token and user ID.
*
* Authless endpoint.
*/
this.app.get(
`/${this.restEndpoint}/resolve-password-token`,
ResponseHelper.send(async (req: PasswordResetRequest.Credentials) => {
const { token: resetPasswordToken, userId: id } = req.query;
if (!resetPasswordToken || !id) {
Logger.debug(
'Request to resolve password token failed because of missing password reset token or user ID in query string',
{
queryString: req.query,
},
);
throw new ResponseHelper.BadRequestError('');
}
// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await Db.collections.User.findOneBy({
id,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
});
if (!user) {
Logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token',
{
userId: id,
resetPasswordToken,
},
);
throw new ResponseHelper.NotFoundError('');
}
Logger.info('Reset-password token resolved successfully', { userId: id });
void InternalHooksManager.getInstance().onUserPasswordResetEmailClick({
user,
});
}),
);
/**
* Verify password reset token and user ID and update password.
*
* Authless endpoint.
*/
this.app.post(
`/${this.restEndpoint}/change-password`,
ResponseHelper.send(async (req: PasswordResetRequest.NewPassword, res: express.Response) => {
const { token: resetPasswordToken, userId, password } = req.body;
if (!resetPasswordToken || !userId || !password) {
Logger.debug(
'Request to change password failed because of missing user ID or password or reset password token in payload',
{
payload: req.body,
},
);
throw new ResponseHelper.BadRequestError(
'Missing user ID or password or reset password token',
);
}
const validPassword = validatePassword(password);
// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await Db.collections.User.findOne({
where: {
id: userId,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
},
relations: ['authIdentities'],
});
if (!user) {
Logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token',
{
userId,
resetPasswordToken,
},
);
throw new ResponseHelper.NotFoundError('');
}
await Db.collections.User.update(userId, {
password: await hashPassword(validPassword),
resetPasswordToken: null,
resetPasswordTokenExpiration: null,
});
Logger.info('User password updated successfully', { userId });
await issueCookie(res, user);
void InternalHooksManager.getInstance().onUserUpdate({
user,
fields_changed: ['password'],
});
// if this user used to be an LDAP users
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
if (ldapIdentity) {
void InternalHooksManager.getInstance().onUserSignup(user, {
user_type: 'email',
was_disabled_ldap_user: true,
});
}
await this.externalHooks.run('user.password.update', [user.email, password]);
}),
);
}

View file

@ -1,610 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { Response } from 'express';
import { ErrorReporterProxy as ErrorReporter, LoggerProxy as Logger } from 'n8n-workflow';
import { In } from 'typeorm';
import validator from 'validator';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import { ITelemetryUserDeletionData } from '@/Interfaces';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { User } from '@db/entities/User';
import { UserRequest } from '@/requests';
import * as UserManagementMailer from '../email/UserManagementMailer';
import { N8nApp, PublicUser } from '../Interfaces';
import {
addInviteLinkToUser,
generateUserInviteUrl,
getInstanceBaseUrl,
hashPassword,
isEmailSetUp,
isUserManagementDisabled,
sanitizeUser,
validatePassword,
} from '../UserManagementHelper';
import config from '@/config';
import { issueCookie } from '../auth/jwt';
import { InternalHooksManager } from '@/InternalHooksManager';
import { AuthIdentity } from '@/databases/entities/AuthIdentity';
import { RoleService } from '@/role/role.service';
export function usersNamespace(this: N8nApp): void {
/**
* Send email invite(s) to one or multiple users and create user shell(s).
*/
this.app.post(
`/${this.restEndpoint}/users`,
ResponseHelper.send(async (req: UserRequest.Invite) => {
const mailer = UserManagementMailer.getInstance();
// TODO: this should be checked in the middleware rather than here
if (isUserManagementDisabled()) {
Logger.debug(
'Request to send email invite(s) to user(s) failed because user management is disabled',
);
throw new ResponseHelper.BadRequestError('User management is disabled');
}
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
Logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
);
throw new ResponseHelper.BadRequestError(
'You must set up your own account before inviting others',
);
}
if (!Array.isArray(req.body)) {
Logger.debug(
'Request to send email invite(s) to user(s) failed because the payload is not an array',
{
payload: req.body,
},
);
throw new ResponseHelper.BadRequestError('Invalid payload');
}
if (!req.body.length) return [];
const createUsers: { [key: string]: string | null } = {};
// Validate payload
req.body.forEach((invite) => {
if (typeof invite !== 'object' || !invite.email) {
throw new ResponseHelper.BadRequestError(
'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>',
);
}
if (!validator.isEmail(invite.email)) {
Logger.debug('Invalid email in payload', { invalidEmail: invite.email });
throw new ResponseHelper.BadRequestError(
`Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`,
);
}
createUsers[invite.email.toLowerCase()] = null;
});
const role = await Db.collections.Role.findOneBy({ scope: 'global', name: 'member' });
if (!role) {
Logger.error(
'Request to send email invite(s) to user(s) failed because no global member role was found in database',
);
throw new ResponseHelper.InternalServerError(
'Members role not found in database - inconsistent state',
);
}
// remove/exclude existing users from creation
const existingUsers = await Db.collections.User.find({
where: { email: In(Object.keys(createUsers)) },
});
existingUsers.forEach((user) => {
if (user.password) {
delete createUsers[user.email];
return;
}
createUsers[user.email] = user.id;
});
const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null);
const total = usersToSetUp.length;
Logger.debug(total > 1 ? `Creating ${total} user shells...` : 'Creating 1 user shell...');
try {
await Db.transaction(async (transactionManager) => {
return Promise.all(
usersToSetUp.map(async (email) => {
const newUser = Object.assign(new User(), {
email,
globalRole: role,
});
const savedUser = await transactionManager.save<User>(newUser);
createUsers[savedUser.email] = savedUser.id;
return savedUser;
}),
);
});
} catch (error) {
ErrorReporter.error(error);
Logger.error('Failed to create user shells', { userShells: createUsers });
throw new ResponseHelper.InternalServerError('An error occurred during user creation');
}
Logger.debug('Created user shell(s) successfully', { userId: req.user.id });
Logger.verbose(total > 1 ? `${total} user shells created` : '1 user shell created', {
userShells: createUsers,
});
const baseUrl = getInstanceBaseUrl();
const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email);
// send invite email to new or not yet setup users
const emailingResults = await Promise.all(
usersPendingSetup.map(async ([email, id]) => {
if (!id) {
// This should never happen since those are removed from the list before reaching this point
throw new ResponseHelper.InternalServerError(
'User ID is missing for user with email address',
);
}
const inviteAcceptUrl = generateUserInviteUrl(req.user.id, id);
const resp: {
user: { id: string | null; email: string; inviteAcceptUrl: string; emailSent: boolean };
error?: string;
} = {
user: {
id,
email,
inviteAcceptUrl,
emailSent: false,
},
};
try {
const result = await mailer.invite({
email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
resp.user.emailSent = true;
void InternalHooksManager.getInstance().onUserTransactionalEmail({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user_id: id,
message_type: 'New user invite',
public_api: false,
});
}
void InternalHooksManager.getInstance().onUserInvite({
user: req.user,
target_user_id: Object.values(createUsers) as string[],
public_api: false,
email_sent: result.emailSent,
});
} catch (error) {
if (error instanceof Error) {
void InternalHooksManager.getInstance().onEmailFailed({
user: req.user,
message_type: 'New user invite',
public_api: false,
});
Logger.error('Failed to send email', {
userId: req.user.id,
inviteAcceptUrl,
domain: baseUrl,
email,
});
resp.error = error.message;
}
}
return resp;
}),
);
await this.externalHooks.run('user.invited', [usersToSetUp]);
Logger.debug(
usersPendingSetup.length > 1
? `Sent ${usersPendingSetup.length} invite emails successfully`
: 'Sent 1 invite email successfully',
{ userShells: createUsers },
);
return emailingResults;
}),
);
/**
* Validate invite token to enable invitee to set up their account.
*
* Authless endpoint.
*/
this.app.get(
`/${this.restEndpoint}/resolve-signup-token`,
ResponseHelper.send(async (req: UserRequest.ResolveSignUp) => {
const { inviterId, inviteeId } = req.query;
if (!inviterId || !inviteeId) {
Logger.debug(
'Request to resolve signup token failed because of missing user IDs in query string',
{ inviterId, inviteeId },
);
throw new ResponseHelper.BadRequestError('Invalid payload');
}
// Postgres validates UUID format
for (const userId of [inviterId, inviteeId]) {
if (!validator.isUUID(userId)) {
Logger.debug('Request to resolve signup token failed because of invalid user ID', {
userId,
});
throw new ResponseHelper.BadRequestError('Invalid userId');
}
}
const users = await Db.collections.User.find({ where: { id: In([inviterId, inviteeId]) } });
if (users.length !== 2) {
Logger.debug(
'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database',
{ inviterId, inviteeId },
);
throw new ResponseHelper.BadRequestError('Invalid invite URL');
}
const invitee = users.find((user) => user.id === inviteeId);
if (!invitee || invitee.password) {
Logger.error('Invalid invite URL - invitee already setup', {
inviterId,
inviteeId,
});
throw new ResponseHelper.BadRequestError(
'The invitation was likely either deleted or already claimed',
);
}
const inviter = users.find((user) => user.id === inviterId);
if (!inviter?.email || !inviter?.firstName) {
Logger.error(
'Request to resolve signup token failed because inviter does not exist or is not set up',
{
inviterId: inviter?.id,
},
);
throw new ResponseHelper.BadRequestError('Invalid request');
}
void InternalHooksManager.getInstance().onUserInviteEmailClick({
inviter,
invitee,
});
const { firstName, lastName } = inviter;
return { inviter: { firstName, lastName } };
}),
);
/**
* Fill out user shell with first name, last name, and password.
*
* Authless endpoint.
*/
this.app.post(
`/${this.restEndpoint}/users/:id`,
ResponseHelper.send(async (req: UserRequest.Update, res: Response) => {
const { id: inviteeId } = req.params;
const { inviterId, firstName, lastName, password } = req.body;
if (!inviterId || !inviteeId || !firstName || !lastName || !password) {
Logger.debug(
'Request to fill out a user shell failed because of missing properties in payload',
{ payload: req.body },
);
throw new ResponseHelper.BadRequestError('Invalid payload');
}
const validPassword = validatePassword(password);
const users = await Db.collections.User.find({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
if (users.length !== 2) {
Logger.debug(
'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database',
{
inviterId,
inviteeId,
},
);
throw new ResponseHelper.BadRequestError('Invalid payload or URL');
}
const invitee = users.find((user) => user.id === inviteeId) as User;
if (invitee.password) {
Logger.debug(
'Request to fill out a user shell failed because the invite had already been accepted',
{ inviteeId },
);
throw new ResponseHelper.BadRequestError('This invite has been accepted already');
}
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = await hashPassword(validPassword);
const updatedUser = await Db.collections.User.save(invitee);
await issueCookie(res, updatedUser);
void InternalHooksManager.getInstance().onUserSignup(updatedUser, {
user_type: 'email',
was_disabled_ldap_user: false,
});
await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return sanitizeUser(updatedUser);
}),
);
this.app.get(
`/${this.restEndpoint}/users`,
ResponseHelper.send(async (req: UserRequest.List) => {
const users = await Db.collections.User.find({ relations: ['globalRole', 'authIdentities'] });
return users.map(
(user): PublicUser =>
addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id),
);
}),
);
/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
this.app.delete(
`/${this.restEndpoint}/users/:id`,
// @ts-ignore
ResponseHelper.send(async (req: UserRequest.Delete) => {
const { id: idToDelete } = req.params;
if (req.user.id === idToDelete) {
Logger.debug(
'Request to delete a user failed because it attempted to delete the requesting user',
{ userId: req.user.id },
);
throw new ResponseHelper.BadRequestError('Cannot delete your own user');
}
const { transferId } = req.query;
if (transferId === idToDelete) {
throw new ResponseHelper.BadRequestError(
'Request to delete a user failed because the user to delete and the transferee are the same user',
);
}
const users = await Db.collections.User.find({
where: { id: In([transferId, idToDelete]) },
});
if (!users.length || (transferId && users.length !== 2)) {
throw new ResponseHelper.NotFoundError(
'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB',
);
}
const userToDelete = users.find((user) => user.id === req.params.id) as User;
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([
RoleService.get({ name: 'owner', scope: 'workflow' }),
RoleService.get({ name: 'owner', scope: 'credential' }),
]);
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await Db.transaction(async (transactionManager) => {
// Get all workflow ids belonging to user to delete
const sharedWorkflowIds = await transactionManager
.getRepository(SharedWorkflow)
.find({
select: ['workflowId'],
where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id },
})
.then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedWorkflow, {
user: transferee,
workflowId: In(sharedWorkflowIds),
});
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete, role: workflowOwnerRole },
{ user: transferee },
);
// Now do the same for creds
// Get all workflow ids belonging to user to delete
const sharedCredentialIds = await transactionManager
.getRepository(SharedCredentials)
.find({
select: ['credentialsId'],
where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id },
})
.then((sharedCredentials) =>
sharedCredentials.map(({ credentialsId }) => credentialsId),
);
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedCredentials, {
user: transferee,
credentialsId: In(sharedCredentialIds),
});
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete, role: credentialOwnerRole },
{ user: transferee },
);
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
// This will remove all shared workflows and credentials not owned
await transactionManager.delete(User, { id: userToDelete.id });
});
void InternalHooksManager.getInstance().onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id },
}),
Db.collections.SharedCredentials.find({
relations: ['credentials'],
where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id },
}),
]);
await Db.transaction(async (transactionManager) => {
const ownedWorkflows = await Promise.all(
ownedSharedWorkflows.map(async ({ workflow }) => {
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowRunner.remove(workflow.id);
}
return workflow;
}),
);
await transactionManager.remove(ownedWorkflows);
await transactionManager.remove(
ownedSharedCredentials.map(({ credentials }) => credentials),
);
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
await transactionManager.delete(User, { id: userToDelete.id });
});
void InternalHooksManager.getInstance().onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}),
);
/**
* Resend email invite to user.
*/
this.app.post(
`/${this.restEndpoint}/users/:id/reinvite`,
ResponseHelper.send(async (req: UserRequest.Reinvite) => {
const { id: idToReinvite } = req.params;
if (!isEmailSetUp()) {
Logger.error('Request to reinvite a user failed because email sending was not set up');
throw new ResponseHelper.InternalServerError(
'Email sending must be set up in order to invite other users',
);
}
const reinvitee = await Db.collections.User.findOneBy({ id: idToReinvite });
if (!reinvitee) {
Logger.debug(
'Request to reinvite a user failed because the ID of the reinvitee was not found in database',
);
throw new ResponseHelper.NotFoundError('Could not find user');
}
if (reinvitee.password) {
Logger.debug(
'Request to reinvite a user failed because the invite had already been accepted',
{ userId: reinvitee.id },
);
throw new ResponseHelper.BadRequestError('User has already accepted the invite');
}
const baseUrl = getInstanceBaseUrl();
const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`;
const mailer = UserManagementMailer.getInstance();
try {
const result = await mailer.invite({
email: reinvitee.email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
void InternalHooksManager.getInstance().onUserReinvite({
user: req.user,
target_user_id: reinvitee.id,
public_api: false,
});
void InternalHooksManager.getInstance().onUserTransactionalEmail({
user_id: reinvitee.id,
message_type: 'Resend invite',
public_api: false,
});
}
} catch (error) {
void InternalHooksManager.getInstance().onEmailFailed({
user: reinvitee,
message_type: 'Resend invite',
public_api: false,
});
Logger.error('Failed to send email', {
email: reinvitee.email,
inviteAcceptUrl,
domain: baseUrl,
});
throw new ResponseHelper.InternalServerError(`Failed to send email to ${reinvitee.email}`);
}
return { success: true };
}),
);
}

View file

@ -9,7 +9,7 @@ import bodyParser from 'body-parser';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import config from '@/config'; import config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { Role } from '@/databases/entities/Role'; import { Role } from '@db/entities/Role';
import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { hashPassword } from '@/UserManagement/UserManagementHelper';
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';

View file

@ -1,4 +1,4 @@
import { User } from '@/databases/entities/User'; import { User } from '@db/entities/User';
import { whereClause } from '@/UserManagement/UserManagementHelper'; import { whereClause } from '@/UserManagement/UserManagementHelper';
import express from 'express'; import express from 'express';
import { LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';

View file

@ -3,7 +3,7 @@ import { DateUtils } from 'typeorm/util/DateUtils';
import * as Db from '@/Db'; import * as Db from '@/Db';
import config from '@/config'; import config from '@/config';
import { CREDENTIALS_REPORT } from '@/audit/constants'; import { CREDENTIALS_REPORT } from '@/audit/constants';
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/audit/types';
async function getAllCredsInUse(workflows: WorkflowEntity[]) { async function getAllCredsInUse(workflows: WorkflowEntity[]) {

View file

@ -5,7 +5,7 @@ import {
DB_QUERY_PARAMS_DOCS_URL, DB_QUERY_PARAMS_DOCS_URL,
SQL_NODE_TYPES_WITH_QUERY_PARAMS, SQL_NODE_TYPES_WITH_QUERY_PARAMS,
} from '@/audit/constants'; } from '@/audit/constants';
import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/audit/types';
function getIssues(workflows: Workflow[]) { function getIssues(workflows: Workflow[]) {

View file

@ -1,6 +1,6 @@
import { getNodeTypes } from '@/audit/utils'; import { getNodeTypes } from '@/audit/utils';
import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants'; import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants';
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/audit/types';
export function reportFilesystemRisk(workflows: WorkflowEntity[]) { export function reportFilesystemRisk(workflows: WorkflowEntity[]) {

View file

@ -11,7 +11,7 @@ import {
WEBHOOK_VALIDATOR_NODE_TYPES, WEBHOOK_VALIDATOR_NODE_TYPES,
} from '@/audit/constants'; } from '@/audit/constants';
import { getN8nPackageJson, inDevelopment } from '@/constants'; import { getN8nPackageJson, inDevelopment } from '@/constants';
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk, n8n } from '@/audit/types'; import type { Risk, n8n } from '@/audit/types';
function getSecuritySettings() { function getSecuritySettings() {

View file

@ -10,7 +10,7 @@ import {
COMMUNITY_NODES_RISKS_URL, COMMUNITY_NODES_RISKS_URL,
NPM_PACKAGE_URL, NPM_PACKAGE_URL,
} from '@/audit/constants'; } from '@/audit/constants';
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/audit/types';
async function getCommunityNodeDetails() { async function getCommunityNodeDetails() {

View file

@ -1,4 +1,4 @@
import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
export namespace Risk { export namespace Risk {
export type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem'; export type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem';

View file

@ -1,4 +1,4 @@
import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity'; import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/audit/types';
type Node = Workflow['nodes'][number]; type Node = Workflow['nodes'][number];

View file

@ -5,7 +5,7 @@ import { Response } from 'express';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { AUTH_COOKIE_NAME } from '@/constants'; import { AUTH_COOKIE_NAME } from '@/constants';
import { JwtPayload, JwtToken } from '../Interfaces'; import { JwtPayload, JwtToken } from '@/Interfaces';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import config from '@/config'; import config from '@/config';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';

View file

@ -0,0 +1,177 @@
import validator from 'validator';
import { Get, Post, RestController } from '@/decorators';
import { AuthError, BadRequestError, InternalServerError } from '@/ResponseHelper';
import { sanitizeUser } from '@/UserManagement/UserManagementHelper';
import { issueCookie, resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME } from '@/constants';
import type { Request, Response } from 'express';
import type { ILogger } from 'n8n-workflow';
import type { User } from '@db/entities/User';
import type { LoginRequest, UserRequest } from '@/requests';
import { In, Repository } from 'typeorm';
import type { Config } from '@/config';
import type { PublicUser, IDatabaseCollections, IInternalHooksClass } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
@RestController()
export class AuthController {
private readonly config: Config;
private readonly logger: ILogger;
private readonly internalHooks: IInternalHooksClass;
private readonly userRepository: Repository<User>;
constructor({
config,
logger,
internalHooks,
repositories,
}: {
config: Config;
logger: ILogger;
internalHooks: IInternalHooksClass;
repositories: Pick<IDatabaseCollections, 'User'>;
}) {
this.config = config;
this.logger = logger;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
}
/**
* Log in a user.
* Authless endpoint.
*/
@Post('/login')
async login(req: LoginRequest, res: Response): Promise<PublicUser> {
const { email, password } = req.body;
if (!email) throw new Error('Email is required to log in');
if (!password) throw new Error('Password is required to log in');
const user =
(await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password));
if (user) {
await issueCookie(res, user);
return sanitizeUser(user);
}
throw new AuthError('Wrong username or password. Do you have caps lock on?');
}
/**
* Manually check the `n8n-auth` cookie.
*/
@Get('/login')
async currentUser(req: Request, res: Response): Promise<PublicUser> {
// Manually check the existing cookie.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
let user: User;
if (cookieContents) {
// If logged in, return user
try {
user = await resolveJwt(cookieContents);
return sanitizeUser(user);
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
}
if (this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
throw new AuthError('Not logged in');
}
try {
user = await this.userRepository.findOneOrFail({
relations: ['globalRole'],
where: {},
});
} catch (error) {
throw new InternalServerError(
'No users found in database - did you wipe the users table? Create at least one user.',
);
}
if (user.email || user.password) {
throw new InternalServerError('Invalid database state - user has password set.');
}
await issueCookie(res, user);
return sanitizeUser(user);
}
/**
* Validate invite token to enable invitee to set up their account.
* Authless endpoint.
*/
@Get('/resolve-signup-token')
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
const { inviterId, inviteeId } = req.query;
if (!inviterId || !inviteeId) {
this.logger.debug(
'Request to resolve signup token failed because of missing user IDs in query string',
{ inviterId, inviteeId },
);
throw new BadRequestError('Invalid payload');
}
// Postgres validates UUID format
for (const userId of [inviterId, inviteeId]) {
if (!validator.isUUID(userId)) {
this.logger.debug('Request to resolve signup token failed because of invalid user ID', {
userId,
});
throw new BadRequestError('Invalid userId');
}
}
const users = await this.userRepository.find({ where: { id: In([inviterId, inviteeId]) } });
if (users.length !== 2) {
this.logger.debug(
'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database',
{ inviterId, inviteeId },
);
throw new BadRequestError('Invalid invite URL');
}
const invitee = users.find((user) => user.id === inviteeId);
if (!invitee || invitee.password) {
this.logger.error('Invalid invite URL - invitee already setup', {
inviterId,
inviteeId,
});
throw new BadRequestError('The invitation was likely either deleted or already claimed');
}
const inviter = users.find((user) => user.id === inviterId);
if (!inviter?.email || !inviter?.firstName) {
this.logger.error(
'Request to resolve signup token failed because inviter does not exist or is not set up',
{
inviterId: inviter?.id,
},
);
throw new BadRequestError('Invalid request');
}
void this.internalHooks.onUserInviteEmailClick({ inviter, invitee });
const { firstName, lastName } = inviter;
return { inviter: { firstName, lastName } };
}
/**
* Log out a user.
* Authless endpoint.
*/
@Post('/logout')
logout(req: Request, res: Response) {
res.clearCookie(AUTH_COOKIE_NAME);
return { loggedOut: true };
}
}

View file

@ -0,0 +1,5 @@
export { AuthController } from './auth.controller';
export { MeController } from './me.controller';
export { OwnerController } from './owner.controller';
export { PasswordResetController } from './passwordReset.controller';
export { UsersController } from './users.controller';

View file

@ -0,0 +1,217 @@
import validator from 'validator';
import { Delete, Get, Patch, Post, RestController } from '@/decorators';
import {
compareHash,
hashPassword,
sanitizeUser,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { BadRequestError } from '@/ResponseHelper';
import { User } from '@db/entities/User';
import { validateEntity } from '@/GenericHelpers';
import { issueCookie } from '@/auth/jwt';
import type { Response } from 'express';
import type { Repository } from 'typeorm';
import type { ILogger } from 'n8n-workflow';
import type { AuthenticatedRequest, MeRequest } from '@/requests';
import type {
PublicUser,
IDatabaseCollections,
IExternalHooksClass,
IInternalHooksClass,
} from '@/Interfaces';
import { randomBytes } from 'crypto';
@RestController('/me')
export class MeController {
private readonly logger: ILogger;
private readonly externalHooks: IExternalHooksClass;
private readonly internalHooks: IInternalHooksClass;
private readonly userRepository: Repository<User>;
constructor({
logger,
externalHooks,
internalHooks,
repositories,
}: {
logger: ILogger;
externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass;
repositories: Pick<IDatabaseCollections, 'User'>;
}) {
this.logger = logger;
this.externalHooks = externalHooks;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
}
/**
* Return the logged-in user.
*/
@Get('/')
async getCurrentUser(req: AuthenticatedRequest): Promise<PublicUser> {
return sanitizeUser(req.user);
}
/**
* Update the logged-in user's settings, except password.
*/
@Patch('/')
async updateCurrentUser(req: MeRequest.Settings, res: Response): Promise<PublicUser> {
const { email } = req.body;
if (!email) {
this.logger.debug('Request to update user email failed because of missing email in payload', {
userId: req.user.id,
payload: req.body,
});
throw new BadRequestError('Email is mandatory');
}
if (!validator.isEmail(email)) {
this.logger.debug('Request to update user email failed because of invalid email in payload', {
userId: req.user.id,
invalidEmail: email,
});
throw new BadRequestError('Invalid email address');
}
const { email: currentEmail } = req.user;
const newUser = new User();
Object.assign(newUser, req.user, req.body);
await validateEntity(newUser);
const user = await this.userRepository.save(newUser);
this.logger.info('User updated successfully', { userId: user.id });
await issueCookie(res, user);
const updatedKeys = Object.keys(req.body);
void this.internalHooks.onUserUpdate({
user,
fields_changed: updatedKeys,
});
await this.externalHooks.run('user.profile.update', [currentEmail, sanitizeUser(user)]);
return sanitizeUser(user);
}
/**
* Update the logged-in user's password.
*/
@Patch('/password')
async updatePassword(req: MeRequest.Password, res: Response) {
const { currentPassword, newPassword } = req.body;
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
throw new BadRequestError('Invalid payload.');
}
if (!req.user.password) {
throw new BadRequestError('Requesting user not set up.');
}
const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password);
if (!isCurrentPwCorrect) {
throw new BadRequestError('Provided current password is incorrect.');
}
const validPassword = validatePassword(newPassword);
req.user.password = await hashPassword(validPassword);
const user = await this.userRepository.save(req.user);
this.logger.info('Password updated successfully', { userId: user.id });
await issueCookie(res, user);
void this.internalHooks.onUserUpdate({
user,
fields_changed: ['password'],
});
await this.externalHooks.run('user.password.update', [user.email, req.user.password]);
return { success: true };
}
/**
* Store the logged-in user's survey answers.
*/
@Post('/survey')
async storeSurveyAnswers(req: MeRequest.SurveyAnswers) {
const { body: personalizationAnswers } = req;
if (!personalizationAnswers) {
this.logger.debug(
'Request to store user personalization survey failed because of empty payload',
{
userId: req.user.id,
},
);
throw new BadRequestError('Personalization answers are mandatory');
}
await this.userRepository.save({
id: req.user.id,
personalizationAnswers,
});
this.logger.info('User survey updated successfully', { userId: req.user.id });
void this.internalHooks.onPersonalizationSurveySubmitted(req.user.id, personalizationAnswers);
return { success: true };
}
/**
* Creates an API Key
*/
@Post('/api-key')
async createAPIKey(req: AuthenticatedRequest) {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;
await this.userRepository.update(req.user.id, {
apiKey,
});
void this.internalHooks.onApiKeyCreated({
user: req.user,
public_api: false,
});
return { apiKey };
}
/**
* Get an API Key
*/
@Get('/api-key')
async getAPIKey(req: AuthenticatedRequest) {
return { apiKey: req.user.apiKey };
}
/**
* Deletes an API Key
*/
@Delete('/api-key')
async deleteAPIKey(req: AuthenticatedRequest) {
await this.userRepository.update(req.user.id, {
apiKey: null,
});
void this.internalHooks.onApiKeyDeleted({
user: req.user,
public_api: false,
});
return { success: true };
}
}

View file

@ -0,0 +1,145 @@
import validator from 'validator';
import { validateEntity } from '@/GenericHelpers';
import { Post, RestController } from '@/decorators';
import { BadRequestError } from '@/ResponseHelper';
import {
hashPassword,
sanitizeUser,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import type { Response } from 'express';
import type { Repository } from 'typeorm';
import type { ILogger } from 'n8n-workflow';
import type { Config } from '@/config';
import type { OwnerRequest } from '@/requests';
import type { IDatabaseCollections, IInternalHooksClass } from '@/Interfaces';
import type { Settings } from '@db/entities/Settings';
import type { User } from '@db/entities/User';
@RestController('/owner')
export class OwnerController {
private readonly config: Config;
private readonly logger: ILogger;
private readonly internalHooks: IInternalHooksClass;
private readonly userRepository: Repository<User>;
private readonly settingsRepository: Repository<Settings>;
constructor({
config,
logger,
internalHooks,
repositories,
}: {
config: Config;
logger: ILogger;
internalHooks: IInternalHooksClass;
repositories: Pick<IDatabaseCollections, 'User' | 'Settings'>;
}) {
this.config = config;
this.logger = logger;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
this.settingsRepository = repositories.Settings;
}
/**
* Promote a shell into the owner of the n8n instance,
* and enable `isInstanceOwnerSetUp` setting.
*/
@Post('/')
async promoteOwner(req: OwnerRequest.Post, res: Response) {
const { email, firstName, lastName, password } = req.body;
const { id: userId } = req.user;
if (this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to claim instance ownership failed because instance owner already exists',
{
userId,
},
);
throw new BadRequestError('Invalid request');
}
if (!email || !validator.isEmail(email)) {
this.logger.debug('Request to claim instance ownership failed because of invalid email', {
userId,
invalidEmail: email,
});
throw new BadRequestError('Invalid email address');
}
const validPassword = validatePassword(password);
if (!firstName || !lastName) {
this.logger.debug(
'Request to claim instance ownership failed because of missing first name or last name in payload',
{ userId, payload: req.body },
);
throw new BadRequestError('First and last names are mandatory');
}
let owner = await this.userRepository.findOne({
relations: ['globalRole'],
where: { id: userId },
});
if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) {
this.logger.debug(
'Request to claim instance ownership failed because user shell does not exist or has wrong role!',
{
userId,
},
);
throw new BadRequestError('Invalid request');
}
Object.assign(owner, {
email,
firstName,
lastName,
password: await hashPassword(validPassword),
});
await validateEntity(owner);
owner = await this.userRepository.save(owner);
this.logger.info('Owner was set up successfully', { userId: req.user.id });
await this.settingsRepository.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
this.config.set('userManagement.isInstanceOwnerSetUp', true);
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id });
await issueCookie(res, owner);
void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });
return sanitizeUser(owner);
}
/**
* Persist that the instance owner setup has been skipped
*/
@Post('/skip-setup')
async skipSetup() {
await this.settingsRepository.update(
{ key: 'userManagement.skipInstanceOwnerSetup' },
{ value: JSON.stringify(true) },
);
this.config.set('userManagement.skipInstanceOwnerSetup', true);
return { success: true };
}
}

View file

@ -0,0 +1,268 @@
import { IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import validator from 'validator';
import { Get, Post, RestController } from '@/decorators';
import {
BadRequestError,
InternalServerError,
NotFoundError,
UnprocessableRequestError,
} from '@/ResponseHelper';
import {
getInstanceBaseUrl,
hashPassword,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import * as UserManagementMailer from '@/UserManagement/email';
import type { Response } from 'express';
import type { ILogger } from 'n8n-workflow';
import type { Config } from '@/config';
import type { User } from '@db/entities/User';
import type { PasswordResetRequest } from '@/requests';
import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import { issueCookie } from '@/auth/jwt';
import { isLdapEnabled } from '@/Ldap/helpers';
@RestController()
export class PasswordResetController {
private readonly config: Config;
private readonly logger: ILogger;
private readonly externalHooks: IExternalHooksClass;
private readonly internalHooks: IInternalHooksClass;
private readonly userRepository: Repository<User>;
constructor({
config,
logger,
externalHooks,
internalHooks,
repositories,
}: {
config: Config;
logger: ILogger;
externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass;
repositories: Pick<IDatabaseCollections, 'User'>;
}) {
this.config = config;
this.logger = logger;
this.externalHooks = externalHooks;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
}
/**
* Send a password reset email.
* Authless endpoint.
*/
@Post('/forgot-password')
async forgotPassword(req: PasswordResetRequest.Email) {
if (this.config.getEnv('userManagement.emails.mode') === '') {
this.logger.debug(
'Request to send password reset email failed because emailing was not set up',
);
throw new InternalServerError(
'Email sending must be set up in order to request a password reset email',
);
}
const { email } = req.body;
if (!email) {
this.logger.debug(
'Request to send password reset email failed because of missing email in payload',
{ payload: req.body },
);
throw new BadRequestError('Email is mandatory');
}
if (!validator.isEmail(email)) {
this.logger.debug(
'Request to send password reset email failed because of invalid email in payload',
{ invalidEmail: email },
);
throw new BadRequestError('Invalid email address');
}
// User should just be able to reset password if one is already present
const user = await this.userRepository.findOne({
where: {
email,
password: Not(IsNull()),
},
relations: ['authIdentities'],
});
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
if (!user?.password || (ldapIdentity && user.disabled)) {
this.logger.debug(
'Request to send password reset email failed because no user was found for the provided email',
{ invalidEmail: email },
);
return;
}
if (isLdapEnabled() && ldapIdentity) {
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
}
user.resetPasswordToken = uuid();
const { id, firstName, lastName, resetPasswordToken } = user;
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;
await this.userRepository.update(id, { resetPasswordToken, resetPasswordTokenExpiration });
const baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`);
url.searchParams.append('userId', id);
url.searchParams.append('token', resetPasswordToken);
try {
const mailer = UserManagementMailer.getInstance();
await mailer.passwordReset({
email,
firstName,
lastName,
passwordResetUrl: url.toString(),
domain: baseUrl,
});
} catch (error) {
void this.internalHooks.onEmailFailed({
user,
message_type: 'Reset password',
public_api: false,
});
if (error instanceof Error) {
throw new InternalServerError(`Please contact your administrator: ${error.message}`);
}
}
this.logger.info('Sent password reset email successfully', { userId: user.id, email });
void this.internalHooks.onUserTransactionalEmail({
user_id: id,
message_type: 'Reset password',
public_api: false,
});
void this.internalHooks.onUserPasswordResetRequestClick({ user });
}
/**
* Verify password reset token and user ID.
* Authless endpoint.
*/
@Get('/resolve-password-token')
async resolvePasswordToken(req: PasswordResetRequest.Credentials) {
const { token: resetPasswordToken, userId: id } = req.query;
if (!resetPasswordToken || !id) {
this.logger.debug(
'Request to resolve password token failed because of missing password reset token or user ID in query string',
{
queryString: req.query,
},
);
throw new BadRequestError('');
}
// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await this.userRepository.findOneBy({
id,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
});
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token',
{
userId: id,
resetPasswordToken,
},
);
throw new NotFoundError('');
}
this.logger.info('Reset-password token resolved successfully', { userId: id });
void this.internalHooks.onUserPasswordResetEmailClick({ user });
}
/**
* Verify password reset token and user ID and update password.
* Authless endpoint.
*/
@Post('/change-password')
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token: resetPasswordToken, userId, password } = req.body;
if (!resetPasswordToken || !userId || !password) {
this.logger.debug(
'Request to change password failed because of missing user ID or password or reset password token in payload',
{
payload: req.body,
},
);
throw new BadRequestError('Missing user ID or password or reset password token');
}
const validPassword = validatePassword(password);
// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await this.userRepository.findOne({
where: {
id: userId,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
},
relations: ['authIdentities'],
});
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token',
{
userId,
resetPasswordToken,
},
);
throw new NotFoundError('');
}
await this.userRepository.update(userId, {
password: await hashPassword(validPassword),
resetPasswordToken: null,
resetPasswordTokenExpiration: null,
});
this.logger.info('User password updated successfully', { userId });
await issueCookie(res, user);
void this.internalHooks.onUserUpdate({
user,
fields_changed: ['password'],
});
// if this user used to be an LDAP users
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
if (ldapIdentity) {
void this.internalHooks.onUserSignup(user, {
user_type: 'email',
was_disabled_ldap_user: true,
});
}
await this.externalHooks.run('user.password.update', [user.email, password]);
}
}

View file

@ -0,0 +1,562 @@
import validator from 'validator';
import { In, Repository } from 'typeorm';
import { ErrorReporterProxy as ErrorReporter, ILogger } from 'n8n-workflow';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Delete, Get, Post, RestController } from '@/decorators';
import {
addInviteLinkToUser,
generateUserInviteUrl,
getInstanceBaseUrl,
hashPassword,
isEmailSetUp,
isUserManagementDisabled,
sanitizeUser,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper';
import type { Response } from 'express';
import type { Config } from '@/config';
import type { UserRequest } from '@/requests';
import type { UserManagementMailer } from '@/UserManagement/email';
import type { Role } from '@db/entities/Role';
import type {
PublicUser,
IDatabaseCollections,
IExternalHooksClass,
IInternalHooksClass,
ITelemetryUserDeletionData,
} from '@/Interfaces';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity';
@RestController('/users')
export class UsersController {
private config: Config;
private logger: ILogger;
private externalHooks: IExternalHooksClass;
private internalHooks: IInternalHooksClass;
private userRepository: Repository<User>;
private roleRepository: Repository<Role>;
private sharedCredentialsRepository: Repository<SharedCredentials>;
private sharedWorkflowRepository: Repository<SharedWorkflow>;
private activeWorkflowRunner: ActiveWorkflowRunner;
private mailer: UserManagementMailer;
constructor({
config,
logger,
externalHooks,
internalHooks,
repositories,
activeWorkflowRunner,
mailer,
}: {
config: Config;
logger: ILogger;
externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass;
repositories: Pick<
IDatabaseCollections,
'User' | 'Role' | 'SharedCredentials' | 'SharedWorkflow'
>;
activeWorkflowRunner: ActiveWorkflowRunner;
mailer: UserManagementMailer;
}) {
this.config = config;
this.logger = logger;
this.externalHooks = externalHooks;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
this.roleRepository = repositories.Role;
this.sharedCredentialsRepository = repositories.SharedCredentials;
this.sharedWorkflowRepository = repositories.SharedWorkflow;
this.activeWorkflowRunner = activeWorkflowRunner;
this.mailer = mailer;
}
/**
* Send email invite(s) to one or multiple users and create user shell(s).
*/
@Post('/')
async sendEmailInvites(req: UserRequest.Invite) {
// TODO: this should be checked in the middleware rather than here
if (isUserManagementDisabled()) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because user management is disabled',
);
throw new BadRequestError('User management is disabled');
}
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
);
throw new BadRequestError('You must set up your own account before inviting others');
}
if (!Array.isArray(req.body)) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the payload is not an array',
{
payload: req.body,
},
);
throw new BadRequestError('Invalid payload');
}
if (!req.body.length) return [];
const createUsers: { [key: string]: string | null } = {};
// Validate payload
req.body.forEach((invite) => {
if (typeof invite !== 'object' || !invite.email) {
throw new BadRequestError(
'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>',
);
}
if (!validator.isEmail(invite.email)) {
this.logger.debug('Invalid email in payload', { invalidEmail: invite.email });
throw new BadRequestError(
`Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`,
);
}
createUsers[invite.email.toLowerCase()] = null;
});
const role = await this.roleRepository.findOneBy({ scope: 'global', name: 'member' });
if (!role) {
this.logger.error(
'Request to send email invite(s) to user(s) failed because no global member role was found in database',
);
throw new InternalServerError('Members role not found in database - inconsistent state');
}
// remove/exclude existing users from creation
const existingUsers = await this.userRepository.find({
where: { email: In(Object.keys(createUsers)) },
});
existingUsers.forEach((user) => {
if (user.password) {
delete createUsers[user.email];
return;
}
createUsers[user.email] = user.id;
});
const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null);
const total = usersToSetUp.length;
this.logger.debug(total > 1 ? `Creating ${total} user shells...` : 'Creating 1 user shell...');
try {
await this.userRepository.manager.transaction(async (transactionManager) =>
Promise.all(
usersToSetUp.map(async (email) => {
const newUser = Object.assign(new User(), {
email,
globalRole: role,
});
const savedUser = await transactionManager.save<User>(newUser);
createUsers[savedUser.email] = savedUser.id;
return savedUser;
}),
),
);
} catch (error) {
ErrorReporter.error(error);
this.logger.error('Failed to create user shells', { userShells: createUsers });
throw new InternalServerError('An error occurred during user creation');
}
this.logger.debug('Created user shell(s) successfully', { userId: req.user.id });
this.logger.verbose(total > 1 ? `${total} user shells created` : '1 user shell created', {
userShells: createUsers,
});
const baseUrl = getInstanceBaseUrl();
const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email);
// send invite email to new or not yet setup users
const emailingResults = await Promise.all(
usersPendingSetup.map(async ([email, id]) => {
if (!id) {
// This should never happen since those are removed from the list before reaching this point
throw new InternalServerError('User ID is missing for user with email address');
}
const inviteAcceptUrl = generateUserInviteUrl(req.user.id, id);
const resp: {
user: { id: string | null; email: string; inviteAcceptUrl: string; emailSent: boolean };
error?: string;
} = {
user: {
id,
email,
inviteAcceptUrl,
emailSent: false,
},
};
try {
const result = await this.mailer.invite({
email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
resp.user.emailSent = true;
void this.internalHooks.onUserTransactionalEmail({
user_id: id,
message_type: 'New user invite',
public_api: false,
});
}
void this.internalHooks.onUserInvite({
user: req.user,
target_user_id: Object.values(createUsers) as string[],
public_api: false,
email_sent: result.emailSent,
});
} catch (error) {
if (error instanceof Error) {
void this.internalHooks.onEmailFailed({
user: req.user,
message_type: 'New user invite',
public_api: false,
});
this.logger.error('Failed to send email', {
userId: req.user.id,
inviteAcceptUrl,
domain: baseUrl,
email,
});
resp.error = error.message;
}
}
return resp;
}),
);
await this.externalHooks.run('user.invited', [usersToSetUp]);
this.logger.debug(
usersPendingSetup.length > 1
? `Sent ${usersPendingSetup.length} invite emails successfully`
: 'Sent 1 invite email successfully',
{ userShells: createUsers },
);
return emailingResults;
}
/**
* Fill out user shell with first name, last name, and password.
*/
@Post('/:id')
async updateUser(req: UserRequest.Update, res: Response) {
const { id: inviteeId } = req.params;
const { inviterId, firstName, lastName, password } = req.body;
if (!inviterId || !inviteeId || !firstName || !lastName || !password) {
this.logger.debug(
'Request to fill out a user shell failed because of missing properties in payload',
{ payload: req.body },
);
throw new BadRequestError('Invalid payload');
}
const validPassword = validatePassword(password);
const users = await this.userRepository.find({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
if (users.length !== 2) {
this.logger.debug(
'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database',
{
inviterId,
inviteeId,
},
);
throw new BadRequestError('Invalid payload or URL');
}
const invitee = users.find((user) => user.id === inviteeId) as User;
if (invitee.password) {
this.logger.debug(
'Request to fill out a user shell failed because the invite had already been accepted',
{ inviteeId },
);
throw new BadRequestError('This invite has been accepted already');
}
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = await hashPassword(validPassword);
const updatedUser = await this.userRepository.save(invitee);
await issueCookie(res, updatedUser);
void this.internalHooks.onUserSignup(updatedUser, {
user_type: 'email',
was_disabled_ldap_user: false,
});
await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return sanitizeUser(updatedUser);
}
@Get('/')
async listUsers(req: UserRequest.List) {
const users = await this.userRepository.find({ relations: ['globalRole', 'authIdentities'] });
return users.map(
(user): PublicUser =>
addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id),
);
}
/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
@Delete('/:id')
async deleteUser(req: UserRequest.Delete) {
const { id: idToDelete } = req.params;
if (req.user.id === idToDelete) {
this.logger.debug(
'Request to delete a user failed because it attempted to delete the requesting user',
{ userId: req.user.id },
);
throw new BadRequestError('Cannot delete your own user');
}
const { transferId } = req.query;
if (transferId === idToDelete) {
throw new BadRequestError(
'Request to delete a user failed because the user to delete and the transferee are the same user',
);
}
const users = await this.userRepository.find({
where: { id: In([transferId, idToDelete]) },
});
if (!users.length || (transferId && users.length !== 2)) {
throw new NotFoundError(
'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB',
);
}
const userToDelete = users.find((user) => user.id === req.params.id) as User;
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([
this.roleRepository.findOneBy({ name: 'owner', scope: 'workflow' }),
this.roleRepository.findOneBy({ name: 'owner', scope: 'credential' }),
]);
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await this.userRepository.manager.transaction(async (transactionManager) => {
// Get all workflow ids belonging to user to delete
const sharedWorkflowIds = await transactionManager
.getRepository(SharedWorkflow)
.find({
select: ['workflowId'],
where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id },
})
.then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedWorkflow, {
user: transferee,
workflowId: In(sharedWorkflowIds),
});
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete, role: workflowOwnerRole },
{ user: transferee },
);
// Now do the same for creds
// Get all workflow ids belonging to user to delete
const sharedCredentialIds = await transactionManager
.getRepository(SharedCredentials)
.find({
select: ['credentialsId'],
where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id },
})
.then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedCredentials, {
user: transferee,
credentialsId: In(sharedCredentialIds),
});
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete, role: credentialOwnerRole },
{ user: transferee },
);
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
// This will remove all shared workflows and credentials not owned
await transactionManager.delete(User, { id: userToDelete.id });
});
void this.internalHooks.onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
this.sharedWorkflowRepository.find({
relations: ['workflow'],
where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id },
}),
this.sharedCredentialsRepository.find({
relations: ['credentials'],
where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id },
}),
]);
await this.userRepository.manager.transaction(async (transactionManager) => {
const ownedWorkflows = await Promise.all(
ownedSharedWorkflows.map(async ({ workflow }) => {
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowRunner.remove(workflow.id);
}
return workflow;
}),
);
await transactionManager.remove(ownedWorkflows);
await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials));
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
await transactionManager.delete(User, { id: userToDelete.id });
});
void this.internalHooks.onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
/**
* Resend email invite to user.
*/
@Post('/:id/reinvite')
async reinviteUser(req: UserRequest.Reinvite) {
const { id: idToReinvite } = req.params;
if (!isEmailSetUp()) {
this.logger.error('Request to reinvite a user failed because email sending was not set up');
throw new InternalServerError('Email sending must be set up in order to invite other users');
}
const reinvitee = await this.userRepository.findOneBy({ id: idToReinvite });
if (!reinvitee) {
this.logger.debug(
'Request to reinvite a user failed because the ID of the reinvitee was not found in database',
);
throw new NotFoundError('Could not find user');
}
if (reinvitee.password) {
this.logger.debug(
'Request to reinvite a user failed because the invite had already been accepted',
{ userId: reinvitee.id },
);
throw new BadRequestError('User has already accepted the invite');
}
const baseUrl = getInstanceBaseUrl();
const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`;
try {
const result = await this.mailer.invite({
email: reinvitee.email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
void this.internalHooks.onUserReinvite({
user: req.user,
target_user_id: reinvitee.id,
public_api: false,
});
void this.internalHooks.onUserTransactionalEmail({
user_id: reinvitee.id,
message_type: 'Resend invite',
public_api: false,
});
}
} catch (error) {
void this.internalHooks.onEmailFailed({
user: reinvitee,
message_type: 'Resend invite',
public_api: false,
});
this.logger.error('Failed to send email', {
email: reinvitee.email,
inviteAcceptUrl,
domain: baseUrl,
});
throw new InternalServerError(`Failed to send email to ${reinvitee.email}`);
}
return { success: true };
}
}

View file

@ -1,7 +1,7 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config'; import config from '@/config';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics';
export class RemoveWorkflowDataLoadedFlag1671726148420 implements MigrationInterface { export class RemoveWorkflowDataLoadedFlag1671726148420 implements MigrationInterface {
name = 'RemoveWorkflowDataLoadedFlag1671726148420'; name = 'RemoveWorkflowDataLoadedFlag1671726148420';

View file

@ -1,5 +1,5 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix } from '@/databases/utils/migrationHelpers'; import { getTablePrefix } from '@db/utils/migrationHelpers';
export class CreateIndexStoppedAt1594828256133 implements MigrationInterface { export class CreateIndexStoppedAt1594828256133 implements MigrationInterface {
name = 'CreateIndexStoppedAt1594828256133'; name = 'CreateIndexStoppedAt1594828256133';

View file

@ -1,6 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics';
export class RemoveWorkflowDataLoadedFlag1671726148421 implements MigrationInterface { export class RemoveWorkflowDataLoadedFlag1671726148421 implements MigrationInterface {
name = 'RemoveWorkflowDataLoadedFlag1671726148421'; name = 'RemoveWorkflowDataLoadedFlag1671726148421';

View file

@ -2,7 +2,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config'; import config from '@/config';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics';
export class RemoveWorkflowDataLoadedFlag1671726148419 implements MigrationInterface { export class RemoveWorkflowDataLoadedFlag1671726148419 implements MigrationInterface {
name = 'RemoveWorkflowDataLoadedFlag1671726148419'; name = 'RemoveWorkflowDataLoadedFlag1671726148419';

View file

@ -0,0 +1,8 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { CONTROLLER_BASE_PATH } from './constants';
export const RestController =
(basePath: `/${string}` = '/'): ClassDecorator =>
(target: object) => {
Reflect.defineMetadata(CONTROLLER_BASE_PATH, basePath, target);
};

View file

@ -0,0 +1,19 @@
import { CONTROLLER_ROUTES } from './constants';
import type { Method, RouteMetadata } from './types';
/* eslint-disable @typescript-eslint/naming-convention */
const RouteFactory =
(method: Method) =>
(path: `/${string}`): MethodDecorator =>
(target, handlerName) => {
const controllerClass = target.constructor;
const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ??
[]) as RouteMetadata[];
routes.push({ method, path, handlerName: String(handlerName) });
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
};
export const Get = RouteFactory('get');
export const Post = RouteFactory('post');
export const Patch = RouteFactory('patch');
export const Delete = RouteFactory('delete');

View file

@ -0,0 +1,2 @@
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH';

View file

@ -0,0 +1,3 @@
export { RestController } from './RestController';
export { Get, Post, Patch, Delete } from './Route';
export { registerController } from './registerController';

View file

@ -0,0 +1,34 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Router } from 'express';
import type { Config } from '@/config';
import { CONTROLLER_BASE_PATH, CONTROLLER_ROUTES } from './constants';
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
import type { Application, Request, Response } from 'express';
import type { Controller, RouteMetadata } from './types';
export const registerController = (app: Application, config: Config, controller: object) => {
const controllerClass = controller.constructor;
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
| string
| undefined;
if (!controllerBasePath)
throw new Error(`${controllerClass.name} is missing the RestController decorator`);
const routes = Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) as RouteMetadata[];
if (routes.length > 0) {
const router = Router({ mergeParams: true });
const restBasePath = config.getEnv('endpoints.rest');
const prefix = `/${[restBasePath, controllerBasePath].join('/')}`.replace(/\/+/g, '/');
routes.forEach(({ method, path, handlerName }) => {
router[method](
path,
send(async (req: Request, res: Response) =>
(controller as Controller)[handlerName](req, res),
),
);
});
app.use(prefix, router);
}
};

View file

@ -0,0 +1,12 @@
import type { Request, Response } from 'express';
export type Method = 'get' | 'post' | 'patch' | 'delete';
export interface RouteMetadata {
method: Method;
path: string;
handlerName: string;
}
type RequestHandler = (req?: Request, res?: Response) => Promise<unknown>;
export type Controller = Record<RouteMetadata['handlerName'], RequestHandler>;

View file

@ -1,12 +1,12 @@
/* eslint-disable import/no-cycle */ /* eslint-disable import/no-cycle */
import type { EventDestinations } from '@/databases/entities/MessageEventBusDestinationEntity'; import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity';
import { promClient } from '@/metrics'; import { promClient } from '@/metrics';
import { import {
EventMessageTypeNames, EventMessageTypeNames,
LoggerProxy, LoggerProxy,
MessageEventBusDestinationTypeNames, MessageEventBusDestinationTypeNames,
} from 'n8n-workflow'; } from 'n8n-workflow';
import config from '../../config'; import config from '@/config';
import type { EventMessageTypes } from '../EventMessageClasses'; import type { EventMessageTypes } from '../EventMessageClasses';
import type { MessageEventBusDestination } from './MessageEventBusDestination.ee'; import type { MessageEventBusDestination } from './MessageEventBusDestination.ee';
import { MessageEventBusDestinationSentry } from './MessageEventBusDestinationSentry.ee'; import { MessageEventBusDestinationSentry } from './MessageEventBusDestinationSentry.ee';

View file

@ -18,10 +18,10 @@ import {
MessageEventBusDestinationWebhookParameterItem, MessageEventBusDestinationWebhookParameterItem,
MessageEventBusDestinationWebhookParameterOptions, MessageEventBusDestinationWebhookParameterOptions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CredentialsHelper } from '../../CredentialsHelper'; import { CredentialsHelper } from '@/CredentialsHelper';
import { UserSettings } from 'n8n-core'; import { UserSettings } from 'n8n-core';
import { Agent as HTTPSAgent } from 'https'; import { Agent as HTTPSAgent } from 'https';
import config from '../../config'; import config from '@/config';
import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'; import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper';
import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric'; import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric';

View file

@ -1,7 +1,7 @@
import { INode, IRun, IWorkflowBase } from 'n8n-workflow'; import { INode, IRun, IWorkflowBase } from 'n8n-workflow';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { InternalHooksManager } from '@/InternalHooksManager'; import { InternalHooksManager } from '@/InternalHooksManager';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';

View file

@ -1,4 +1,4 @@
import { User } from '@/databases/entities/User'; import { User } from '@db/entities/User';
import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { ExecutionsService } from './executions.service'; import { ExecutionsService } from './executions.service';

View file

@ -15,7 +15,7 @@ import {
import { FindOperator, FindOptionsWhere, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm'; import { FindOperator, FindOptionsWhere, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm';
import * as ActiveExecutions from '@/ActiveExecutions'; import * as ActiveExecutions from '@/ActiveExecutions';
import config from '@/config'; import config from '@/config';
import type { User } from '@/databases/entities/User'; import type { User } from '@db/entities/User';
import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
import { import {
IExecutionFlattedResponse, IExecutionFlattedResponse,

View file

@ -1,32 +1,80 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ import type { Application, NextFunction, Request, RequestHandler, Response } from 'express';
/* eslint-disable @typescript-eslint/no-unsafe-call */ import jwt from 'jsonwebtoken';
/* eslint-disable @typescript-eslint/no-unsafe-return */
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import passport from 'passport'; import passport from 'passport';
import { NextFunction, Request, Response } from 'express'; import { Strategy } from 'passport-jwt';
import { LoggerProxy as Logger } from 'n8n-workflow'; import { LoggerProxy as Logger } from 'n8n-workflow';
import { N8nApp } from '../Interfaces'; import { JwtPayload } from '@/Interfaces';
import { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import { issueCookie, resolveJwtContent } from '@/auth/jwt';
import { import {
isAuthenticatedRequest,
isAuthExcluded, isAuthExcluded,
isPostUsersId, isPostUsersId,
isAuthenticatedRequest,
isUserManagementDisabled, isUserManagementDisabled,
} from '../UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import * as Db from '@/Db'; import type { Repository } from 'typeorm';
import { jwtAuth, refreshExpiringCookie } from '../middlewares'; import type { User } from '@db/entities/User';
import { authenticationMethods } from './auth';
import { meNamespace } from './me';
import { usersNamespace } from './users';
import { passwordResetNamespace } from './passwordReset';
import { ownerNamespace } from './owner';
export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { const jwtFromRequest = (req: Request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null;
};
const jwtAuth = (): RequestHandler => {
const jwtStrategy = new Strategy(
{
jwtFromRequest,
secretOrKey: config.getEnv('userManagement.jwtSecret'),
},
async (jwtPayload: JwtPayload, done) => {
try {
const user = await resolveJwtContent(jwtPayload);
return done(null, user);
} catch (error) {
Logger.debug('Failed to extract user from JWT payload', { jwtPayload });
return done(null, false, { message: 'User not found' });
}
},
);
passport.use(jwtStrategy);
return passport.initialize();
};
/**
* middleware to refresh cookie before it expires
*/
const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, res, next) => {
const cookieAuth = jwtFromRequest(req);
if (cookieAuth && req.user) {
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
if (cookieContents.exp * 1000 - Date.now() < 259200000) {
// if cookie expires in < 3 days, renew it.
await issueCookie(res, req.user);
}
}
next();
};
const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler;
/**
* This sets up the auth middlewares in the correct order
*/
export const setupAuthMiddlewares = (
app: Application,
ignoredEndpoints: Readonly<string[]>,
restEndpoint: string,
userRepository: Repository<User>,
) => {
// needed for testing; not adding overhead since it directly returns if req.cookies exists // needed for testing; not adding overhead since it directly returns if req.cookies exists
this.app.use(cookieParser()); app.use(cookieParser());
this.app.use(jwtAuth()); app.use(jwtAuth());
this.app.use(async (req: Request, res: Response, next: NextFunction) => { app.use(async (req: Request, res: Response, next: NextFunction) => {
if ( if (
// TODO: refactor me!!! // TODO: refactor me!!!
// skip authentication for preflight requests // skip authentication for preflight requests
@ -54,17 +102,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
// skip authentication if user management is disabled // skip authentication if user management is disabled
if (isUserManagementDisabled()) { if (isUserManagementDisabled()) {
req.user = await Db.collections.User.findOneOrFail({ req.user = await userRepository.findOneOrFail({
relations: ['globalRole'], relations: ['globalRole'],
where: {}, where: {},
}); });
return next(); return next();
} }
return passport.authenticate('jwt', { session: false })(req, res, next); return passportMiddleware(req, res, next);
}); });
this.app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => { app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => {
// req.user is empty for public routes, so just proceed // req.user is empty for public routes, so just proceed
// owner can do anything, so proceed as well // owner can do anything, so proceed as well
if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) { if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) {
@ -73,17 +121,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
} }
// Not owner and user exists. We now protect restricted urls. // Not owner and user exists. We now protect restricted urls.
const postRestrictedUrls = [ const postRestrictedUrls = [
`/${this.restEndpoint}/users`, `/${restEndpoint}/users`,
`/${this.restEndpoint}/owner`, `/${restEndpoint}/owner`,
`/${this.restEndpoint}/ldap/sync`, `/${restEndpoint}/ldap/sync`,
`/${this.restEndpoint}/ldap/test-connection`, `/${restEndpoint}/ldap/test-connection`,
]; ];
const getRestrictedUrls = [ const getRestrictedUrls = [
`/${this.restEndpoint}/users`, `/${restEndpoint}/users`,
`/${this.restEndpoint}/ldap/sync`, `/${restEndpoint}/ldap/sync`,
`/${this.restEndpoint}/ldap/config`, `/${restEndpoint}/ldap/config`,
]; ];
const putRestrictedUrls = [`/${this.restEndpoint}/ldap/config`]; const putRestrictedUrls = [`/${restEndpoint}/ldap/config`];
const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url;
if ( if (
(req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) ||
@ -106,11 +154,5 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
next(); next();
}); });
this.app.use(refreshExpiringCookie); app.use(refreshExpiringCookie);
};
authenticationMethods.apply(this);
ownerNamespace.apply(this);
meNamespace.apply(this);
passwordResetNamespace.apply(this);
usersNamespace.apply(this);
}

View file

@ -1 +1,2 @@
export * from './auth'; export * from './auth';
export * from './cors';

View file

@ -10,11 +10,10 @@ import {
IWorkflowSettings, IWorkflowSettings,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
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 * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
import type { PublicUser } from '@/UserManagement/Interfaces';
export type AuthlessRequest< export type AuthlessRequest<
RouteParams = {}, RouteParams = {},

View file

@ -5,8 +5,8 @@ import * as Db from '@/Db';
import { toReportTitle } from '@/audit/utils'; import { toReportTitle } from '@/audit/utils';
import * as constants from '@/constants'; import * as constants from '@/constants';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/audit/types';
import type { InstalledNodes } from '@/databases/entities/InstalledNodes'; import type { InstalledNodes } from '@db/entities/InstalledNodes';
import type { InstalledPackages } from '@/databases/entities/InstalledPackages'; import type { InstalledPackages } from '@db/entities/InstalledPackages';
type GetSectionKind<C extends Risk.Category> = C extends 'instance' type GetSectionKind<C extends Risk.Category> = C extends 'instance'
? Risk.InstanceSection ? Risk.InstanceSection

View file

@ -16,16 +16,12 @@ let globalMemberRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['auth'] });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
}); });
beforeEach(async () => { beforeEach(async () => {
@ -276,6 +272,60 @@ test('GET /login should return logged-in member', async () => {
expect(authToken).toBeUndefined(); expect(authToken).toBeUndefined();
}); });
test('GET /resolve-signup-token should validate invite token', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const memberShell = await testDb.createUserShell(globalMemberRole);
const response = await authAgent(owner)
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId: memberShell.id });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
data: {
inviter: {
firstName: owner.firstName,
lastName: owner.lastName,
},
},
});
});
test('GET /resolve-signup-token should fail with invalid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = authAgent(owner);
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id });
const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId });
const third = await authOwnerAgent.get('/resolve-signup-token').query({
inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d',
inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d',
});
// user is already set up, so call should error
const fourth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId });
// cause inconsistent DB state
await Db.collections.User.update(owner.id, { email: '' });
const fifth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId });
for (const response of [first, second, third, fourth, fifth]) {
expect(response.statusCode).toBe(400);
}
});
test('POST /logout should log user out', async () => { test('POST /logout should log user out', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });

View file

@ -16,18 +16,11 @@ let globalMemberRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] });
applyAuth: true,
endpointGroups: ['me', 'auth', 'owner', 'users'],
});
await testDb.init();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
}); });
afterAll(async () => { afterAll(async () => {

View file

@ -1,16 +1,11 @@
import express from 'express';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { Reset } from '@/commands/user-management/reset'; import { Reset } from '@/commands/user-management/reset';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import * as utils from '../shared/utils';
import * as testDb from '../shared/testDb'; import * as testDb from '../shared/testDb';
let app: express.Application;
let globalOwnerRole: Role; let globalOwnerRole: Role;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
await testDb.init(); await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();

View file

@ -22,11 +22,7 @@ let authAgent: AuthAgent;
let sharingSpy: jest.SpyInstance<boolean>; let sharingSpy: jest.SpyInstance<boolean>;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({ endpointGroups: ['credentials'] });
endpointGroups: ['credentials'],
applyAuth: true,
});
await testDb.init();
utils.initConfigFile(); utils.initConfigFile();
@ -37,9 +33,6 @@ beforeAll(async () => {
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
}); });

View file

@ -25,11 +25,7 @@ let saveCredential: SaveCredentialFunction;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({ endpointGroups: ['credentials'] });
endpointGroups: ['credentials'],
applyAuth: true,
});
await testDb.init();
utils.initConfigFile(); utils.initConfigFile();
@ -40,9 +36,6 @@ beforeAll(async () => {
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -81,12 +81,11 @@ async function confirmIdSent(id: string) {
} }
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); app = await utils.initTestServer({ endpointGroups: ['eventBus'] });
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
owner = await testDb.createUser({ globalRole: globalOwnerRole }); owner = await testDb.createUser({ globalRole: globalOwnerRole });
app = await utils.initTestServer({ endpointGroups: ['eventBus'], applyAuth: true });
unAuthOwnerAgent = utils.createAgent(app, { unAuthOwnerAgent = utils.createAgent(app, {
apiPath: 'internal', apiPath: 'internal',
auth: false, auth: false,
@ -104,7 +103,6 @@ beforeAll(async () => {
mockedSyslog.createClient.mockImplementation(() => new syslog.Client()); mockedSyslog.createClient.mockImplementation(() => new syslog.Client());
utils.initConfigFile(); utils.initConfigFile();
utils.initTestLogger();
config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter'); config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter');
config.set('eventBus.logWriter.keepLogCount', '1'); config.set('eventBus.logWriter.keepLogCount', '1');
config.set('enterprise.features.logStreaming', true); config.set('enterprise.features.logStreaming', true);

View file

@ -40,8 +40,7 @@ const defaultLdapConfig = {
}; };
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'] });
app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'], applyAuth: true });
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles(); const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles();
@ -56,8 +55,6 @@ beforeAll(async () => {
); );
utils.initConfigFile(); utils.initConfigFile();
utils.initTestLogger();
utils.initTestTelemetry();
await utils.initLdapManager(); await utils.initLdapManager();
}); });

View file

@ -19,17 +19,13 @@ let authAgent: AuthAgent;
let license: License; let license: License;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['license'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['license'] });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
config.set('license.serverUrl', MOCK_SERVER_URL); config.set('license.serverUrl', MOCK_SERVER_URL);
config.set('license.autoRenewEnabled', true); config.set('license.autoRenewEnabled', true);
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);

View file

@ -23,16 +23,12 @@ let globalMemberRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['me'] });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -47,16 +47,13 @@ let globalOwnerRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['nodes'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['nodes'] });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initConfigFile(); utils.initConfigFile();
utils.initTestLogger();
utils.initTestTelemetry();
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -19,15 +19,11 @@ let globalOwnerRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['owner'] });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -21,14 +21,10 @@ let globalOwnerRole: Role;
let globalMemberRole: Role; let globalMemberRole: Role;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['passwordReset'] });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
utils.initTestTelemetry();
utils.initTestLogger();
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -23,7 +23,6 @@ beforeAll(async () => {
applyAuth: false, applyAuth: false,
enablePublicAPI: true, enablePublicAPI: true,
}); });
await testDb.init();
utils.initConfigFile(); utils.initConfigFile();
@ -36,8 +35,6 @@ beforeAll(async () => {
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
utils.initTestLogger();
utils.initTestTelemetry();
utils.initCredentialsTypes(); utils.initCredentialsTypes();
}); });

View file

@ -18,13 +18,9 @@ beforeAll(async () => {
applyAuth: false, applyAuth: false,
enablePublicAPI: true, enablePublicAPI: true,
}); });
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
utils.initTestTelemetry();
utils.initTestLogger();
await utils.initBinaryManager(); await utils.initBinaryManager();
await utils.initNodeTypes(); await utils.initNodeTypes();

View file

@ -22,7 +22,6 @@ beforeAll(async () => {
applyAuth: false, applyAuth: false,
enablePublicAPI: true, enablePublicAPI: true,
}); });
await testDb.init();
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] = const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] =
await testDb.getAllRoles(); await testDb.getAllRoles();
@ -31,8 +30,6 @@ beforeAll(async () => {
globalMemberRole = fetchedGlobalMemberRole; globalMemberRole = fetchedGlobalMemberRole;
workflowOwnerRole = fetchedWorkflowOwnerRole; workflowOwnerRole = fetchedWorkflowOwnerRole;
utils.initTestTelemetry();
utils.initTestLogger();
utils.initConfigFile(); utils.initConfigFile();
await utils.initNodeTypes(); await utils.initNodeTypes();
workflowRunner = await utils.initActiveWorkflowRunner(); workflowRunner = await utils.initActiveWorkflowRunner();

View file

@ -22,7 +22,6 @@ import {
toCronExpression, toCronExpression,
TriggerTime, TriggerTime,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { N8nApp } from '@/UserManagement/Interfaces';
import superagent from 'superagent'; import superagent from 'superagent';
import request from 'supertest'; import request from 'supertest';
import { URL } from 'url'; import { URL } from 'url';
@ -34,11 +33,6 @@ import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooksManager } from '@/InternalHooksManager'; import { InternalHooksManager } from '@/InternalHooksManager';
import { NodeTypes } from '@/NodeTypes'; import { NodeTypes } from '@/NodeTypes';
import * as ActiveWorkflowRunner from '@/ActiveWorkflowRunner'; import * as ActiveWorkflowRunner from '@/ActiveWorkflowRunner';
import { meNamespace as meEndpoints } from '@/UserManagement/routes/me';
import { usersNamespace as usersEndpoints } from '@/UserManagement/routes/users';
import { authenticationMethods as authEndpoints } from '@/UserManagement/routes/auth';
import { ownerNamespace as ownerEndpoints } from '@/UserManagement/routes/owner';
import { passwordResetNamespace as passwordResetEndpoints } from '@/UserManagement/routes/passwordReset';
import { nodesController } from '@/api/nodes.api'; import { nodesController } from '@/api/nodes.api';
import { workflowsController } from '@/workflows/workflows.controller'; import { workflowsController } from '@/workflows/workflows.controller';
import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants'; import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants';
@ -47,8 +41,8 @@ import { InstalledPackages } from '@db/entities/InstalledPackages';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { getLogger } from '@/Logger'; import { getLogger } from '@/Logger';
import { loadPublicApiVersions } from '@/PublicApi/'; import { loadPublicApiVersions } from '@/PublicApi/';
import { issueJWT } from '@/UserManagement/auth/jwt'; import { issueJWT } from '@/auth/jwt';
import { addRoutes as authMiddleware } from '@/UserManagement/routes'; import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
import { import {
AUTHLESS_ENDPOINTS, AUTHLESS_ENDPOINTS,
COMMUNITY_NODE_VERSION, COMMUNITY_NODE_VERSION,
@ -66,9 +60,19 @@ import type {
} from './types'; } from './types';
import { licenseController } from '@/license/license.controller'; import { licenseController } from '@/license/license.controller';
import { eventBusRouter } from '@/eventbus/eventBusRoutes'; import { eventBusRouter } from '@/eventbus/eventBusRoutes';
import { registerController } from '@/decorators';
import {
AuthController,
MeController,
OwnerController,
PasswordResetController,
UsersController,
} from '@/controllers';
import { setupAuthMiddlewares } from '@/middlewares';
import * as testDb from '../shared/testDb';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { handleLdapInit } from '../../../src/Ldap/helpers'; import { handleLdapInit } from '@/Ldap/helpers';
import { ldapController } from '@/Ldap/routes/ldap.controller.ee'; import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
const loadNodesAndCredentials: INodesAndCredentials = { const loadNodesAndCredentials: INodesAndCredentials = {
@ -84,14 +88,15 @@ CredentialTypes(loadNodesAndCredentials);
* Initialize a test server. * Initialize a test server.
*/ */
export async function initTestServer({ export async function initTestServer({
applyAuth, applyAuth = true,
endpointGroups, endpointGroups,
enablePublicAPI = false, enablePublicAPI = false,
}: { }: {
applyAuth: boolean; applyAuth?: boolean;
endpointGroups?: EndpointGroup[]; endpointGroups?: EndpointGroup[];
enablePublicAPI?: boolean; enablePublicAPI?: boolean;
}) { }) {
await testDb.init();
const testServer = { const testServer = {
app: express(), app: express(),
restEndpoint: REST_PATH_SEGMENT, restEndpoint: REST_PATH_SEGMENT,
@ -99,6 +104,12 @@ export async function initTestServer({
externalHooks: {}, externalHooks: {},
}; };
const logger = getLogger();
LoggerProxy.init(logger);
// Pre-requisite: Mock the telemetry module before calling.
await InternalHooksManager.init('test-instance-id', mockNodeTypes);
testServer.app.use(bodyParser.json()); testServer.app.use(bodyParser.json());
testServer.app.use(bodyParser.urlencoded({ extended: true })); testServer.app.use(bodyParser.urlencoded({ extended: true }));
@ -106,7 +117,12 @@ export async function initTestServer({
config.set('userManagement.isInstanceOwnerSetUp', false); config.set('userManagement.isInstanceOwnerSetUp', false);
if (applyAuth) { if (applyAuth) {
authMiddleware.apply(testServer, [AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT]); setupAuthMiddlewares(
testServer.app,
AUTHLESS_ENDPOINTS,
REST_PATH_SEGMENT,
Db.collections.User,
);
} }
if (!endpointGroups) return testServer.app; if (!endpointGroups) return testServer.app;
@ -147,36 +163,75 @@ export async function initTestServer({
} }
if (functionEndpoints.length) { if (functionEndpoints.length) {
const map: Record<string, (this: N8nApp) => void> = { const externalHooks = ExternalHooks();
me: meEndpoints, const internalHooks = InternalHooksManager.getInstance();
users: usersEndpoints, const mailer = UserManagementMailer.getInstance();
auth: authEndpoints, const repositories = Db.collections;
owner: ownerEndpoints,
passwordReset: passwordResetEndpoints,
};
for (const group of functionEndpoints) { for (const group of functionEndpoints) {
map[group].apply(testServer); switch (group) {
case 'auth':
registerController(
testServer.app,
config,
new AuthController({ config, logger, internalHooks, repositories }),
);
break;
case 'me':
registerController(
testServer.app,
config,
new MeController({ logger, externalHooks, internalHooks, repositories }),
);
break;
case 'passwordReset':
registerController(
testServer.app,
config,
new PasswordResetController({
config,
logger,
externalHooks,
internalHooks,
repositories,
}),
);
break;
case 'owner':
registerController(
testServer.app,
config,
new OwnerController({ config, logger, internalHooks, repositories }),
);
break;
case 'users':
registerController(
testServer.app,
config,
new UsersController({
config,
mailer,
externalHooks,
internalHooks,
repositories,
activeWorkflowRunner: ActiveWorkflowRunner.getInstance(),
logger,
}),
);
}
} }
} }
return testServer.app; return testServer.app;
} }
/**
* Pre-requisite: Mock the telemetry module before calling.
*/
export function initTestTelemetry() {
void InternalHooksManager.init('test-instance-id', mockNodeTypes);
}
/** /**
* Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`),
* and `functionEndpoints` (legacy, namespaced inside a function). * and `functionEndpoints` (legacy, namespaced inside a function).
*/ */
const classifyEndpointGroups = (endpointGroups: string[]) => { const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => {
const routerEndpoints: string[] = []; const routerEndpoints: EndpointGroup[] = [];
const functionEndpoints: string[] = []; const functionEndpoints: EndpointGroup[] = [];
const ROUTER_GROUP = [ const ROUTER_GROUP = [
'credentials', 'credentials',
@ -559,13 +614,6 @@ export async function initNodeTypes() {
}; };
} }
/**
* Initialize a logger for test runs.
*/
export function initTestLogger() {
LoggerProxy.init(getLogger());
}
/** /**
* Initialize a BinaryManager for test runs. * Initialize a BinaryManager for test runs.
*/ */

View file

@ -33,8 +33,7 @@ let credentialOwnerRole: Role;
let authAgent: AuthAgent; let authAgent: AuthAgent;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); app = await utils.initTestServer({ endpointGroups: ['users'] });
await testDb.init();
const [ const [
fetchedGlobalOwnerRole, fetchedGlobalOwnerRole,
@ -49,9 +48,6 @@ beforeAll(async () => {
credentialOwnerRole = fetchedCredentialOwnerRole; credentialOwnerRole = fetchedCredentialOwnerRole;
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestTelemetry();
utils.initTestLogger();
}); });
beforeEach(async () => { beforeEach(async () => {
@ -241,60 +237,6 @@ test('DELETE /users/:id with transferId should perform transfer', async () => {
expect(deletedUser).toBeNull(); expect(deletedUser).toBeNull();
}); });
test('GET /resolve-signup-token should validate invite token', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const memberShell = await testDb.createUserShell(globalMemberRole);
const response = await authAgent(owner)
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId: memberShell.id });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
data: {
inviter: {
firstName: owner.firstName,
lastName: owner.lastName,
},
},
});
});
test('GET /resolve-signup-token should fail with invalid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = authAgent(owner);
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id });
const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId });
const third = await authOwnerAgent.get('/resolve-signup-token').query({
inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d',
inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d',
});
// user is already set up, so call should error
const fourth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId });
// cause inconsistent DB state
await Db.collections.User.update(owner.id, { email: '' });
const fifth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId });
for (const response of [first, second, third, fourth, fifth]) {
expect(response.statusCode).toBe(400);
}
});
test('POST /users/:id should fill out a user shell', async () => { test('POST /users/:id should fill out a user shell', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });

View file

@ -24,11 +24,7 @@ let workflowRunner: ActiveWorkflowRunner;
let sharingSpy: jest.SpyInstance<boolean>; let sharingSpy: jest.SpyInstance<boolean>;
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({ endpointGroups: ['workflows'] });
endpointGroups: ['workflows'],
applyAuth: true,
});
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole(); globalMemberRole = await testDb.getGlobalMemberRole();
@ -38,9 +34,6 @@ beforeAll(async () => {
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
await utils.initNodeTypes(); await utils.initNodeTypes();

View file

@ -15,16 +15,9 @@ let globalOwnerRole: Role;
jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false);
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({ endpointGroups: ['workflows'] });
endpointGroups: ['workflows'],
applyAuth: true,
});
await testDb.init();
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
utils.initTestLogger();
utils.initTestTelemetry();
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -3,7 +3,7 @@ import { InternalHooksManager } from '@/InternalHooksManager';
import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics'; import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics';
import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow'; import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow';
import { getLogger } from '@/Logger'; import { getLogger } from '@/Logger';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
const FAKE_USER_ID = 'abcde-fghij'; const FAKE_USER_ID = 'abcde-fghij';

View file

@ -20,10 +20,10 @@ import {
randomPositiveDigit, randomPositiveDigit,
} from '../integration/shared/random'; } from '../integration/shared/random';
import { Role } from '@/databases/entities/Role'; import { Role } from '@db/entities/Role';
import type { SaveCredentialFunction } from '../integration/shared/types'; import type { SaveCredentialFunction } from '../integration/shared/types';
import { User } from '@/databases/entities/User'; import { User } from '@db/entities/User';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; import { SharedWorkflow } from '@db/entities/SharedWorkflow';
let mockNodeTypes: INodeTypes; let mockNodeTypes: INodeTypes;
let credentialOwnerRole: Role; let credentialOwnerRole: Role;