mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -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 { NodeCreator } from '../pages/features/node-creator';
|
||||||
import { INodeTypeDescription } from '../../packages/workflow';
|
import { INodeTypeDescription } from 'n8n-workflow';
|
||||||
import CustomNodeFixture from '../fixtures/Custom_node.json';
|
import CustomNodeFixture from '../fixtures/Custom_node.json';
|
||||||
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
|
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
|
||||||
import { randFirstName, randLastName } from '@ngneat/falso';
|
import { randFirstName, randLastName } from '@ngneat/falso';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
import { INodeTypeDescription } from '../../packages/workflow';
|
import { INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
|
||||||
export class NodeCreator extends BasePage {
|
export class NodeCreator extends BasePage {
|
||||||
url = '/workflow/new';
|
url = '/workflow/new';
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
sendSuccessResponse,
|
sendSuccessResponse,
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
} from '@/ResponseHelper';
|
} from '@/ResponseHelper';
|
||||||
import { corsMiddleware } from '@/middlewares/cors';
|
import { corsMiddleware } from '@/middlewares';
|
||||||
import * as TestWebhooks from '@/TestWebhooks';
|
import * as TestWebhooks from '@/TestWebhooks';
|
||||||
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
||||||
import { WEBHOOK_METHODS } from '@/WebhookHelpers';
|
import { WEBHOOK_METHODS } from '@/WebhookHelpers';
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
import type { Application } from 'express';
|
||||||
import type {
|
import type {
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
ICredentialDataDecryptedObject,
|
ICredentialDataDecryptedObject,
|
||||||
|
@ -21,9 +22,11 @@ import type {
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { WorkflowExecute } from 'n8n-core';
|
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
||||||
|
|
||||||
import PCancelable from 'p-cancelable';
|
import type { WorkflowExecute } from 'n8n-core';
|
||||||
|
|
||||||
|
import type PCancelable from 'p-cancelable';
|
||||||
import type { FindOperator, Repository } from 'typeorm';
|
import type { FindOperator, Repository } from 'typeorm';
|
||||||
|
|
||||||
import type { ChildProcess } from 'child_process';
|
import type { ChildProcess } from 'child_process';
|
||||||
|
@ -365,6 +368,7 @@ export interface IInternalHooksClass {
|
||||||
user: User;
|
user: User;
|
||||||
target_user_id: string[];
|
target_user_id: string[];
|
||||||
public_api: boolean;
|
public_api: boolean;
|
||||||
|
email_sent: boolean;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
onUserReinvite(userReinviteData: {
|
onUserReinvite(userReinviteData: {
|
||||||
user: User;
|
user: User;
|
||||||
|
@ -378,6 +382,7 @@ export interface IInternalHooksClass {
|
||||||
userTransactionalEmailData: {
|
userTransactionalEmailData: {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
||||||
|
public_api: boolean;
|
||||||
},
|
},
|
||||||
user?: User,
|
user?: User,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
@ -841,3 +846,37 @@ export interface ILicenseReadResponse {
|
||||||
export interface ILicensePostResponse extends ILicenseReadResponse {
|
export interface ILicensePostResponse extends ILicenseReadResponse {
|
||||||
managementToken: string;
|
managementToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JwtToken {
|
||||||
|
token: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
password: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicUser {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
personalizationAnswers?: IPersonalizationSurveyAnswers | null;
|
||||||
|
password?: string;
|
||||||
|
passwordResetToken?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
isPending: boolean;
|
||||||
|
globalRole?: Role;
|
||||||
|
signInType: AuthProviderType;
|
||||||
|
disabled: boolean;
|
||||||
|
inviteAcceptUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface N8nApp {
|
||||||
|
app: Application;
|
||||||
|
restEndpoint: string;
|
||||||
|
externalHooks: IExternalHooksClass;
|
||||||
|
activeWorkflowRunner: ActiveWorkflowRunner;
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import * as Db from '@/Db';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import { User } from '@db/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import { AuthIdentity } from '@/databases/entities/AuthIdentity';
|
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
||||||
import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory';
|
import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory';
|
||||||
import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper';
|
import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper';
|
||||||
import { LdapManager } from './LdapManager.ee';
|
import { LdapManager } from './LdapManager.ee';
|
||||||
|
|
|
@ -63,9 +63,6 @@ import {
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
INodeTypes,
|
INodeTypes,
|
||||||
ICredentialTypes,
|
ICredentialTypes,
|
||||||
INode,
|
|
||||||
IWorkflowBase,
|
|
||||||
IRun,
|
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import basicAuth from 'basic-auth';
|
import basicAuth from 'basic-auth';
|
||||||
|
@ -103,8 +100,15 @@ import type {
|
||||||
OAuthRequest,
|
OAuthRequest,
|
||||||
WorkflowRequest,
|
WorkflowRequest,
|
||||||
} from '@/requests';
|
} from '@/requests';
|
||||||
import { userManagementRouter } from '@/UserManagement';
|
import { registerController } from '@/decorators';
|
||||||
import { resolveJwt } from '@/UserManagement/auth/jwt';
|
import {
|
||||||
|
AuthController,
|
||||||
|
MeController,
|
||||||
|
OwnerController,
|
||||||
|
PasswordResetController,
|
||||||
|
UsersController,
|
||||||
|
} from '@/controllers';
|
||||||
|
import { resolveJwt } from '@/auth/jwt';
|
||||||
|
|
||||||
import { executionsController } from '@/executions/executions.controller';
|
import { executionsController } from '@/executions/executions.controller';
|
||||||
import { nodeTypesController } from '@/api/nodeTypes.api';
|
import { nodeTypesController } from '@/api/nodeTypes.api';
|
||||||
|
@ -118,6 +122,7 @@ import {
|
||||||
isUserManagementEnabled,
|
isUserManagementEnabled,
|
||||||
whereClause,
|
whereClause,
|
||||||
} from '@/UserManagement/UserManagementHelper';
|
} from '@/UserManagement/UserManagementHelper';
|
||||||
|
import { getInstance as getMailerInstance } from '@/UserManagement/email';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import {
|
import {
|
||||||
DatabaseType,
|
DatabaseType,
|
||||||
|
@ -151,7 +156,7 @@ import { eventBusRouter } from '@/eventbus/eventBusRoutes';
|
||||||
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
|
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
|
||||||
import { getLicense } from '@/License';
|
import { getLicense } from '@/License';
|
||||||
import { licenseController } from './license/license.controller';
|
import { licenseController } from './license/license.controller';
|
||||||
import { corsMiddleware } from './middlewares/cors';
|
import { corsMiddleware, setupAuthMiddlewares } from './middlewares';
|
||||||
import { initEvents } from './events';
|
import { initEvents } from './events';
|
||||||
import { ldapController } from './Ldap/routes/ldap.controller.ee';
|
import { ldapController } from './Ldap/routes/ldap.controller.ee';
|
||||||
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
|
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
|
||||||
|
@ -336,6 +341,33 @@ class Server extends AbstractServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private registerControllers(ignoredEndpoints: Readonly<string[]>) {
|
||||||
|
const { app, externalHooks, activeWorkflowRunner } = this;
|
||||||
|
const repositories = Db.collections;
|
||||||
|
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint, repositories.User);
|
||||||
|
|
||||||
|
const logger = LoggerProxy;
|
||||||
|
const internalHooks = InternalHooksManager.getInstance();
|
||||||
|
const mailer = getMailerInstance();
|
||||||
|
|
||||||
|
const controllers = [
|
||||||
|
new AuthController({ config, internalHooks, repositories, logger }),
|
||||||
|
new OwnerController({ config, internalHooks, repositories, logger }),
|
||||||
|
new MeController({ externalHooks, internalHooks, repositories, logger }),
|
||||||
|
new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }),
|
||||||
|
new UsersController({
|
||||||
|
config,
|
||||||
|
mailer,
|
||||||
|
externalHooks,
|
||||||
|
internalHooks,
|
||||||
|
repositories,
|
||||||
|
activeWorkflowRunner,
|
||||||
|
logger,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
controllers.forEach((controller) => registerController(app, config, controller));
|
||||||
|
}
|
||||||
|
|
||||||
async configure(): Promise<void> {
|
async configure(): Promise<void> {
|
||||||
configureMetrics(this.app);
|
configureMetrics(this.app);
|
||||||
|
|
||||||
|
@ -354,7 +386,7 @@ class Server extends AbstractServer {
|
||||||
const publicApiEndpoint = config.getEnv('publicApi.path');
|
const publicApiEndpoint = config.getEnv('publicApi.path');
|
||||||
const excludeEndpoints = config.getEnv('security.excludeEndpoints');
|
const excludeEndpoints = config.getEnv('security.excludeEndpoints');
|
||||||
|
|
||||||
const ignoredEndpoints = [
|
const ignoredEndpoints: Readonly<string[]> = [
|
||||||
'assets',
|
'assets',
|
||||||
'healthz',
|
'healthz',
|
||||||
'metrics',
|
'metrics',
|
||||||
|
@ -587,7 +619,7 @@ class Server extends AbstractServer {
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// User Management
|
// User Management
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]);
|
this.registerControllers(ignoredEndpoints);
|
||||||
|
|
||||||
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
|
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
|
||||||
|
|
||||||
|
|
|
@ -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 Db from '@/Db';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
import { PublicUser } from './Interfaces';
|
import type { PublicUser, WhereClause } from '@/Interfaces';
|
||||||
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '@db/entities/User';
|
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '@db/entities/User';
|
||||||
import { Role } from '@db/entities/Role';
|
import { Role } from '@db/entities/Role';
|
||||||
import { AuthenticatedRequest } from '@/requests';
|
import { AuthenticatedRequest } from '@/requests';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { getWebhookBaseUrl } from '../WebhookHelpers';
|
import { getWebhookBaseUrl } from '@/WebhookHelpers';
|
||||||
import { getLicense } from '@/License';
|
import { getLicense } from '@/License';
|
||||||
import { WhereClause } from '@/Interfaces';
|
|
||||||
import { RoleService } from '@/role/role.service';
|
import { RoleService } from '@/role/role.service';
|
||||||
|
|
||||||
export async function getWorkflowOwner(workflowId: string): Promise<User> {
|
export async function getWorkflowOwner(workflowId: string): Promise<User> {
|
||||||
|
@ -177,7 +176,7 @@ export async function getUserById(userId: string): Promise<User> {
|
||||||
/**
|
/**
|
||||||
* Check if a URL contains an auth-excluded endpoint.
|
* Check if a URL contains an auth-excluded endpoint.
|
||||||
*/
|
*/
|
||||||
export function isAuthExcluded(url: string, ignoredEndpoints: string[]): boolean {
|
export function isAuthExcluded(url: string, ignoredEndpoints: Readonly<string[]>): boolean {
|
||||||
return !!ignoredEndpoints
|
return !!ignoredEndpoints
|
||||||
.filter(Boolean) // skip empty paths
|
.filter(Boolean) // skip empty paths
|
||||||
.find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`));
|
.find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`));
|
||||||
|
|
|
@ -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 { v4 as uuid } from 'uuid';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import { Role } from '@/databases/entities/Role';
|
import { Role } from '@db/entities/Role';
|
||||||
import { hashPassword } from '@/UserManagement/UserManagementHelper';
|
import { hashPassword } from '@/UserManagement/UserManagementHelper';
|
||||||
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { User } from '@/databases/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import { whereClause } from '@/UserManagement/UserManagementHelper';
|
import { whereClause } from '@/UserManagement/UserManagementHelper';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { LoggerProxy } from 'n8n-workflow';
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { DateUtils } from 'typeorm/util/DateUtils';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { CREDENTIALS_REPORT } from '@/audit/constants';
|
import { CREDENTIALS_REPORT } from '@/audit/constants';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { Risk } from '@/audit/types';
|
import type { Risk } from '@/audit/types';
|
||||||
|
|
||||||
async function getAllCredsInUse(workflows: WorkflowEntity[]) {
|
async function getAllCredsInUse(workflows: WorkflowEntity[]) {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
DB_QUERY_PARAMS_DOCS_URL,
|
DB_QUERY_PARAMS_DOCS_URL,
|
||||||
SQL_NODE_TYPES_WITH_QUERY_PARAMS,
|
SQL_NODE_TYPES_WITH_QUERY_PARAMS,
|
||||||
} from '@/audit/constants';
|
} from '@/audit/constants';
|
||||||
import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
|
||||||
import type { Risk } from '@/audit/types';
|
import type { Risk } from '@/audit/types';
|
||||||
|
|
||||||
function getIssues(workflows: Workflow[]) {
|
function getIssues(workflows: Workflow[]) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { getNodeTypes } from '@/audit/utils';
|
import { getNodeTypes } from '@/audit/utils';
|
||||||
import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants';
|
import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { Risk } from '@/audit/types';
|
import type { Risk } from '@/audit/types';
|
||||||
|
|
||||||
export function reportFilesystemRisk(workflows: WorkflowEntity[]) {
|
export function reportFilesystemRisk(workflows: WorkflowEntity[]) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
WEBHOOK_VALIDATOR_NODE_TYPES,
|
WEBHOOK_VALIDATOR_NODE_TYPES,
|
||||||
} from '@/audit/constants';
|
} from '@/audit/constants';
|
||||||
import { getN8nPackageJson, inDevelopment } from '@/constants';
|
import { getN8nPackageJson, inDevelopment } from '@/constants';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { Risk, n8n } from '@/audit/types';
|
import type { Risk, n8n } from '@/audit/types';
|
||||||
|
|
||||||
function getSecuritySettings() {
|
function getSecuritySettings() {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
COMMUNITY_NODES_RISKS_URL,
|
COMMUNITY_NODES_RISKS_URL,
|
||||||
NPM_PACKAGE_URL,
|
NPM_PACKAGE_URL,
|
||||||
} from '@/audit/constants';
|
} from '@/audit/constants';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { Risk } from '@/audit/types';
|
import type { Risk } from '@/audit/types';
|
||||||
|
|
||||||
async function getCommunityNodeDetails() {
|
async function getCommunityNodeDetails() {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
|
||||||
|
|
||||||
export namespace Risk {
|
export namespace Risk {
|
||||||
export type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem';
|
export type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
|
||||||
import type { Risk } from '@/audit/types';
|
import type { Risk } from '@/audit/types';
|
||||||
|
|
||||||
type Node = Workflow['nodes'][number];
|
type Node = Workflow['nodes'][number];
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Response } from 'express';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||||
import { JwtPayload, JwtToken } from '../Interfaces';
|
import { JwtPayload, JwtToken } from '@/Interfaces';
|
||||||
import { User } from '@db/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||||
|
|
||||||
export class RemoveWorkflowDataLoadedFlag1671726148420 implements MigrationInterface {
|
export class RemoveWorkflowDataLoadedFlag1671726148420 implements MigrationInterface {
|
||||||
name = 'RemoveWorkflowDataLoadedFlag1671726148420';
|
name = 'RemoveWorkflowDataLoadedFlag1671726148420';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
import { getTablePrefix } from '@/databases/utils/migrationHelpers';
|
import { getTablePrefix } from '@db/utils/migrationHelpers';
|
||||||
|
|
||||||
export class CreateIndexStoppedAt1594828256133 implements MigrationInterface {
|
export class CreateIndexStoppedAt1594828256133 implements MigrationInterface {
|
||||||
name = 'CreateIndexStoppedAt1594828256133';
|
name = 'CreateIndexStoppedAt1594828256133';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||||
|
|
||||||
export class RemoveWorkflowDataLoadedFlag1671726148421 implements MigrationInterface {
|
export class RemoveWorkflowDataLoadedFlag1671726148421 implements MigrationInterface {
|
||||||
name = 'RemoveWorkflowDataLoadedFlag1671726148421';
|
name = 'RemoveWorkflowDataLoadedFlag1671726148421';
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||||
|
|
||||||
export class RemoveWorkflowDataLoadedFlag1671726148419 implements MigrationInterface {
|
export class RemoveWorkflowDataLoadedFlag1671726148419 implements MigrationInterface {
|
||||||
name = 'RemoveWorkflowDataLoadedFlag1671726148419';
|
name = 'RemoveWorkflowDataLoadedFlag1671726148419';
|
||||||
|
|
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 */
|
/* eslint-disable import/no-cycle */
|
||||||
import type { EventDestinations } from '@/databases/entities/MessageEventBusDestinationEntity';
|
import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity';
|
||||||
import { promClient } from '@/metrics';
|
import { promClient } from '@/metrics';
|
||||||
import {
|
import {
|
||||||
EventMessageTypeNames,
|
EventMessageTypeNames,
|
||||||
LoggerProxy,
|
LoggerProxy,
|
||||||
MessageEventBusDestinationTypeNames,
|
MessageEventBusDestinationTypeNames,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import config from '../../config';
|
import config from '@/config';
|
||||||
import type { EventMessageTypes } from '../EventMessageClasses';
|
import type { EventMessageTypes } from '../EventMessageClasses';
|
||||||
import type { MessageEventBusDestination } from './MessageEventBusDestination.ee';
|
import type { MessageEventBusDestination } from './MessageEventBusDestination.ee';
|
||||||
import { MessageEventBusDestinationSentry } from './MessageEventBusDestinationSentry.ee';
|
import { MessageEventBusDestinationSentry } from './MessageEventBusDestinationSentry.ee';
|
||||||
|
|
|
@ -18,10 +18,10 @@ import {
|
||||||
MessageEventBusDestinationWebhookParameterItem,
|
MessageEventBusDestinationWebhookParameterItem,
|
||||||
MessageEventBusDestinationWebhookParameterOptions,
|
MessageEventBusDestinationWebhookParameterOptions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { CredentialsHelper } from '../../CredentialsHelper';
|
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||||
import { UserSettings } from 'n8n-core';
|
import { UserSettings } from 'n8n-core';
|
||||||
import { Agent as HTTPSAgent } from 'https';
|
import { Agent as HTTPSAgent } from 'https';
|
||||||
import config from '../../config';
|
import config from '@/config';
|
||||||
import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper';
|
import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper';
|
||||||
import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric';
|
import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric';
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { INode, IRun, IWorkflowBase } from 'n8n-workflow';
|
import { INode, IRun, IWorkflowBase } from 'n8n-workflow';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import { InternalHooksManager } from '@/InternalHooksManager';
|
import { InternalHooksManager } from '@/InternalHooksManager';
|
||||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||||
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
|
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
|
||||||
import { QueryFailedError } from 'typeorm';
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { User } from '@/databases/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
|
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
|
||||||
import { ExecutionsService } from './executions.service';
|
import { ExecutionsService } from './executions.service';
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { FindOperator, FindOptionsWhere, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm';
|
import { FindOperator, FindOptionsWhere, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm';
|
||||||
import * as ActiveExecutions from '@/ActiveExecutions';
|
import * as ActiveExecutions from '@/ActiveExecutions';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@/databases/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
|
import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
|
||||||
import {
|
import {
|
||||||
IExecutionFlattedResponse,
|
IExecutionFlattedResponse,
|
||||||
|
|
|
@ -1,32 +1,80 @@
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
import type { Application, NextFunction, Request, RequestHandler, Response } from 'express';
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
import jwt from 'jsonwebtoken';
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import passport from 'passport';
|
import passport from 'passport';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { LoggerProxy as Logger } from 'n8n-workflow';
|
import { LoggerProxy as Logger } from 'n8n-workflow';
|
||||||
import { N8nApp } from '../Interfaces';
|
import { JwtPayload } from '@/Interfaces';
|
||||||
import { AuthenticatedRequest } from '@/requests';
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
|
import config from '@/config';
|
||||||
|
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||||
|
import { issueCookie, resolveJwtContent } from '@/auth/jwt';
|
||||||
import {
|
import {
|
||||||
|
isAuthenticatedRequest,
|
||||||
isAuthExcluded,
|
isAuthExcluded,
|
||||||
isPostUsersId,
|
isPostUsersId,
|
||||||
isAuthenticatedRequest,
|
|
||||||
isUserManagementDisabled,
|
isUserManagementDisabled,
|
||||||
} from '../UserManagementHelper';
|
} from '@/UserManagement/UserManagementHelper';
|
||||||
import * as Db from '@/Db';
|
import type { Repository } from 'typeorm';
|
||||||
import { jwtAuth, refreshExpiringCookie } from '../middlewares';
|
import type { User } from '@db/entities/User';
|
||||||
import { authenticationMethods } from './auth';
|
|
||||||
import { meNamespace } from './me';
|
|
||||||
import { usersNamespace } from './users';
|
|
||||||
import { passwordResetNamespace } from './passwordReset';
|
|
||||||
import { ownerNamespace } from './owner';
|
|
||||||
|
|
||||||
export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void {
|
const jwtFromRequest = (req: Request) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jwtAuth = (): RequestHandler => {
|
||||||
|
const jwtStrategy = new Strategy(
|
||||||
|
{
|
||||||
|
jwtFromRequest,
|
||||||
|
secretOrKey: config.getEnv('userManagement.jwtSecret'),
|
||||||
|
},
|
||||||
|
async (jwtPayload: JwtPayload, done) => {
|
||||||
|
try {
|
||||||
|
const user = await resolveJwtContent(jwtPayload);
|
||||||
|
return done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.debug('Failed to extract user from JWT payload', { jwtPayload });
|
||||||
|
return done(null, false, { message: 'User not found' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
passport.use(jwtStrategy);
|
||||||
|
return passport.initialize();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* middleware to refresh cookie before it expires
|
||||||
|
*/
|
||||||
|
const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
const cookieAuth = jwtFromRequest(req);
|
||||||
|
if (cookieAuth && req.user) {
|
||||||
|
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
|
||||||
|
if (cookieContents.exp * 1000 - Date.now() < 259200000) {
|
||||||
|
// if cookie expires in < 3 days, renew it.
|
||||||
|
await issueCookie(res, req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This sets up the auth middlewares in the correct order
|
||||||
|
*/
|
||||||
|
export const setupAuthMiddlewares = (
|
||||||
|
app: Application,
|
||||||
|
ignoredEndpoints: Readonly<string[]>,
|
||||||
|
restEndpoint: string,
|
||||||
|
userRepository: Repository<User>,
|
||||||
|
) => {
|
||||||
// needed for testing; not adding overhead since it directly returns if req.cookies exists
|
// needed for testing; not adding overhead since it directly returns if req.cookies exists
|
||||||
this.app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
this.app.use(jwtAuth());
|
app.use(jwtAuth());
|
||||||
|
|
||||||
this.app.use(async (req: Request, res: Response, next: NextFunction) => {
|
app.use(async (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (
|
if (
|
||||||
// TODO: refactor me!!!
|
// TODO: refactor me!!!
|
||||||
// skip authentication for preflight requests
|
// skip authentication for preflight requests
|
||||||
|
@ -54,17 +102,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
|
||||||
|
|
||||||
// skip authentication if user management is disabled
|
// skip authentication if user management is disabled
|
||||||
if (isUserManagementDisabled()) {
|
if (isUserManagementDisabled()) {
|
||||||
req.user = await Db.collections.User.findOneOrFail({
|
req.user = await userRepository.findOneOrFail({
|
||||||
relations: ['globalRole'],
|
relations: ['globalRole'],
|
||||||
where: {},
|
where: {},
|
||||||
});
|
});
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return passport.authenticate('jwt', { session: false })(req, res, next);
|
return passportMiddleware(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => {
|
app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
// req.user is empty for public routes, so just proceed
|
// req.user is empty for public routes, so just proceed
|
||||||
// owner can do anything, so proceed as well
|
// owner can do anything, so proceed as well
|
||||||
if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) {
|
if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) {
|
||||||
|
@ -73,17 +121,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
|
||||||
}
|
}
|
||||||
// Not owner and user exists. We now protect restricted urls.
|
// Not owner and user exists. We now protect restricted urls.
|
||||||
const postRestrictedUrls = [
|
const postRestrictedUrls = [
|
||||||
`/${this.restEndpoint}/users`,
|
`/${restEndpoint}/users`,
|
||||||
`/${this.restEndpoint}/owner`,
|
`/${restEndpoint}/owner`,
|
||||||
`/${this.restEndpoint}/ldap/sync`,
|
`/${restEndpoint}/ldap/sync`,
|
||||||
`/${this.restEndpoint}/ldap/test-connection`,
|
`/${restEndpoint}/ldap/test-connection`,
|
||||||
];
|
];
|
||||||
const getRestrictedUrls = [
|
const getRestrictedUrls = [
|
||||||
`/${this.restEndpoint}/users`,
|
`/${restEndpoint}/users`,
|
||||||
`/${this.restEndpoint}/ldap/sync`,
|
`/${restEndpoint}/ldap/sync`,
|
||||||
`/${this.restEndpoint}/ldap/config`,
|
`/${restEndpoint}/ldap/config`,
|
||||||
];
|
];
|
||||||
const putRestrictedUrls = [`/${this.restEndpoint}/ldap/config`];
|
const putRestrictedUrls = [`/${restEndpoint}/ldap/config`];
|
||||||
const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url;
|
const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url;
|
||||||
if (
|
if (
|
||||||
(req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) ||
|
(req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) ||
|
||||||
|
@ -106,11 +154,5 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.use(refreshExpiringCookie);
|
app.use(refreshExpiringCookie);
|
||||||
|
};
|
||||||
authenticationMethods.apply(this);
|
|
||||||
ownerNamespace.apply(this);
|
|
||||||
meNamespace.apply(this);
|
|
||||||
passwordResetNamespace.apply(this);
|
|
||||||
usersNamespace.apply(this);
|
|
||||||
}
|
|
|
@ -1 +1,2 @@
|
||||||
export * from './auth';
|
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,
|
IWorkflowSettings,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type { IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
|
import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
|
import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
|
||||||
import type { PublicUser } from '@/UserManagement/Interfaces';
|
|
||||||
|
|
||||||
export type AuthlessRequest<
|
export type AuthlessRequest<
|
||||||
RouteParams = {},
|
RouteParams = {},
|
||||||
|
|
|
@ -5,8 +5,8 @@ import * as Db from '@/Db';
|
||||||
import { toReportTitle } from '@/audit/utils';
|
import { toReportTitle } from '@/audit/utils';
|
||||||
import * as constants from '@/constants';
|
import * as constants from '@/constants';
|
||||||
import type { Risk } from '@/audit/types';
|
import type { Risk } from '@/audit/types';
|
||||||
import type { InstalledNodes } from '@/databases/entities/InstalledNodes';
|
import type { InstalledNodes } from '@db/entities/InstalledNodes';
|
||||||
import type { InstalledPackages } from '@/databases/entities/InstalledPackages';
|
import type { InstalledPackages } from '@db/entities/InstalledPackages';
|
||||||
|
|
||||||
type GetSectionKind<C extends Risk.Category> = C extends 'instance'
|
type GetSectionKind<C extends Risk.Category> = C extends 'instance'
|
||||||
? Risk.InstanceSection
|
? Risk.InstanceSection
|
||||||
|
|
|
@ -16,16 +16,12 @@ let globalMemberRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['auth'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -276,6 +272,60 @@ test('GET /login should return logged-in member', async () => {
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /resolve-signup-token should validate invite token', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
const memberShell = await testDb.createUserShell(globalMemberRole);
|
||||||
|
|
||||||
|
const response = await authAgent(owner)
|
||||||
|
.get('/resolve-signup-token')
|
||||||
|
.query({ inviterId: owner.id })
|
||||||
|
.query({ inviteeId: memberShell.id });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
data: {
|
||||||
|
inviter: {
|
||||||
|
firstName: owner.firstName,
|
||||||
|
lastName: owner.lastName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /resolve-signup-token should fail with invalid inputs', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const authOwnerAgent = authAgent(owner);
|
||||||
|
|
||||||
|
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id });
|
||||||
|
|
||||||
|
const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId });
|
||||||
|
|
||||||
|
const third = await authOwnerAgent.get('/resolve-signup-token').query({
|
||||||
|
inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d',
|
||||||
|
inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d',
|
||||||
|
});
|
||||||
|
|
||||||
|
// user is already set up, so call should error
|
||||||
|
const fourth = await authOwnerAgent
|
||||||
|
.get('/resolve-signup-token')
|
||||||
|
.query({ inviterId: owner.id })
|
||||||
|
.query({ inviteeId });
|
||||||
|
|
||||||
|
// cause inconsistent DB state
|
||||||
|
await Db.collections.User.update(owner.id, { email: '' });
|
||||||
|
const fifth = await authOwnerAgent
|
||||||
|
.get('/resolve-signup-token')
|
||||||
|
.query({ inviterId: owner.id })
|
||||||
|
.query({ inviteeId });
|
||||||
|
|
||||||
|
for (const response of [first, second, third, fourth, fifth]) {
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /logout should log user out', async () => {
|
test('POST /logout should log user out', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
|
|
@ -16,18 +16,11 @@ let globalMemberRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] });
|
||||||
applyAuth: true,
|
|
||||||
endpointGroups: ['me', 'auth', 'owner', 'users'],
|
|
||||||
});
|
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
import express from 'express';
|
|
||||||
|
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import { Reset } from '@/commands/user-management/reset';
|
import { Reset } from '@/commands/user-management/reset';
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import * as utils from '../shared/utils';
|
|
||||||
import * as testDb from '../shared/testDb';
|
import * as testDb from '../shared/testDb';
|
||||||
|
|
||||||
let app: express.Application;
|
|
||||||
let globalOwnerRole: Role;
|
let globalOwnerRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
|
|
|
@ -22,11 +22,7 @@ let authAgent: AuthAgent;
|
||||||
let sharingSpy: jest.SpyInstance<boolean>;
|
let sharingSpy: jest.SpyInstance<boolean>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({ endpointGroups: ['credentials'] });
|
||||||
endpointGroups: ['credentials'],
|
|
||||||
applyAuth: true,
|
|
||||||
});
|
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
|
|
||||||
|
@ -37,9 +33,6 @@ beforeAll(async () => {
|
||||||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
|
|
||||||
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -25,11 +25,7 @@ let saveCredential: SaveCredentialFunction;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({ endpointGroups: ['credentials'] });
|
||||||
endpointGroups: ['credentials'],
|
|
||||||
applyAuth: true,
|
|
||||||
});
|
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
|
|
||||||
|
@ -40,9 +36,6 @@ beforeAll(async () => {
|
||||||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -81,12 +81,11 @@ async function confirmIdSent(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testDb.init();
|
app = await utils.initTestServer({ endpointGroups: ['eventBus'] });
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
app = await utils.initTestServer({ endpointGroups: ['eventBus'], applyAuth: true });
|
|
||||||
|
|
||||||
unAuthOwnerAgent = utils.createAgent(app, {
|
unAuthOwnerAgent = utils.createAgent(app, {
|
||||||
apiPath: 'internal',
|
apiPath: 'internal',
|
||||||
auth: false,
|
auth: false,
|
||||||
|
@ -104,7 +103,6 @@ beforeAll(async () => {
|
||||||
mockedSyslog.createClient.mockImplementation(() => new syslog.Client());
|
mockedSyslog.createClient.mockImplementation(() => new syslog.Client());
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
utils.initTestLogger();
|
|
||||||
config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter');
|
config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter');
|
||||||
config.set('eventBus.logWriter.keepLogCount', '1');
|
config.set('eventBus.logWriter.keepLogCount', '1');
|
||||||
config.set('enterprise.features.logStreaming', true);
|
config.set('enterprise.features.logStreaming', true);
|
||||||
|
|
|
@ -40,8 +40,7 @@ const defaultLdapConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testDb.init();
|
app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'] });
|
||||||
app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'], applyAuth: true });
|
|
||||||
|
|
||||||
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles();
|
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles();
|
||||||
|
|
||||||
|
@ -56,8 +55,6 @@ beforeAll(async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
await utils.initLdapManager();
|
await utils.initLdapManager();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -19,17 +19,13 @@ let authAgent: AuthAgent;
|
||||||
let license: License;
|
let license: License;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['license'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['license'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
|
|
||||||
config.set('license.serverUrl', MOCK_SERVER_URL);
|
config.set('license.serverUrl', MOCK_SERVER_URL);
|
||||||
config.set('license.autoRenewEnabled', true);
|
config.set('license.autoRenewEnabled', true);
|
||||||
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
|
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
|
||||||
|
|
|
@ -23,16 +23,12 @@ let globalMemberRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['me'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -47,16 +47,13 @@ let globalOwnerRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['nodes'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['nodes'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -19,15 +19,11 @@ let globalOwnerRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['owner'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -21,14 +21,10 @@ let globalOwnerRole: Role;
|
||||||
let globalMemberRole: Role;
|
let globalMemberRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['passwordReset'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
utils.initTestLogger();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -23,7 +23,6 @@ beforeAll(async () => {
|
||||||
applyAuth: false,
|
applyAuth: false,
|
||||||
enablePublicAPI: true,
|
enablePublicAPI: true,
|
||||||
});
|
});
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
|
|
||||||
|
@ -36,8 +35,6 @@ beforeAll(async () => {
|
||||||
|
|
||||||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
utils.initCredentialsTypes();
|
utils.initCredentialsTypes();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -18,13 +18,9 @@ beforeAll(async () => {
|
||||||
applyAuth: false,
|
applyAuth: false,
|
||||||
enablePublicAPI: true,
|
enablePublicAPI: true,
|
||||||
});
|
});
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
utils.initTestLogger();
|
|
||||||
|
|
||||||
await utils.initBinaryManager();
|
await utils.initBinaryManager();
|
||||||
await utils.initNodeTypes();
|
await utils.initNodeTypes();
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,6 @@ beforeAll(async () => {
|
||||||
applyAuth: false,
|
applyAuth: false,
|
||||||
enablePublicAPI: true,
|
enablePublicAPI: true,
|
||||||
});
|
});
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] =
|
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] =
|
||||||
await testDb.getAllRoles();
|
await testDb.getAllRoles();
|
||||||
|
@ -31,8 +30,6 @@ beforeAll(async () => {
|
||||||
globalMemberRole = fetchedGlobalMemberRole;
|
globalMemberRole = fetchedGlobalMemberRole;
|
||||||
workflowOwnerRole = fetchedWorkflowOwnerRole;
|
workflowOwnerRole = fetchedWorkflowOwnerRole;
|
||||||
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
await utils.initNodeTypes();
|
await utils.initNodeTypes();
|
||||||
workflowRunner = await utils.initActiveWorkflowRunner();
|
workflowRunner = await utils.initActiveWorkflowRunner();
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
toCronExpression,
|
toCronExpression,
|
||||||
TriggerTime,
|
TriggerTime,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { N8nApp } from '@/UserManagement/Interfaces';
|
|
||||||
import superagent from 'superagent';
|
import superagent from 'superagent';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
@ -34,11 +33,6 @@ import { ExternalHooks } from '@/ExternalHooks';
|
||||||
import { InternalHooksManager } from '@/InternalHooksManager';
|
import { InternalHooksManager } from '@/InternalHooksManager';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import * as ActiveWorkflowRunner from '@/ActiveWorkflowRunner';
|
import * as ActiveWorkflowRunner from '@/ActiveWorkflowRunner';
|
||||||
import { meNamespace as meEndpoints } from '@/UserManagement/routes/me';
|
|
||||||
import { usersNamespace as usersEndpoints } from '@/UserManagement/routes/users';
|
|
||||||
import { authenticationMethods as authEndpoints } from '@/UserManagement/routes/auth';
|
|
||||||
import { ownerNamespace as ownerEndpoints } from '@/UserManagement/routes/owner';
|
|
||||||
import { passwordResetNamespace as passwordResetEndpoints } from '@/UserManagement/routes/passwordReset';
|
|
||||||
import { nodesController } from '@/api/nodes.api';
|
import { nodesController } from '@/api/nodes.api';
|
||||||
import { workflowsController } from '@/workflows/workflows.controller';
|
import { workflowsController } from '@/workflows/workflows.controller';
|
||||||
import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants';
|
import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants';
|
||||||
|
@ -47,8 +41,8 @@ import { InstalledPackages } from '@db/entities/InstalledPackages';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { getLogger } from '@/Logger';
|
import { getLogger } from '@/Logger';
|
||||||
import { loadPublicApiVersions } from '@/PublicApi/';
|
import { loadPublicApiVersions } from '@/PublicApi/';
|
||||||
import { issueJWT } from '@/UserManagement/auth/jwt';
|
import { issueJWT } from '@/auth/jwt';
|
||||||
import { addRoutes as authMiddleware } from '@/UserManagement/routes';
|
import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
|
||||||
import {
|
import {
|
||||||
AUTHLESS_ENDPOINTS,
|
AUTHLESS_ENDPOINTS,
|
||||||
COMMUNITY_NODE_VERSION,
|
COMMUNITY_NODE_VERSION,
|
||||||
|
@ -66,9 +60,19 @@ import type {
|
||||||
} from './types';
|
} from './types';
|
||||||
import { licenseController } from '@/license/license.controller';
|
import { licenseController } from '@/license/license.controller';
|
||||||
import { eventBusRouter } from '@/eventbus/eventBusRoutes';
|
import { eventBusRouter } from '@/eventbus/eventBusRoutes';
|
||||||
|
import { registerController } from '@/decorators';
|
||||||
|
import {
|
||||||
|
AuthController,
|
||||||
|
MeController,
|
||||||
|
OwnerController,
|
||||||
|
PasswordResetController,
|
||||||
|
UsersController,
|
||||||
|
} from '@/controllers';
|
||||||
|
import { setupAuthMiddlewares } from '@/middlewares';
|
||||||
|
import * as testDb from '../shared/testDb';
|
||||||
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { handleLdapInit } from '../../../src/Ldap/helpers';
|
import { handleLdapInit } from '@/Ldap/helpers';
|
||||||
import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
|
import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
|
||||||
|
|
||||||
const loadNodesAndCredentials: INodesAndCredentials = {
|
const loadNodesAndCredentials: INodesAndCredentials = {
|
||||||
|
@ -84,14 +88,15 @@ CredentialTypes(loadNodesAndCredentials);
|
||||||
* Initialize a test server.
|
* Initialize a test server.
|
||||||
*/
|
*/
|
||||||
export async function initTestServer({
|
export async function initTestServer({
|
||||||
applyAuth,
|
applyAuth = true,
|
||||||
endpointGroups,
|
endpointGroups,
|
||||||
enablePublicAPI = false,
|
enablePublicAPI = false,
|
||||||
}: {
|
}: {
|
||||||
applyAuth: boolean;
|
applyAuth?: boolean;
|
||||||
endpointGroups?: EndpointGroup[];
|
endpointGroups?: EndpointGroup[];
|
||||||
enablePublicAPI?: boolean;
|
enablePublicAPI?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
await testDb.init();
|
||||||
const testServer = {
|
const testServer = {
|
||||||
app: express(),
|
app: express(),
|
||||||
restEndpoint: REST_PATH_SEGMENT,
|
restEndpoint: REST_PATH_SEGMENT,
|
||||||
|
@ -99,6 +104,12 @@ export async function initTestServer({
|
||||||
externalHooks: {},
|
externalHooks: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
LoggerProxy.init(logger);
|
||||||
|
|
||||||
|
// Pre-requisite: Mock the telemetry module before calling.
|
||||||
|
await InternalHooksManager.init('test-instance-id', mockNodeTypes);
|
||||||
|
|
||||||
testServer.app.use(bodyParser.json());
|
testServer.app.use(bodyParser.json());
|
||||||
testServer.app.use(bodyParser.urlencoded({ extended: true }));
|
testServer.app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
@ -106,7 +117,12 @@ export async function initTestServer({
|
||||||
config.set('userManagement.isInstanceOwnerSetUp', false);
|
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||||
|
|
||||||
if (applyAuth) {
|
if (applyAuth) {
|
||||||
authMiddleware.apply(testServer, [AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT]);
|
setupAuthMiddlewares(
|
||||||
|
testServer.app,
|
||||||
|
AUTHLESS_ENDPOINTS,
|
||||||
|
REST_PATH_SEGMENT,
|
||||||
|
Db.collections.User,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!endpointGroups) return testServer.app;
|
if (!endpointGroups) return testServer.app;
|
||||||
|
@ -147,36 +163,75 @@ export async function initTestServer({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (functionEndpoints.length) {
|
if (functionEndpoints.length) {
|
||||||
const map: Record<string, (this: N8nApp) => void> = {
|
const externalHooks = ExternalHooks();
|
||||||
me: meEndpoints,
|
const internalHooks = InternalHooksManager.getInstance();
|
||||||
users: usersEndpoints,
|
const mailer = UserManagementMailer.getInstance();
|
||||||
auth: authEndpoints,
|
const repositories = Db.collections;
|
||||||
owner: ownerEndpoints,
|
|
||||||
passwordReset: passwordResetEndpoints,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const group of functionEndpoints) {
|
for (const group of functionEndpoints) {
|
||||||
map[group].apply(testServer);
|
switch (group) {
|
||||||
|
case 'auth':
|
||||||
|
registerController(
|
||||||
|
testServer.app,
|
||||||
|
config,
|
||||||
|
new AuthController({ config, logger, internalHooks, repositories }),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'me':
|
||||||
|
registerController(
|
||||||
|
testServer.app,
|
||||||
|
config,
|
||||||
|
new MeController({ logger, externalHooks, internalHooks, repositories }),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'passwordReset':
|
||||||
|
registerController(
|
||||||
|
testServer.app,
|
||||||
|
config,
|
||||||
|
new PasswordResetController({
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
externalHooks,
|
||||||
|
internalHooks,
|
||||||
|
repositories,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'owner':
|
||||||
|
registerController(
|
||||||
|
testServer.app,
|
||||||
|
config,
|
||||||
|
new OwnerController({ config, logger, internalHooks, repositories }),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'users':
|
||||||
|
registerController(
|
||||||
|
testServer.app,
|
||||||
|
config,
|
||||||
|
new UsersController({
|
||||||
|
config,
|
||||||
|
mailer,
|
||||||
|
externalHooks,
|
||||||
|
internalHooks,
|
||||||
|
repositories,
|
||||||
|
activeWorkflowRunner: ActiveWorkflowRunner.getInstance(),
|
||||||
|
logger,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return testServer.app;
|
return testServer.app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-requisite: Mock the telemetry module before calling.
|
|
||||||
*/
|
|
||||||
export function initTestTelemetry() {
|
|
||||||
void InternalHooksManager.init('test-instance-id', mockNodeTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`),
|
* Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`),
|
||||||
* and `functionEndpoints` (legacy, namespaced inside a function).
|
* and `functionEndpoints` (legacy, namespaced inside a function).
|
||||||
*/
|
*/
|
||||||
const classifyEndpointGroups = (endpointGroups: string[]) => {
|
const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => {
|
||||||
const routerEndpoints: string[] = [];
|
const routerEndpoints: EndpointGroup[] = [];
|
||||||
const functionEndpoints: string[] = [];
|
const functionEndpoints: EndpointGroup[] = [];
|
||||||
|
|
||||||
const ROUTER_GROUP = [
|
const ROUTER_GROUP = [
|
||||||
'credentials',
|
'credentials',
|
||||||
|
@ -559,13 +614,6 @@ export async function initNodeTypes() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize a logger for test runs.
|
|
||||||
*/
|
|
||||||
export function initTestLogger() {
|
|
||||||
LoggerProxy.init(getLogger());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize a BinaryManager for test runs.
|
* Initialize a BinaryManager for test runs.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -33,8 +33,7 @@ let credentialOwnerRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['users'] });
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
const [
|
const [
|
||||||
fetchedGlobalOwnerRole,
|
fetchedGlobalOwnerRole,
|
||||||
|
@ -49,9 +48,6 @@ beforeAll(async () => {
|
||||||
credentialOwnerRole = fetchedCredentialOwnerRole;
|
credentialOwnerRole = fetchedCredentialOwnerRole;
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
utils.initTestLogger();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -241,60 +237,6 @@ test('DELETE /users/:id with transferId should perform transfer', async () => {
|
||||||
expect(deletedUser).toBeNull();
|
expect(deletedUser).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /resolve-signup-token should validate invite token', async () => {
|
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
|
||||||
|
|
||||||
const memberShell = await testDb.createUserShell(globalMemberRole);
|
|
||||||
|
|
||||||
const response = await authAgent(owner)
|
|
||||||
.get('/resolve-signup-token')
|
|
||||||
.query({ inviterId: owner.id })
|
|
||||||
.query({ inviteeId: memberShell.id });
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
data: {
|
|
||||||
inviter: {
|
|
||||||
firstName: owner.firstName,
|
|
||||||
lastName: owner.lastName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /resolve-signup-token should fail with invalid inputs', async () => {
|
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
|
||||||
const authOwnerAgent = authAgent(owner);
|
|
||||||
|
|
||||||
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
|
|
||||||
|
|
||||||
const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id });
|
|
||||||
|
|
||||||
const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId });
|
|
||||||
|
|
||||||
const third = await authOwnerAgent.get('/resolve-signup-token').query({
|
|
||||||
inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d',
|
|
||||||
inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d',
|
|
||||||
});
|
|
||||||
|
|
||||||
// user is already set up, so call should error
|
|
||||||
const fourth = await authOwnerAgent
|
|
||||||
.get('/resolve-signup-token')
|
|
||||||
.query({ inviterId: owner.id })
|
|
||||||
.query({ inviteeId });
|
|
||||||
|
|
||||||
// cause inconsistent DB state
|
|
||||||
await Db.collections.User.update(owner.id, { email: '' });
|
|
||||||
const fifth = await authOwnerAgent
|
|
||||||
.get('/resolve-signup-token')
|
|
||||||
.query({ inviterId: owner.id })
|
|
||||||
.query({ inviteeId });
|
|
||||||
|
|
||||||
for (const response of [first, second, third, fourth, fifth]) {
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /users/:id should fill out a user shell', async () => {
|
test('POST /users/:id should fill out a user shell', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
|
|
@ -24,11 +24,7 @@ let workflowRunner: ActiveWorkflowRunner;
|
||||||
let sharingSpy: jest.SpyInstance<boolean>;
|
let sharingSpy: jest.SpyInstance<boolean>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({ endpointGroups: ['workflows'] });
|
||||||
endpointGroups: ['workflows'],
|
|
||||||
applyAuth: true,
|
|
||||||
});
|
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
@ -38,9 +34,6 @@ beforeAll(async () => {
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
|
|
||||||
isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
await utils.initNodeTypes();
|
await utils.initNodeTypes();
|
||||||
|
|
|
@ -15,16 +15,9 @@ let globalOwnerRole: Role;
|
||||||
jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false);
|
jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({ endpointGroups: ['workflows'] });
|
||||||
endpointGroups: ['workflows'],
|
|
||||||
applyAuth: true,
|
|
||||||
});
|
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
|
|
||||||
utils.initTestLogger();
|
|
||||||
utils.initTestTelemetry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { InternalHooksManager } from '@/InternalHooksManager';
|
||||||
import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics';
|
import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics';
|
||||||
import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow';
|
import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow';
|
||||||
import { getLogger } from '@/Logger';
|
import { getLogger } from '@/Logger';
|
||||||
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
import { StatisticsNames } from '@db/entities/WorkflowStatistics';
|
||||||
import { QueryFailedError } from 'typeorm';
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
|
||||||
const FAKE_USER_ID = 'abcde-fghij';
|
const FAKE_USER_ID = 'abcde-fghij';
|
||||||
|
|
|
@ -20,10 +20,10 @@ import {
|
||||||
randomPositiveDigit,
|
randomPositiveDigit,
|
||||||
} from '../integration/shared/random';
|
} from '../integration/shared/random';
|
||||||
|
|
||||||
import { Role } from '@/databases/entities/Role';
|
import { Role } from '@db/entities/Role';
|
||||||
import type { SaveCredentialFunction } from '../integration/shared/types';
|
import type { SaveCredentialFunction } from '../integration/shared/types';
|
||||||
import { User } from '@/databases/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
|
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
||||||
|
|
||||||
let mockNodeTypes: INodeTypes;
|
let mockNodeTypes: INodeTypes;
|
||||||
let credentialOwnerRole: Role;
|
let credentialOwnerRole: Role;
|
||||||
|
|
Loading…
Reference in a new issue