mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
refactor(core): Switch over all user-management routes to use decorators (#5115)
This commit is contained in:
parent
08a90d7e09
commit
845f0f9d20
|
@ -1,5 +1,5 @@
|
|||
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 { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
|
||||
import { randFirstName, randLastName } from '@ngneat/falso';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { BasePage } from '../base';
|
||||
import { INodeTypeDescription } from '../../packages/workflow';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class NodeCreator extends BasePage {
|
||||
url = '/workflow/new';
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
sendSuccessResponse,
|
||||
ServiceUnavailableError,
|
||||
} from '@/ResponseHelper';
|
||||
import { corsMiddleware } from '@/middlewares/cors';
|
||||
import { corsMiddleware } from '@/middlewares';
|
||||
import * as TestWebhooks from '@/TestWebhooks';
|
||||
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
||||
import { WEBHOOK_METHODS } from '@/WebhookHelpers';
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { Application } from 'express';
|
||||
import type {
|
||||
ExecutionError,
|
||||
ICredentialDataDecryptedObject,
|
||||
|
@ -21,9 +22,11 @@ import type {
|
|||
WorkflowExecuteMode,
|
||||
} 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 { ChildProcess } from 'child_process';
|
||||
|
@ -365,6 +368,7 @@ export interface IInternalHooksClass {
|
|||
user: User;
|
||||
target_user_id: string[];
|
||||
public_api: boolean;
|
||||
email_sent: boolean;
|
||||
}): Promise<void>;
|
||||
onUserReinvite(userReinviteData: {
|
||||
user: User;
|
||||
|
@ -378,6 +382,7 @@ export interface IInternalHooksClass {
|
|||
userTransactionalEmailData: {
|
||||
user_id: string;
|
||||
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
||||
public_api: boolean;
|
||||
},
|
||||
user?: User,
|
||||
): Promise<void>;
|
||||
|
@ -841,3 +846,37 @@ export interface ILicenseReadResponse {
|
|||
export interface ILicensePostResponse extends ILicenseReadResponse {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import * as Db from '@/Db';
|
|||
import config from '@/config';
|
||||
import type { Role } from '@db/entities/Role';
|
||||
import { User } from '@db/entities/User';
|
||||
import { AuthIdentity } from '@/databases/entities/AuthIdentity';
|
||||
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
||||
import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory';
|
||||
import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper';
|
||||
import { LdapManager } from './LdapManager.ee';
|
||||
|
|
|
@ -63,9 +63,6 @@ import {
|
|||
WorkflowExecuteMode,
|
||||
INodeTypes,
|
||||
ICredentialTypes,
|
||||
INode,
|
||||
IWorkflowBase,
|
||||
IRun,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import basicAuth from 'basic-auth';
|
||||
|
@ -103,8 +100,15 @@ import type {
|
|||
OAuthRequest,
|
||||
WorkflowRequest,
|
||||
} from '@/requests';
|
||||
import { userManagementRouter } from '@/UserManagement';
|
||||
import { resolveJwt } from '@/UserManagement/auth/jwt';
|
||||
import { registerController } from '@/decorators';
|
||||
import {
|
||||
AuthController,
|
||||
MeController,
|
||||
OwnerController,
|
||||
PasswordResetController,
|
||||
UsersController,
|
||||
} from '@/controllers';
|
||||
import { resolveJwt } from '@/auth/jwt';
|
||||
|
||||
import { executionsController } from '@/executions/executions.controller';
|
||||
import { nodeTypesController } from '@/api/nodeTypes.api';
|
||||
|
@ -118,6 +122,7 @@ import {
|
|||
isUserManagementEnabled,
|
||||
whereClause,
|
||||
} from '@/UserManagement/UserManagementHelper';
|
||||
import { getInstance as getMailerInstance } from '@/UserManagement/email';
|
||||
import * as Db from '@/Db';
|
||||
import {
|
||||
DatabaseType,
|
||||
|
@ -151,7 +156,7 @@ import { eventBusRouter } from '@/eventbus/eventBusRoutes';
|
|||
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
|
||||
import { getLicense } from '@/License';
|
||||
import { licenseController } from './license/license.controller';
|
||||
import { corsMiddleware } from './middlewares/cors';
|
||||
import { corsMiddleware, setupAuthMiddlewares } from './middlewares';
|
||||
import { initEvents } from './events';
|
||||
import { ldapController } from './Ldap/routes/ldap.controller.ee';
|
||||
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> {
|
||||
configureMetrics(this.app);
|
||||
|
||||
|
@ -354,7 +386,7 @@ class Server extends AbstractServer {
|
|||
const publicApiEndpoint = config.getEnv('publicApi.path');
|
||||
const excludeEndpoints = config.getEnv('security.excludeEndpoints');
|
||||
|
||||
const ignoredEndpoints = [
|
||||
const ignoredEndpoints: Readonly<string[]> = [
|
||||
'assets',
|
||||
'healthz',
|
||||
'metrics',
|
||||
|
@ -587,7 +619,7 @@ class Server extends AbstractServer {
|
|||
// ----------------------------------------
|
||||
// User Management
|
||||
// ----------------------------------------
|
||||
await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]);
|
||||
this.registerControllers(ignoredEndpoints);
|
||||
|
||||
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -6,14 +6,13 @@ import { compare, genSaltSync, hash } from 'bcryptjs';
|
|||
|
||||
import * as Db from '@/Db';
|
||||
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 { Role } from '@db/entities/Role';
|
||||
import { AuthenticatedRequest } from '@/requests';
|
||||
import config from '@/config';
|
||||
import { getWebhookBaseUrl } from '../WebhookHelpers';
|
||||
import { getWebhookBaseUrl } from '@/WebhookHelpers';
|
||||
import { getLicense } from '@/License';
|
||||
import { WhereClause } from '@/Interfaces';
|
||||
import { RoleService } from '@/role/role.service';
|
||||
|
||||
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.
|
||||
*/
|
||||
export function isAuthExcluded(url: string, ignoredEndpoints: string[]): boolean {
|
||||
export function isAuthExcluded(url: string, ignoredEndpoints: Readonly<string[]>): boolean {
|
||||
return !!ignoredEndpoints
|
||||
.filter(Boolean) // skip empty paths
|
||||
.find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`));
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
import { addRoutes } from './routes';
|
||||
|
||||
export const userManagementRouter = { addRoutes };
|
|
@ -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();
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -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]);
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -9,7 +9,7 @@ import bodyParser from 'body-parser';
|
|||
import { v4 as uuid } from 'uuid';
|
||||
import config from '@/config';
|
||||
import * as Db from '@/Db';
|
||||
import { Role } from '@/databases/entities/Role';
|
||||
import { Role } from '@db/entities/Role';
|
||||
import { hashPassword } from '@/UserManagement/UserManagementHelper';
|
||||
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { User } from '@/databases/entities/User';
|
||||
import { User } from '@db/entities/User';
|
||||
import { whereClause } from '@/UserManagement/UserManagementHelper';
|
||||
import express from 'express';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
|
|
|
@ -3,7 +3,7 @@ import { DateUtils } from 'typeorm/util/DateUtils';
|
|||
import * as Db from '@/Db';
|
||||
import config from '@/config';
|
||||
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';
|
||||
|
||||
async function getAllCredsInUse(workflows: WorkflowEntity[]) {
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
DB_QUERY_PARAMS_DOCS_URL,
|
||||
SQL_NODE_TYPES_WITH_QUERY_PARAMS,
|
||||
} 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';
|
||||
|
||||
function getIssues(workflows: Workflow[]) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { getNodeTypes } from '@/audit/utils';
|
||||
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';
|
||||
|
||||
export function reportFilesystemRisk(workflows: WorkflowEntity[]) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
WEBHOOK_VALIDATOR_NODE_TYPES,
|
||||
} from '@/audit/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';
|
||||
|
||||
function getSecuritySettings() {
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
COMMUNITY_NODES_RISKS_URL,
|
||||
NPM_PACKAGE_URL,
|
||||
} from '@/audit/constants';
|
||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
import type { Risk } from '@/audit/types';
|
||||
|
||||
async function getCommunityNodeDetails() {
|
||||
|
|
|
@ -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 type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem';
|
||||
|
|
|
@ -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';
|
||||
|
||||
type Node = Workflow['nodes'][number];
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Response } from 'express';
|
|||
import { createHash } from 'crypto';
|
||||
import * as Db from '@/Db';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { JwtPayload, JwtToken } from '../Interfaces';
|
||||
import { JwtPayload, JwtToken } from '@/Interfaces';
|
||||
import { User } from '@db/entities/User';
|
||||
import config from '@/config';
|
||||
import * as ResponseHelper from '@/ResponseHelper';
|
177
packages/cli/src/controllers/auth.controller.ts
Normal file
177
packages/cli/src/controllers/auth.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
5
packages/cli/src/controllers/index.ts
Normal file
5
packages/cli/src/controllers/index.ts
Normal 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';
|
217
packages/cli/src/controllers/me.controller.ts
Normal file
217
packages/cli/src/controllers/me.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
145
packages/cli/src/controllers/owner.controller.ts
Normal file
145
packages/cli/src/controllers/owner.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
268
packages/cli/src/controllers/passwordReset.controller.ts
Normal file
268
packages/cli/src/controllers/passwordReset.controller.ts
Normal 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]);
|
||||
}
|
||||
}
|
562
packages/cli/src/controllers/users.controller.ts
Normal file
562
packages/cli/src/controllers/users.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||
import config from '@/config';
|
||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
||||
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||
|
||||
export class RemoveWorkflowDataLoadedFlag1671726148420 implements MigrationInterface {
|
||||
name = 'RemoveWorkflowDataLoadedFlag1671726148420';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { getTablePrefix } from '@/databases/utils/migrationHelpers';
|
||||
import { getTablePrefix } from '@db/utils/migrationHelpers';
|
||||
|
||||
export class CreateIndexStoppedAt1594828256133 implements MigrationInterface {
|
||||
name = 'CreateIndexStoppedAt1594828256133';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
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 {
|
||||
name = 'RemoveWorkflowDataLoadedFlag1671726148421';
|
||||
|
|
|
@ -2,7 +2,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
|||
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||
import config from '@/config';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
||||
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||
|
||||
export class RemoveWorkflowDataLoadedFlag1671726148419 implements MigrationInterface {
|
||||
name = 'RemoveWorkflowDataLoadedFlag1671726148419';
|
||||
|
|
8
packages/cli/src/decorators/RestController.ts
Normal file
8
packages/cli/src/decorators/RestController.ts
Normal 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);
|
||||
};
|
19
packages/cli/src/decorators/Route.ts
Normal file
19
packages/cli/src/decorators/Route.ts
Normal 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');
|
2
packages/cli/src/decorators/constants.ts
Normal file
2
packages/cli/src/decorators/constants.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
|
||||
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH';
|
3
packages/cli/src/decorators/index.ts
Normal file
3
packages/cli/src/decorators/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { RestController } from './RestController';
|
||||
export { Get, Post, Patch, Delete } from './Route';
|
||||
export { registerController } from './registerController';
|
34
packages/cli/src/decorators/registerController.ts
Normal file
34
packages/cli/src/decorators/registerController.ts
Normal 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);
|
||||
}
|
||||
};
|
12
packages/cli/src/decorators/types.ts
Normal file
12
packages/cli/src/decorators/types.ts
Normal 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>;
|
|
@ -1,12 +1,12 @@
|
|||
/* eslint-disable import/no-cycle */
|
||||
import type { EventDestinations } from '@/databases/entities/MessageEventBusDestinationEntity';
|
||||
import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity';
|
||||
import { promClient } from '@/metrics';
|
||||
import {
|
||||
EventMessageTypeNames,
|
||||
LoggerProxy,
|
||||
MessageEventBusDestinationTypeNames,
|
||||
} from 'n8n-workflow';
|
||||
import config from '../../config';
|
||||
import config from '@/config';
|
||||
import type { EventMessageTypes } from '../EventMessageClasses';
|
||||
import type { MessageEventBusDestination } from './MessageEventBusDestination.ee';
|
||||
import { MessageEventBusDestinationSentry } from './MessageEventBusDestinationSentry.ee';
|
||||
|
|
|
@ -18,10 +18,10 @@ import {
|
|||
MessageEventBusDestinationWebhookParameterItem,
|
||||
MessageEventBusDestinationWebhookParameterOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { CredentialsHelper } from '../../CredentialsHelper';
|
||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||
import { UserSettings } from 'n8n-core';
|
||||
import { Agent as HTTPSAgent } from 'https';
|
||||
import config from '../../config';
|
||||
import config from '@/config';
|
||||
import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper';
|
||||
import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { INode, IRun, IWorkflowBase } from 'n8n-workflow';
|
||||
import * as Db from '@/Db';
|
||||
import { InternalHooksManager } from '@/InternalHooksManager';
|
||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
||||
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { User } from '@/databases/entities/User';
|
||||
import { User } from '@db/entities/User';
|
||||
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
|
||||
import { ExecutionsService } from './executions.service';
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { FindOperator, FindOptionsWhere, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm';
|
||||
import * as ActiveExecutions from '@/ActiveExecutions';
|
||||
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 {
|
||||
IExecutionFlattedResponse,
|
||||
|
|
|
@ -1,32 +1,80 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import type { Application, NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import passport from 'passport';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { LoggerProxy as Logger } from 'n8n-workflow';
|
||||
import { N8nApp } from '../Interfaces';
|
||||
import { AuthenticatedRequest } from '@/requests';
|
||||
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';
|
||||
import {
|
||||
isAuthenticatedRequest,
|
||||
isAuthExcluded,
|
||||
isPostUsersId,
|
||||
isAuthenticatedRequest,
|
||||
isUserManagementDisabled,
|
||||
} from '../UserManagementHelper';
|
||||
import * as Db from '@/Db';
|
||||
import { jwtAuth, refreshExpiringCookie } from '../middlewares';
|
||||
import { authenticationMethods } from './auth';
|
||||
import { meNamespace } from './me';
|
||||
import { usersNamespace } from './users';
|
||||
import { passwordResetNamespace } from './passwordReset';
|
||||
import { ownerNamespace } from './owner';
|
||||
} from '@/UserManagement/UserManagementHelper';
|
||||
import type { Repository } from 'typeorm';
|
||||
import type { User } from '@db/entities/User';
|
||||
|
||||
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
|
||||
this.app.use(cookieParser());
|
||||
this.app.use(jwtAuth());
|
||||
app.use(cookieParser());
|
||||
app.use(jwtAuth());
|
||||
|
||||
this.app.use(async (req: Request, res: Response, next: NextFunction) => {
|
||||
app.use(async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (
|
||||
// TODO: refactor me!!!
|
||||
// skip authentication for preflight requests
|
||||
|
@ -54,17 +102,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
|
|||
|
||||
// skip authentication if user management is disabled
|
||||
if (isUserManagementDisabled()) {
|
||||
req.user = await Db.collections.User.findOneOrFail({
|
||||
req.user = await userRepository.findOneOrFail({
|
||||
relations: ['globalRole'],
|
||||
where: {},
|
||||
});
|
||||
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
|
||||
// owner can do anything, so proceed as well
|
||||
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.
|
||||
const postRestrictedUrls = [
|
||||
`/${this.restEndpoint}/users`,
|
||||
`/${this.restEndpoint}/owner`,
|
||||
`/${this.restEndpoint}/ldap/sync`,
|
||||
`/${this.restEndpoint}/ldap/test-connection`,
|
||||
`/${restEndpoint}/users`,
|
||||
`/${restEndpoint}/owner`,
|
||||
`/${restEndpoint}/ldap/sync`,
|
||||
`/${restEndpoint}/ldap/test-connection`,
|
||||
];
|
||||
const getRestrictedUrls = [
|
||||
`/${this.restEndpoint}/users`,
|
||||
`/${this.restEndpoint}/ldap/sync`,
|
||||
`/${this.restEndpoint}/ldap/config`,
|
||||
`/${restEndpoint}/users`,
|
||||
`/${restEndpoint}/ldap/sync`,
|
||||
`/${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;
|
||||
if (
|
||||
(req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) ||
|
||||
|
@ -106,11 +154,5 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
|
|||
next();
|
||||
});
|
||||
|
||||
this.app.use(refreshExpiringCookie);
|
||||
|
||||
authenticationMethods.apply(this);
|
||||
ownerNamespace.apply(this);
|
||||
meNamespace.apply(this);
|
||||
passwordResetNamespace.apply(this);
|
||||
usersNamespace.apply(this);
|
||||
}
|
||||
app.use(refreshExpiringCookie);
|
||||
};
|
|
@ -1 +1,2 @@
|
|||
export * from './auth';
|
||||
export * from './cors';
|
3
packages/cli/src/requests.d.ts
vendored
3
packages/cli/src/requests.d.ts
vendored
|
@ -10,11 +10,10 @@ import {
|
|||
IWorkflowSettings,
|
||||
} 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 { User } from '@db/entities/User';
|
||||
import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
|
||||
import type { PublicUser } from '@/UserManagement/Interfaces';
|
||||
|
||||
export type AuthlessRequest<
|
||||
RouteParams = {},
|
||||
|
|
|
@ -5,8 +5,8 @@ import * as Db from '@/Db';
|
|||
import { toReportTitle } from '@/audit/utils';
|
||||
import * as constants from '@/constants';
|
||||
import type { Risk } from '@/audit/types';
|
||||
import type { InstalledNodes } from '@/databases/entities/InstalledNodes';
|
||||
import type { InstalledPackages } from '@/databases/entities/InstalledPackages';
|
||||
import type { InstalledNodes } from '@db/entities/InstalledNodes';
|
||||
import type { InstalledPackages } from '@db/entities/InstalledPackages';
|
||||
|
||||
type GetSectionKind<C extends Risk.Category> = C extends 'instance'
|
||||
? Risk.InstanceSection
|
||||
|
|
|
@ -16,16 +16,12 @@ let globalMemberRole: Role;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['auth'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -276,6 +272,60 @@ test('GET /login should return logged-in member', async () => {
|
|||
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 () => {
|
||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||
|
||||
|
|
|
@ -16,18 +16,11 @@ let globalMemberRole: Role;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({
|
||||
applyAuth: true,
|
||||
endpointGroups: ['me', 'auth', 'owner', 'users'],
|
||||
});
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] });
|
||||
|
||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
import express from 'express';
|
||||
|
||||
import * as Db from '@/Db';
|
||||
import { Reset } from '@/commands/user-management/reset';
|
||||
import type { Role } from '@db/entities/Role';
|
||||
import * as utils from '../shared/utils';
|
||||
import * as testDb from '../shared/testDb';
|
||||
|
||||
let app: express.Application;
|
||||
let globalOwnerRole: Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
||||
await testDb.init();
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
|
|
|
@ -22,11 +22,7 @@ let authAgent: AuthAgent;
|
|||
let sharingSpy: jest.SpyInstance<boolean>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({
|
||||
endpointGroups: ['credentials'],
|
||||
applyAuth: true,
|
||||
});
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['credentials'] });
|
||||
|
||||
utils.initConfigFile();
|
||||
|
||||
|
@ -37,9 +33,6 @@ beforeAll(async () => {
|
|||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
|
||||
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
||||
});
|
||||
|
||||
|
|
|
@ -25,11 +25,7 @@ let saveCredential: SaveCredentialFunction;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({
|
||||
endpointGroups: ['credentials'],
|
||||
applyAuth: true,
|
||||
});
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['credentials'] });
|
||||
|
||||
utils.initConfigFile();
|
||||
|
||||
|
@ -40,9 +36,6 @@ beforeAll(async () => {
|
|||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -81,12 +81,11 @@ async function confirmIdSent(id: string) {
|
|||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['eventBus'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||
|
||||
app = await utils.initTestServer({ endpointGroups: ['eventBus'], applyAuth: true });
|
||||
|
||||
unAuthOwnerAgent = utils.createAgent(app, {
|
||||
apiPath: 'internal',
|
||||
auth: false,
|
||||
|
@ -104,7 +103,6 @@ beforeAll(async () => {
|
|||
mockedSyslog.createClient.mockImplementation(() => new syslog.Client());
|
||||
|
||||
utils.initConfigFile();
|
||||
utils.initTestLogger();
|
||||
config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter');
|
||||
config.set('eventBus.logWriter.keepLogCount', '1');
|
||||
config.set('enterprise.features.logStreaming', true);
|
||||
|
|
|
@ -40,8 +40,7 @@ const defaultLdapConfig = {
|
|||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'], applyAuth: true });
|
||||
app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'] });
|
||||
|
||||
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles();
|
||||
|
||||
|
@ -56,8 +55,6 @@ beforeAll(async () => {
|
|||
);
|
||||
|
||||
utils.initConfigFile();
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
await utils.initLdapManager();
|
||||
});
|
||||
|
||||
|
|
|
@ -19,17 +19,13 @@ let authAgent: AuthAgent;
|
|||
let license: License;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['license'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['license'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
|
||||
config.set('license.serverUrl', MOCK_SERVER_URL);
|
||||
config.set('license.autoRenewEnabled', true);
|
||||
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
|
||||
|
|
|
@ -23,16 +23,12 @@ let globalMemberRole: Role;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['me'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -47,16 +47,13 @@ let globalOwnerRole: Role;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['nodes'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['nodes'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initConfigFile();
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -19,15 +19,11 @@ let globalOwnerRole: Role;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['owner'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -21,14 +21,10 @@ let globalOwnerRole: Role;
|
|||
let globalMemberRole: Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['passwordReset'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||
|
||||
utils.initTestTelemetry();
|
||||
utils.initTestLogger();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -23,7 +23,6 @@ beforeAll(async () => {
|
|||
applyAuth: false,
|
||||
enablePublicAPI: true,
|
||||
});
|
||||
await testDb.init();
|
||||
|
||||
utils.initConfigFile();
|
||||
|
||||
|
@ -36,8 +35,6 @@ beforeAll(async () => {
|
|||
|
||||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
utils.initCredentialsTypes();
|
||||
});
|
||||
|
||||
|
|
|
@ -18,13 +18,9 @@ beforeAll(async () => {
|
|||
applyAuth: false,
|
||||
enablePublicAPI: true,
|
||||
});
|
||||
await testDb.init();
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
|
||||
utils.initTestTelemetry();
|
||||
utils.initTestLogger();
|
||||
|
||||
await utils.initBinaryManager();
|
||||
await utils.initNodeTypes();
|
||||
|
||||
|
|
|
@ -22,7 +22,6 @@ beforeAll(async () => {
|
|||
applyAuth: false,
|
||||
enablePublicAPI: true,
|
||||
});
|
||||
await testDb.init();
|
||||
|
||||
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] =
|
||||
await testDb.getAllRoles();
|
||||
|
@ -31,8 +30,6 @@ beforeAll(async () => {
|
|||
globalMemberRole = fetchedGlobalMemberRole;
|
||||
workflowOwnerRole = fetchedWorkflowOwnerRole;
|
||||
|
||||
utils.initTestTelemetry();
|
||||
utils.initTestLogger();
|
||||
utils.initConfigFile();
|
||||
await utils.initNodeTypes();
|
||||
workflowRunner = await utils.initActiveWorkflowRunner();
|
||||
|
|
|
@ -22,7 +22,6 @@ import {
|
|||
toCronExpression,
|
||||
TriggerTime,
|
||||
} from 'n8n-workflow';
|
||||
import type { N8nApp } from '@/UserManagement/Interfaces';
|
||||
import superagent from 'superagent';
|
||||
import request from 'supertest';
|
||||
import { URL } from 'url';
|
||||
|
@ -34,11 +33,6 @@ import { ExternalHooks } from '@/ExternalHooks';
|
|||
import { InternalHooksManager } from '@/InternalHooksManager';
|
||||
import { NodeTypes } from '@/NodeTypes';
|
||||
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 { workflowsController } from '@/workflows/workflows.controller';
|
||||
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 { getLogger } from '@/Logger';
|
||||
import { loadPublicApiVersions } from '@/PublicApi/';
|
||||
import { issueJWT } from '@/UserManagement/auth/jwt';
|
||||
import { addRoutes as authMiddleware } from '@/UserManagement/routes';
|
||||
import { issueJWT } from '@/auth/jwt';
|
||||
import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
|
||||
import {
|
||||
AUTHLESS_ENDPOINTS,
|
||||
COMMUNITY_NODE_VERSION,
|
||||
|
@ -66,9 +60,19 @@ import type {
|
|||
} from './types';
|
||||
import { licenseController } from '@/license/license.controller';
|
||||
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 { handleLdapInit } from '../../../src/Ldap/helpers';
|
||||
import { handleLdapInit } from '@/Ldap/helpers';
|
||||
import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
|
||||
|
||||
const loadNodesAndCredentials: INodesAndCredentials = {
|
||||
|
@ -84,14 +88,15 @@ CredentialTypes(loadNodesAndCredentials);
|
|||
* Initialize a test server.
|
||||
*/
|
||||
export async function initTestServer({
|
||||
applyAuth,
|
||||
applyAuth = true,
|
||||
endpointGroups,
|
||||
enablePublicAPI = false,
|
||||
}: {
|
||||
applyAuth: boolean;
|
||||
applyAuth?: boolean;
|
||||
endpointGroups?: EndpointGroup[];
|
||||
enablePublicAPI?: boolean;
|
||||
}) {
|
||||
await testDb.init();
|
||||
const testServer = {
|
||||
app: express(),
|
||||
restEndpoint: REST_PATH_SEGMENT,
|
||||
|
@ -99,6 +104,12 @@ export async function initTestServer({
|
|||
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.urlencoded({ extended: true }));
|
||||
|
||||
|
@ -106,7 +117,12 @@ export async function initTestServer({
|
|||
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||
|
||||
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;
|
||||
|
@ -147,36 +163,75 @@ export async function initTestServer({
|
|||
}
|
||||
|
||||
if (functionEndpoints.length) {
|
||||
const map: Record<string, (this: N8nApp) => void> = {
|
||||
me: meEndpoints,
|
||||
users: usersEndpoints,
|
||||
auth: authEndpoints,
|
||||
owner: ownerEndpoints,
|
||||
passwordReset: passwordResetEndpoints,
|
||||
};
|
||||
const externalHooks = ExternalHooks();
|
||||
const internalHooks = InternalHooksManager.getInstance();
|
||||
const mailer = UserManagementMailer.getInstance();
|
||||
const repositories = Db.collections;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`),
|
||||
* and `functionEndpoints` (legacy, namespaced inside a function).
|
||||
*/
|
||||
const classifyEndpointGroups = (endpointGroups: string[]) => {
|
||||
const routerEndpoints: string[] = [];
|
||||
const functionEndpoints: string[] = [];
|
||||
const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => {
|
||||
const routerEndpoints: EndpointGroup[] = [];
|
||||
const functionEndpoints: EndpointGroup[] = [];
|
||||
|
||||
const ROUTER_GROUP = [
|
||||
'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.
|
||||
*/
|
||||
|
|
|
@ -33,8 +33,7 @@ let credentialOwnerRole: Role;
|
|||
let authAgent: AuthAgent;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['users'] });
|
||||
|
||||
const [
|
||||
fetchedGlobalOwnerRole,
|
||||
|
@ -49,9 +48,6 @@ beforeAll(async () => {
|
|||
credentialOwnerRole = fetchedCredentialOwnerRole;
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestTelemetry();
|
||||
utils.initTestLogger();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -241,60 +237,6 @@ test('DELETE /users/:id with transferId should perform transfer', async () => {
|
|||
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 () => {
|
||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||
|
||||
|
|
|
@ -24,11 +24,7 @@ let workflowRunner: ActiveWorkflowRunner;
|
|||
let sharingSpy: jest.SpyInstance<boolean>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({
|
||||
endpointGroups: ['workflows'],
|
||||
applyAuth: true,
|
||||
});
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['workflows'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||
|
@ -38,9 +34,6 @@ beforeAll(async () => {
|
|||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
|
||||
isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
||||
|
||||
await utils.initNodeTypes();
|
||||
|
|
|
@ -15,16 +15,9 @@ let globalOwnerRole: Role;
|
|||
jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await utils.initTestServer({
|
||||
endpointGroups: ['workflows'],
|
||||
applyAuth: true,
|
||||
});
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['workflows'] });
|
||||
|
||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { InternalHooksManager } from '@/InternalHooksManager';
|
|||
import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics';
|
||||
import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow';
|
||||
import { getLogger } from '@/Logger';
|
||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
||||
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
const FAKE_USER_ID = 'abcde-fghij';
|
||||
|
|
|
@ -20,10 +20,10 @@ import {
|
|||
randomPositiveDigit,
|
||||
} from '../integration/shared/random';
|
||||
|
||||
import { Role } from '@/databases/entities/Role';
|
||||
import { Role } from '@db/entities/Role';
|
||||
import type { SaveCredentialFunction } from '../integration/shared/types';
|
||||
import { User } from '@/databases/entities/User';
|
||||
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
|
||||
import { User } from '@db/entities/User';
|
||||
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
||||
|
||||
let mockNodeTypes: INodeTypes;
|
||||
let credentialOwnerRole: Role;
|
||||
|
|
Loading…
Reference in a new issue