From 845f0f9d20ddebce61f7b21cad5db01cfa1cba21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Fri, 27 Jan 2023 11:19:47 +0100 Subject: [PATCH] refactor(core): Switch over all user-management routes to use decorators (#5115) --- cypress/e2e/4-node-creator.cy.ts | 2 +- cypress/pages/features/node-creator.ts | 2 +- packages/cli/src/AbstractServer.ts | 2 +- packages/cli/src/Interfaces.ts | 43 +- packages/cli/src/Ldap/helpers.ts | 2 +- packages/cli/src/Server.ts | 48 +- packages/cli/src/UserManagement/Interfaces.ts | 39 -- .../UserManagement/UserManagementHelper.ts | 7 +- packages/cli/src/UserManagement/index.ts | 3 - .../src/UserManagement/middlewares/auth.ts | 55 -- .../cli/src/UserManagement/routes/auth.ts | 118 ---- packages/cli/src/UserManagement/routes/me.ts | 202 ------ .../cli/src/UserManagement/routes/owner.ts | 119 ---- .../UserManagement/routes/passwordReset.ts | 248 ------- .../cli/src/UserManagement/routes/users.ts | 610 ------------------ packages/cli/src/api/e2e.api.ts | 2 +- packages/cli/src/api/workflowStats.api.ts | 2 +- .../cli/src/audit/risks/credentials.risk.ts | 2 +- packages/cli/src/audit/risks/database.risk.ts | 2 +- .../cli/src/audit/risks/filesystem.risk.ts | 2 +- packages/cli/src/audit/risks/instance.risk.ts | 2 +- packages/cli/src/audit/risks/nodes.risk.ts | 2 +- packages/cli/src/audit/types.ts | 2 +- packages/cli/src/audit/utils.ts | 2 +- .../cli/src/{UserManagement => }/auth/jwt.ts | 2 +- .../cli/src/controllers/auth.controller.ts | 177 +++++ packages/cli/src/controllers/index.ts | 5 + packages/cli/src/controllers/me.controller.ts | 217 +++++++ .../cli/src/controllers/owner.controller.ts | 145 +++++ .../controllers/passwordReset.controller.ts | 268 ++++++++ .../cli/src/controllers/users.controller.ts | 562 ++++++++++++++++ ...1726148420-RemoveWorkflowDataLoadedFlag.ts | 2 +- .../1594828256133-CreateIndexStoppedAt.ts | 2 +- ...1726148421-RemoveWorkflowDataLoadedFlag.ts | 2 +- ...1726148419-RemoveWorkflowDataLoadedFlag.ts | 2 +- packages/cli/src/decorators/RestController.ts | 8 + packages/cli/src/decorators/Route.ts | 19 + packages/cli/src/decorators/constants.ts | 2 + packages/cli/src/decorators/index.ts | 3 + .../cli/src/decorators/registerController.ts | 34 + packages/cli/src/decorators/types.ts | 12 + .../MessageEventBusDestination/Helpers.ee.ts | 4 +- .../MessageEventBusDestinationWebhook.ee.ts | 4 +- packages/cli/src/events/WorkflowStatistics.ts | 2 +- .../src/executions/executions.service.ee.ts | 2 +- .../cli/src/executions/executions.service.ts | 2 +- .../routes/index.ts => middlewares/auth.ts} | 118 ++-- .../{UserManagement => }/middlewares/index.ts | 1 + packages/cli/src/requests.d.ts | 3 +- packages/cli/test/integration/audit/utils.ts | 4 +- .../cli/test/integration/auth.api.test.ts | 60 +- packages/cli/test/integration/auth.mw.test.ts | 9 +- .../integration/commands/reset.cmd.test.ts | 5 - .../test/integration/credentials.ee.test.ts | 9 +- .../cli/test/integration/credentials.test.ts | 9 +- .../cli/test/integration/eventbus.test.ts | 6 +- .../test/integration/ldap/ldap.api.test.ts | 5 +- .../cli/test/integration/license.api.test.ts | 6 +- packages/cli/test/integration/me.api.test.ts | 6 +- .../cli/test/integration/nodes.api.test.ts | 5 +- .../cli/test/integration/owner.api.test.ts | 6 +- .../integration/passwordReset.api.test.ts | 6 +- .../integration/publicApi/credentials.test.ts | 3 - .../integration/publicApi/executions.test.ts | 4 - .../integration/publicApi/workflows.test.ts | 3 - packages/cli/test/integration/shared/utils.ts | 122 ++-- .../cli/test/integration/users.api.test.ts | 60 +- .../workflows.controller.ee.test.ts | 9 +- .../integration/workflows.controller.test.ts | 9 +- packages/cli/test/unit/Events.test.ts | 2 +- .../cli/test/unit/PermissionChecker.test.ts | 6 +- 71 files changed, 1803 insertions(+), 1667 deletions(-) delete mode 100644 packages/cli/src/UserManagement/Interfaces.ts delete mode 100644 packages/cli/src/UserManagement/index.ts delete mode 100644 packages/cli/src/UserManagement/middlewares/auth.ts delete mode 100644 packages/cli/src/UserManagement/routes/auth.ts delete mode 100644 packages/cli/src/UserManagement/routes/me.ts delete mode 100644 packages/cli/src/UserManagement/routes/owner.ts delete mode 100644 packages/cli/src/UserManagement/routes/passwordReset.ts delete mode 100644 packages/cli/src/UserManagement/routes/users.ts rename packages/cli/src/{UserManagement => }/auth/jwt.ts (97%) create mode 100644 packages/cli/src/controllers/auth.controller.ts create mode 100644 packages/cli/src/controllers/index.ts create mode 100644 packages/cli/src/controllers/me.controller.ts create mode 100644 packages/cli/src/controllers/owner.controller.ts create mode 100644 packages/cli/src/controllers/passwordReset.controller.ts create mode 100644 packages/cli/src/controllers/users.controller.ts create mode 100644 packages/cli/src/decorators/RestController.ts create mode 100644 packages/cli/src/decorators/Route.ts create mode 100644 packages/cli/src/decorators/constants.ts create mode 100644 packages/cli/src/decorators/index.ts create mode 100644 packages/cli/src/decorators/registerController.ts create mode 100644 packages/cli/src/decorators/types.ts rename packages/cli/src/{UserManagement/routes/index.ts => middlewares/auth.ts} (50%) rename packages/cli/src/{UserManagement => }/middlewares/index.ts (50%) diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 84a2c18f32..84eb2d5f38 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -1,5 +1,5 @@ import { NodeCreator } from '../pages/features/node-creator'; -import { INodeTypeDescription } from '../../packages/workflow'; +import { INodeTypeDescription } from 'n8n-workflow'; import CustomNodeFixture from '../fixtures/Custom_node.json'; import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants'; import { randFirstName, randLastName } from '@ngneat/falso'; diff --git a/cypress/pages/features/node-creator.ts b/cypress/pages/features/node-creator.ts index 8d5ebfbc62..7db317a48e 100644 --- a/cypress/pages/features/node-creator.ts +++ b/cypress/pages/features/node-creator.ts @@ -1,5 +1,5 @@ import { BasePage } from '../base'; -import { INodeTypeDescription } from '../../packages/workflow'; +import { INodeTypeDescription } from 'n8n-workflow'; export class NodeCreator extends BasePage { url = '/workflow/new'; diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 89aac21972..1d7f02f8b9 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -25,7 +25,7 @@ import { sendSuccessResponse, ServiceUnavailableError, } from '@/ResponseHelper'; -import { corsMiddleware } from '@/middlewares/cors'; +import { corsMiddleware } from '@/middlewares'; import * as TestWebhooks from '@/TestWebhooks'; import { WaitingWebhooks } from '@/WaitingWebhooks'; import { WEBHOOK_METHODS } from '@/WebhookHelpers'; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index efbcd136a6..c0ad0d33ee 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import type { Application } from 'express'; import type { ExecutionError, ICredentialDataDecryptedObject, @@ -21,9 +22,11 @@ import type { WorkflowExecuteMode, } from 'n8n-workflow'; -import { WorkflowExecute } from 'n8n-core'; +import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import PCancelable from 'p-cancelable'; +import type { WorkflowExecute } from 'n8n-core'; + +import type PCancelable from 'p-cancelable'; import type { FindOperator, Repository } from 'typeorm'; import type { ChildProcess } from 'child_process'; @@ -365,6 +368,7 @@ export interface IInternalHooksClass { user: User; target_user_id: string[]; public_api: boolean; + email_sent: boolean; }): Promise; onUserReinvite(userReinviteData: { user: User; @@ -378,6 +382,7 @@ export interface IInternalHooksClass { userTransactionalEmailData: { user_id: string; message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + public_api: boolean; }, user?: User, ): Promise; @@ -841,3 +846,37 @@ export interface ILicenseReadResponse { export interface ILicensePostResponse extends ILicenseReadResponse { managementToken: string; } + +export interface JwtToken { + token: string; + expiresIn: number; +} + +export interface JwtPayload { + id: string; + email: string | null; + password: string | null; +} + +export interface PublicUser { + id: string; + email?: string; + firstName?: string; + lastName?: string; + personalizationAnswers?: IPersonalizationSurveyAnswers | null; + password?: string; + passwordResetToken?: string; + createdAt: Date; + isPending: boolean; + globalRole?: Role; + signInType: AuthProviderType; + disabled: boolean; + inviteAcceptUrl?: string; +} + +export interface N8nApp { + app: Application; + restEndpoint: string; + externalHooks: IExternalHooksClass; + activeWorkflowRunner: ActiveWorkflowRunner; +} diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index c8a8c3e140..927d23963f 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -8,7 +8,7 @@ import * as Db from '@/Db'; import config from '@/config'; import type { Role } from '@db/entities/Role'; import { User } from '@db/entities/User'; -import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import { LdapManager } from './LdapManager.ee'; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 483a279a82..bf8487cd57 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -63,9 +63,6 @@ import { WorkflowExecuteMode, INodeTypes, ICredentialTypes, - INode, - IWorkflowBase, - IRun, } from 'n8n-workflow'; import basicAuth from 'basic-auth'; @@ -103,8 +100,15 @@ import type { OAuthRequest, WorkflowRequest, } from '@/requests'; -import { userManagementRouter } from '@/UserManagement'; -import { resolveJwt } from '@/UserManagement/auth/jwt'; +import { registerController } from '@/decorators'; +import { + AuthController, + MeController, + OwnerController, + PasswordResetController, + UsersController, +} from '@/controllers'; +import { resolveJwt } from '@/auth/jwt'; import { executionsController } from '@/executions/executions.controller'; import { nodeTypesController } from '@/api/nodeTypes.api'; @@ -118,6 +122,7 @@ import { isUserManagementEnabled, whereClause, } from '@/UserManagement/UserManagementHelper'; +import { getInstance as getMailerInstance } from '@/UserManagement/email'; import * as Db from '@/Db'; import { DatabaseType, @@ -151,7 +156,7 @@ import { eventBusRouter } from '@/eventbus/eventBusRoutes'; import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper'; import { getLicense } from '@/License'; import { licenseController } from './license/license.controller'; -import { corsMiddleware } from './middlewares/cors'; +import { corsMiddleware, setupAuthMiddlewares } from './middlewares'; import { initEvents } from './events'; import { ldapController } from './Ldap/routes/ldap.controller.ee'; import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers'; @@ -336,6 +341,33 @@ class Server extends AbstractServer { } } + private registerControllers(ignoredEndpoints: Readonly) { + 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 { configureMetrics(this.app); @@ -354,7 +386,7 @@ class Server extends AbstractServer { const publicApiEndpoint = config.getEnv('publicApi.path'); const excludeEndpoints = config.getEnv('security.excludeEndpoints'); - const ignoredEndpoints = [ + const ignoredEndpoints: Readonly = [ 'assets', 'healthz', 'metrics', @@ -587,7 +619,7 @@ class Server extends AbstractServer { // ---------------------------------------- // User Management // ---------------------------------------- - await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]); + this.registerControllers(ignoredEndpoints); this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts deleted file mode 100644 index 6fa153247a..0000000000 --- a/packages/cli/src/UserManagement/Interfaces.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index e2841db9d1..37cef89cb6 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -6,14 +6,13 @@ import { compare, genSaltSync, hash } from 'bcryptjs'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; -import { PublicUser } from './Interfaces'; +import type { PublicUser, WhereClause } from '@/Interfaces'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '@db/entities/User'; import { Role } from '@db/entities/Role'; import { AuthenticatedRequest } from '@/requests'; import config from '@/config'; -import { getWebhookBaseUrl } from '../WebhookHelpers'; +import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { getLicense } from '@/License'; -import { WhereClause } from '@/Interfaces'; import { RoleService } from '@/role/role.service'; export async function getWorkflowOwner(workflowId: string): Promise { @@ -177,7 +176,7 @@ export async function getUserById(userId: string): Promise { /** * Check if a URL contains an auth-excluded endpoint. */ -export function isAuthExcluded(url: string, ignoredEndpoints: string[]): boolean { +export function isAuthExcluded(url: string, ignoredEndpoints: Readonly): boolean { return !!ignoredEndpoints .filter(Boolean) // skip empty paths .find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`)); diff --git a/packages/cli/src/UserManagement/index.ts b/packages/cli/src/UserManagement/index.ts deleted file mode 100644 index 56d5867e76..0000000000 --- a/packages/cli/src/UserManagement/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { addRoutes } from './routes'; - -export const userManagementRouter = { addRoutes }; diff --git a/packages/cli/src/UserManagement/middlewares/auth.ts b/packages/cli/src/UserManagement/middlewares/auth.ts deleted file mode 100644 index 0a3138211f..0000000000 --- a/packages/cli/src/UserManagement/middlewares/auth.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts deleted file mode 100644 index c105aacd33..0000000000 --- a/packages/cli/src/UserManagement/routes/auth.ts +++ /dev/null @@ -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 => { - 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 => { - // 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 => { - res.clearCookie(AUTH_COOKIE_NAME); - return { - loggedOut: true, - }; - }), - ); -} diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts deleted file mode 100644 index ef68d3b784..0000000000 --- a/packages/cli/src/UserManagement/routes/me.ts +++ /dev/null @@ -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 => { - 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 => { - 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 }; - }), - ); -} diff --git a/packages/cli/src/UserManagement/routes/owner.ts b/packages/cli/src/UserManagement/routes/owner.ts deleted file mode 100644 index 21511db258..0000000000 --- a/packages/cli/src/UserManagement/routes/owner.ts +++ /dev/null @@ -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 }; - }), - ); -} diff --git a/packages/cli/src/UserManagement/routes/passwordReset.ts b/packages/cli/src/UserManagement/routes/passwordReset.ts deleted file mode 100644 index 1951e7a367..0000000000 --- a/packages/cli/src/UserManagement/routes/passwordReset.ts +++ /dev/null @@ -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]); - }), - ); -} diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts deleted file mode 100644 index ffb5da2d61..0000000000 --- a/packages/cli/src/UserManagement/routes/users.ts +++ /dev/null @@ -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(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 }; - }), - ); -} diff --git a/packages/cli/src/api/e2e.api.ts b/packages/cli/src/api/e2e.api.ts index f0c3f39357..13d7f79651 100644 --- a/packages/cli/src/api/e2e.api.ts +++ b/packages/cli/src/api/e2e.api.ts @@ -9,7 +9,7 @@ import bodyParser from 'body-parser'; import { v4 as uuid } from 'uuid'; import config from '@/config'; import * as Db from '@/Db'; -import { Role } from '@/databases/entities/Role'; +import { Role } from '@db/entities/Role'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; diff --git a/packages/cli/src/api/workflowStats.api.ts b/packages/cli/src/api/workflowStats.api.ts index ec2f7b575d..157741e0af 100644 --- a/packages/cli/src/api/workflowStats.api.ts +++ b/packages/cli/src/api/workflowStats.api.ts @@ -1,4 +1,4 @@ -import { User } from '@/databases/entities/User'; +import { User } from '@db/entities/User'; import { whereClause } from '@/UserManagement/UserManagementHelper'; import express from 'express'; import { LoggerProxy } from 'n8n-workflow'; diff --git a/packages/cli/src/audit/risks/credentials.risk.ts b/packages/cli/src/audit/risks/credentials.risk.ts index df39f24fe3..49684facfb 100644 --- a/packages/cli/src/audit/risks/credentials.risk.ts +++ b/packages/cli/src/audit/risks/credentials.risk.ts @@ -3,7 +3,7 @@ import { DateUtils } from 'typeorm/util/DateUtils'; import * as Db from '@/Db'; import config from '@/config'; import { CREDENTIALS_REPORT } from '@/audit/constants'; -import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Risk } from '@/audit/types'; async function getAllCredsInUse(workflows: WorkflowEntity[]) { diff --git a/packages/cli/src/audit/risks/database.risk.ts b/packages/cli/src/audit/risks/database.risk.ts index 06e36f47f8..c0f62438c3 100644 --- a/packages/cli/src/audit/risks/database.risk.ts +++ b/packages/cli/src/audit/risks/database.risk.ts @@ -5,7 +5,7 @@ import { DB_QUERY_PARAMS_DOCS_URL, SQL_NODE_TYPES_WITH_QUERY_PARAMS, } from '@/audit/constants'; -import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity'; import type { Risk } from '@/audit/types'; function getIssues(workflows: Workflow[]) { diff --git a/packages/cli/src/audit/risks/filesystem.risk.ts b/packages/cli/src/audit/risks/filesystem.risk.ts index 177358abb4..6e1033d5a6 100644 --- a/packages/cli/src/audit/risks/filesystem.risk.ts +++ b/packages/cli/src/audit/risks/filesystem.risk.ts @@ -1,6 +1,6 @@ import { getNodeTypes } from '@/audit/utils'; import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants'; -import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Risk } from '@/audit/types'; export function reportFilesystemRisk(workflows: WorkflowEntity[]) { diff --git a/packages/cli/src/audit/risks/instance.risk.ts b/packages/cli/src/audit/risks/instance.risk.ts index f0edbe595e..0bd0c18fb3 100644 --- a/packages/cli/src/audit/risks/instance.risk.ts +++ b/packages/cli/src/audit/risks/instance.risk.ts @@ -11,7 +11,7 @@ import { WEBHOOK_VALIDATOR_NODE_TYPES, } from '@/audit/constants'; import { getN8nPackageJson, inDevelopment } from '@/constants'; -import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Risk, n8n } from '@/audit/types'; function getSecuritySettings() { diff --git a/packages/cli/src/audit/risks/nodes.risk.ts b/packages/cli/src/audit/risks/nodes.risk.ts index 94f041c1f3..032fd13663 100644 --- a/packages/cli/src/audit/risks/nodes.risk.ts +++ b/packages/cli/src/audit/risks/nodes.risk.ts @@ -10,7 +10,7 @@ import { COMMUNITY_NODES_RISKS_URL, NPM_PACKAGE_URL, } from '@/audit/constants'; -import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Risk } from '@/audit/types'; async function getCommunityNodeDetails() { diff --git a/packages/cli/src/audit/types.ts b/packages/cli/src/audit/types.ts index 73c1e5e044..aa88f21aa5 100644 --- a/packages/cli/src/audit/types.ts +++ b/packages/cli/src/audit/types.ts @@ -1,4 +1,4 @@ -import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity'; export namespace Risk { export type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem'; diff --git a/packages/cli/src/audit/utils.ts b/packages/cli/src/audit/utils.ts index 3a4f1a798d..9a0bf208e6 100644 --- a/packages/cli/src/audit/utils.ts +++ b/packages/cli/src/audit/utils.ts @@ -1,4 +1,4 @@ -import type { WorkflowEntity as Workflow } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity'; import type { Risk } from '@/audit/types'; type Node = Workflow['nodes'][number]; diff --git a/packages/cli/src/UserManagement/auth/jwt.ts b/packages/cli/src/auth/jwt.ts similarity index 97% rename from packages/cli/src/UserManagement/auth/jwt.ts rename to packages/cli/src/auth/jwt.ts index ad520917c9..277b806136 100644 --- a/packages/cli/src/UserManagement/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -5,7 +5,7 @@ import { Response } from 'express'; import { createHash } from 'crypto'; import * as Db from '@/Db'; import { AUTH_COOKIE_NAME } from '@/constants'; -import { JwtPayload, JwtToken } from '../Interfaces'; +import { JwtPayload, JwtToken } from '@/Interfaces'; import { User } from '@db/entities/User'; import config from '@/config'; import * as ResponseHelper from '@/ResponseHelper'; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts new file mode 100644 index 0000000000..f7e0ef753e --- /dev/null +++ b/packages/cli/src/controllers/auth.controller.ts @@ -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; + + constructor({ + config, + logger, + internalHooks, + repositories, + }: { + config: Config; + logger: ILogger; + internalHooks: IInternalHooksClass; + repositories: Pick; + }) { + 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 { + 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 { + // 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 }; + } +} diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts new file mode 100644 index 0000000000..2ee7c7fd06 --- /dev/null +++ b/packages/cli/src/controllers/index.ts @@ -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'; diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts new file mode 100644 index 0000000000..62db2b7d6b --- /dev/null +++ b/packages/cli/src/controllers/me.controller.ts @@ -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; + + constructor({ + logger, + externalHooks, + internalHooks, + repositories, + }: { + logger: ILogger; + externalHooks: IExternalHooksClass; + internalHooks: IInternalHooksClass; + repositories: Pick; + }) { + this.logger = logger; + this.externalHooks = externalHooks; + this.internalHooks = internalHooks; + this.userRepository = repositories.User; + } + + /** + * Return the logged-in user. + */ + @Get('/') + async getCurrentUser(req: AuthenticatedRequest): Promise { + return sanitizeUser(req.user); + } + + /** + * Update the logged-in user's settings, except password. + */ + @Patch('/') + async updateCurrentUser(req: MeRequest.Settings, res: Response): Promise { + 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 }; + } +} diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts new file mode 100644 index 0000000000..9882d8cdd8 --- /dev/null +++ b/packages/cli/src/controllers/owner.controller.ts @@ -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; + + private readonly settingsRepository: Repository; + + constructor({ + config, + logger, + internalHooks, + repositories, + }: { + config: Config; + logger: ILogger; + internalHooks: IInternalHooksClass; + repositories: Pick; + }) { + 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 }; + } +} diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts new file mode 100644 index 0000000000..3d32d60512 --- /dev/null +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -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; + + constructor({ + config, + logger, + externalHooks, + internalHooks, + repositories, + }: { + config: Config; + logger: ILogger; + externalHooks: IExternalHooksClass; + internalHooks: IInternalHooksClass; + repositories: Pick; + }) { + 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]); + } +} diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts new file mode 100644 index 0000000000..5a7a0636d7 --- /dev/null +++ b/packages/cli/src/controllers/users.controller.ts @@ -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; + + private roleRepository: Repository; + + private sharedCredentialsRepository: Repository; + + private sharedWorkflowRepository: Repository; + + 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(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 }; + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/1671726148420-RemoveWorkflowDataLoadedFlag.ts b/packages/cli/src/databases/migrations/mysqldb/1671726148420-RemoveWorkflowDataLoadedFlag.ts index a789738972..471548d086 100644 --- a/packages/cli/src/databases/migrations/mysqldb/1671726148420-RemoveWorkflowDataLoadedFlag.ts +++ b/packages/cli/src/databases/migrations/mysqldb/1671726148420-RemoveWorkflowDataLoadedFlag.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; import config from '@/config'; -import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; +import { StatisticsNames } from '@db/entities/WorkflowStatistics'; export class RemoveWorkflowDataLoadedFlag1671726148420 implements MigrationInterface { name = 'RemoveWorkflowDataLoadedFlag1671726148420'; diff --git a/packages/cli/src/databases/migrations/postgresdb/1594828256133-CreateIndexStoppedAt.ts b/packages/cli/src/databases/migrations/postgresdb/1594828256133-CreateIndexStoppedAt.ts index 3b6b795e60..f8d315e54f 100644 --- a/packages/cli/src/databases/migrations/postgresdb/1594828256133-CreateIndexStoppedAt.ts +++ b/packages/cli/src/databases/migrations/postgresdb/1594828256133-CreateIndexStoppedAt.ts @@ -1,5 +1,5 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -import { getTablePrefix } from '@/databases/utils/migrationHelpers'; +import { getTablePrefix } from '@db/utils/migrationHelpers'; export class CreateIndexStoppedAt1594828256133 implements MigrationInterface { name = 'CreateIndexStoppedAt1594828256133'; diff --git a/packages/cli/src/databases/migrations/postgresdb/1671726148421-RemoveWorkflowDataLoadedFlag.ts b/packages/cli/src/databases/migrations/postgresdb/1671726148421-RemoveWorkflowDataLoadedFlag.ts index 2c8d3d00e7..a651c43946 100644 --- a/packages/cli/src/databases/migrations/postgresdb/1671726148421-RemoveWorkflowDataLoadedFlag.ts +++ b/packages/cli/src/databases/migrations/postgresdb/1671726148421-RemoveWorkflowDataLoadedFlag.ts @@ -1,6 +1,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; -import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; +import { StatisticsNames } from '@db/entities/WorkflowStatistics'; export class RemoveWorkflowDataLoadedFlag1671726148421 implements MigrationInterface { name = 'RemoveWorkflowDataLoadedFlag1671726148421'; diff --git a/packages/cli/src/databases/migrations/sqlite/1671726148419-RemoveWorkflowDataLoadedFlag.ts b/packages/cli/src/databases/migrations/sqlite/1671726148419-RemoveWorkflowDataLoadedFlag.ts index 8271c021c3..36ac917b60 100644 --- a/packages/cli/src/databases/migrations/sqlite/1671726148419-RemoveWorkflowDataLoadedFlag.ts +++ b/packages/cli/src/databases/migrations/sqlite/1671726148419-RemoveWorkflowDataLoadedFlag.ts @@ -2,7 +2,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; import config from '@/config'; import { v4 as uuidv4 } from 'uuid'; -import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; +import { StatisticsNames } from '@db/entities/WorkflowStatistics'; export class RemoveWorkflowDataLoadedFlag1671726148419 implements MigrationInterface { name = 'RemoveWorkflowDataLoadedFlag1671726148419'; diff --git a/packages/cli/src/decorators/RestController.ts b/packages/cli/src/decorators/RestController.ts new file mode 100644 index 0000000000..11c2d55665 --- /dev/null +++ b/packages/cli/src/decorators/RestController.ts @@ -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); + }; diff --git a/packages/cli/src/decorators/Route.ts b/packages/cli/src/decorators/Route.ts new file mode 100644 index 0000000000..773bb2193a --- /dev/null +++ b/packages/cli/src/decorators/Route.ts @@ -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'); diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts new file mode 100644 index 0000000000..9c77ab696e --- /dev/null +++ b/packages/cli/src/decorators/constants.ts @@ -0,0 +1,2 @@ +export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; +export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts new file mode 100644 index 0000000000..bda31ce887 --- /dev/null +++ b/packages/cli/src/decorators/index.ts @@ -0,0 +1,3 @@ +export { RestController } from './RestController'; +export { Get, Post, Patch, Delete } from './Route'; +export { registerController } from './registerController'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts new file mode 100644 index 0000000000..9f1ab5bba7 --- /dev/null +++ b/packages/cli/src/decorators/registerController.ts @@ -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); + } +}; diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts new file mode 100644 index 0000000000..829451f4d7 --- /dev/null +++ b/packages/cli/src/decorators/types.ts @@ -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; +export type Controller = Record; diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts index 712b1170dc..a262468aae 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts @@ -1,12 +1,12 @@ /* eslint-disable import/no-cycle */ -import type { EventDestinations } from '@/databases/entities/MessageEventBusDestinationEntity'; +import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity'; import { promClient } from '@/metrics'; import { EventMessageTypeNames, LoggerProxy, MessageEventBusDestinationTypeNames, } from 'n8n-workflow'; -import config from '../../config'; +import config from '@/config'; import type { EventMessageTypes } from '../EventMessageClasses'; import type { MessageEventBusDestination } from './MessageEventBusDestination.ee'; import { MessageEventBusDestinationSentry } from './MessageEventBusDestinationSentry.ee'; diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts index 81aa75f2c4..0dc1e14185 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts @@ -18,10 +18,10 @@ import { MessageEventBusDestinationWebhookParameterItem, MessageEventBusDestinationWebhookParameterOptions, } from 'n8n-workflow'; -import { CredentialsHelper } from '../../CredentialsHelper'; +import { CredentialsHelper } from '@/CredentialsHelper'; import { UserSettings } from 'n8n-core'; import { Agent as HTTPSAgent } from 'https'; -import config from '../../config'; +import config from '@/config'; import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'; import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric'; diff --git a/packages/cli/src/events/WorkflowStatistics.ts b/packages/cli/src/events/WorkflowStatistics.ts index a4023d261a..a926211fc2 100644 --- a/packages/cli/src/events/WorkflowStatistics.ts +++ b/packages/cli/src/events/WorkflowStatistics.ts @@ -1,7 +1,7 @@ import { INode, IRun, IWorkflowBase } from 'n8n-workflow'; import * as Db from '@/Db'; import { InternalHooksManager } from '@/InternalHooksManager'; -import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; +import { StatisticsNames } from '@db/entities/WorkflowStatistics'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { QueryFailedError } from 'typeorm'; diff --git a/packages/cli/src/executions/executions.service.ee.ts b/packages/cli/src/executions/executions.service.ee.ts index 8d60cf3d02..3e0ef04abc 100644 --- a/packages/cli/src/executions/executions.service.ee.ts +++ b/packages/cli/src/executions/executions.service.ee.ts @@ -1,4 +1,4 @@ -import { User } from '@/databases/entities/User'; +import { User } from '@db/entities/User'; import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { ExecutionsService } from './executions.service'; diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index 98153bad6a..8f1a90b16f 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -15,7 +15,7 @@ import { import { FindOperator, FindOptionsWhere, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm'; import * as ActiveExecutions from '@/ActiveExecutions'; import config from '@/config'; -import type { User } from '@/databases/entities/User'; +import type { User } from '@db/entities/User'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; import { IExecutionFlattedResponse, diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/middlewares/auth.ts similarity index 50% rename from packages/cli/src/UserManagement/routes/index.ts rename to packages/cli/src/middlewares/auth.ts index e33030092f..f8b271cbed 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -1,32 +1,80 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ +import type { Application, NextFunction, Request, RequestHandler, Response } from 'express'; +import jwt from 'jsonwebtoken'; import cookieParser from 'cookie-parser'; import passport from 'passport'; -import { NextFunction, Request, Response } from 'express'; +import { Strategy } from 'passport-jwt'; import { LoggerProxy as Logger } from 'n8n-workflow'; -import { N8nApp } from '../Interfaces'; -import { AuthenticatedRequest } from '@/requests'; +import { JwtPayload } from '@/Interfaces'; +import type { AuthenticatedRequest } from '@/requests'; +import config from '@/config'; +import { AUTH_COOKIE_NAME } from '@/constants'; +import { issueCookie, resolveJwtContent } from '@/auth/jwt'; import { + isAuthenticatedRequest, isAuthExcluded, isPostUsersId, - isAuthenticatedRequest, isUserManagementDisabled, -} from '../UserManagementHelper'; -import * as Db from '@/Db'; -import { jwtAuth, refreshExpiringCookie } from '../middlewares'; -import { authenticationMethods } from './auth'; -import { meNamespace } from './me'; -import { usersNamespace } from './users'; -import { passwordResetNamespace } from './passwordReset'; -import { ownerNamespace } from './owner'; +} from '@/UserManagement/UserManagementHelper'; +import type { Repository } from 'typeorm'; +import type { User } from '@db/entities/User'; -export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { +const jwtFromRequest = (req: Request) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null; +}; + +const jwtAuth = (): RequestHandler => { + const jwtStrategy = new Strategy( + { + jwtFromRequest, + secretOrKey: config.getEnv('userManagement.jwtSecret'), + }, + async (jwtPayload: JwtPayload, done) => { + try { + const user = await resolveJwtContent(jwtPayload); + return done(null, user); + } catch (error) { + Logger.debug('Failed to extract user from JWT payload', { jwtPayload }); + return done(null, false, { message: 'User not found' }); + } + }, + ); + + passport.use(jwtStrategy); + return passport.initialize(); +}; + +/** + * middleware to refresh cookie before it expires + */ +const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, res, next) => { + const cookieAuth = jwtFromRequest(req); + if (cookieAuth && req.user) { + const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number }; + if (cookieContents.exp * 1000 - Date.now() < 259200000) { + // if cookie expires in < 3 days, renew it. + await issueCookie(res, req.user); + } + } + next(); +}; + +const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler; + +/** + * This sets up the auth middlewares in the correct order + */ +export const setupAuthMiddlewares = ( + app: Application, + ignoredEndpoints: Readonly, + restEndpoint: string, + userRepository: Repository, +) => { // needed for testing; not adding overhead since it directly returns if req.cookies exists - this.app.use(cookieParser()); - this.app.use(jwtAuth()); + app.use(cookieParser()); + app.use(jwtAuth()); - this.app.use(async (req: Request, res: Response, next: NextFunction) => { + app.use(async (req: Request, res: Response, next: NextFunction) => { if ( // TODO: refactor me!!! // skip authentication for preflight requests @@ -54,17 +102,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint // skip authentication if user management is disabled if (isUserManagementDisabled()) { - req.user = await Db.collections.User.findOneOrFail({ + req.user = await userRepository.findOneOrFail({ relations: ['globalRole'], where: {}, }); return next(); } - return passport.authenticate('jwt', { session: false })(req, res, next); + return passportMiddleware(req, res, next); }); - this.app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => { + app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => { // req.user is empty for public routes, so just proceed // owner can do anything, so proceed as well if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) { @@ -73,17 +121,17 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint } // Not owner and user exists. We now protect restricted urls. const postRestrictedUrls = [ - `/${this.restEndpoint}/users`, - `/${this.restEndpoint}/owner`, - `/${this.restEndpoint}/ldap/sync`, - `/${this.restEndpoint}/ldap/test-connection`, + `/${restEndpoint}/users`, + `/${restEndpoint}/owner`, + `/${restEndpoint}/ldap/sync`, + `/${restEndpoint}/ldap/test-connection`, ]; const getRestrictedUrls = [ - `/${this.restEndpoint}/users`, - `/${this.restEndpoint}/ldap/sync`, - `/${this.restEndpoint}/ldap/config`, + `/${restEndpoint}/users`, + `/${restEndpoint}/ldap/sync`, + `/${restEndpoint}/ldap/config`, ]; - const putRestrictedUrls = [`/${this.restEndpoint}/ldap/config`]; + const putRestrictedUrls = [`/${restEndpoint}/ldap/config`]; const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; if ( (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || @@ -106,11 +154,5 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint next(); }); - this.app.use(refreshExpiringCookie); - - authenticationMethods.apply(this); - ownerNamespace.apply(this); - meNamespace.apply(this); - passwordResetNamespace.apply(this); - usersNamespace.apply(this); -} + app.use(refreshExpiringCookie); +}; diff --git a/packages/cli/src/UserManagement/middlewares/index.ts b/packages/cli/src/middlewares/index.ts similarity index 50% rename from packages/cli/src/UserManagement/middlewares/index.ts rename to packages/cli/src/middlewares/index.ts index 269586ee8b..e287c326a2 100644 --- a/packages/cli/src/UserManagement/middlewares/index.ts +++ b/packages/cli/src/middlewares/index.ts @@ -1 +1,2 @@ export * from './auth'; +export * from './cors'; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 08e3bbfda8..000af81840 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -10,11 +10,10 @@ import { IWorkflowSettings, } from 'n8n-workflow'; -import type { IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; +import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; -import type { PublicUser } from '@/UserManagement/Interfaces'; export type AuthlessRequest< RouteParams = {}, diff --git a/packages/cli/test/integration/audit/utils.ts b/packages/cli/test/integration/audit/utils.ts index 8669010361..9a023fa5dd 100644 --- a/packages/cli/test/integration/audit/utils.ts +++ b/packages/cli/test/integration/audit/utils.ts @@ -5,8 +5,8 @@ import * as Db from '@/Db'; import { toReportTitle } from '@/audit/utils'; import * as constants from '@/constants'; import type { Risk } from '@/audit/types'; -import type { InstalledNodes } from '@/databases/entities/InstalledNodes'; -import type { InstalledPackages } from '@/databases/entities/InstalledPackages'; +import type { InstalledNodes } from '@db/entities/InstalledNodes'; +import type { InstalledPackages } from '@db/entities/InstalledPackages'; type GetSectionKind = C extends 'instance' ? Risk.InstanceSection diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 38aef7e9bf..cad139d0ce 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -16,16 +16,12 @@ let globalMemberRole: Role; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['auth'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); authAgent = utils.createAuthAgent(app); - - utils.initTestLogger(); - utils.initTestTelemetry(); }); beforeEach(async () => { @@ -276,6 +272,60 @@ test('GET /login should return logged-in member', async () => { expect(authToken).toBeUndefined(); }); +test('GET /resolve-signup-token should validate invite token', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const memberShell = await testDb.createUserShell(globalMemberRole); + + const response = await authAgent(owner) + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId: memberShell.id }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + data: { + inviter: { + firstName: owner.firstName, + lastName: owner.lastName, + }, + }, + }); +}); + +test('GET /resolve-signup-token should fail with invalid inputs', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = authAgent(owner); + + const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); + + const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id }); + + const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); + + const third = await authOwnerAgent.get('/resolve-signup-token').query({ + inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d', + inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d', + }); + + // user is already set up, so call should error + const fourth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId }); + + // cause inconsistent DB state + await Db.collections.User.update(owner.id, { email: '' }); + const fifth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId }); + + for (const response of [first, second, third, fourth, fifth]) { + expect(response.statusCode).toBe(400); + } +}); + test('POST /logout should log user out', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index fafcb8c334..94291ddfc8 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -16,18 +16,11 @@ let globalMemberRole: Role; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ - applyAuth: true, - endpointGroups: ['me', 'auth', 'owner', 'users'], - }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] }); globalMemberRole = await testDb.getGlobalMemberRole(); authAgent = utils.createAuthAgent(app); - - utils.initTestLogger(); - utils.initTestTelemetry(); }); afterAll(async () => { diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index 179d4d413c..ca1c207760 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -1,16 +1,11 @@ -import express from 'express'; - import * as Db from '@/Db'; import { Reset } from '@/commands/user-management/reset'; import type { Role } from '@db/entities/Role'; -import * as utils from '../shared/utils'; import * as testDb from '../shared/testDb'; -let app: express.Application; let globalOwnerRole: Role; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); await testDb.init(); globalOwnerRole = await testDb.getGlobalOwnerRole(); diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index 5dc70307e7..81313739e4 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -22,11 +22,7 @@ let authAgent: AuthAgent; let sharingSpy: jest.SpyInstance; beforeAll(async () => { - app = await utils.initTestServer({ - endpointGroups: ['credentials'], - applyAuth: true, - }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['credentials'] }); utils.initConfigFile(); @@ -37,9 +33,6 @@ beforeAll(async () => { saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); authAgent = utils.createAuthAgent(app); - utils.initTestLogger(); - utils.initTestTelemetry(); - sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); }); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index 9baadd078c..1de7561721 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -25,11 +25,7 @@ let saveCredential: SaveCredentialFunction; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ - endpointGroups: ['credentials'], - applyAuth: true, - }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['credentials'] }); utils.initConfigFile(); @@ -40,9 +36,6 @@ beforeAll(async () => { saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); authAgent = utils.createAuthAgent(app); - - utils.initTestLogger(); - utils.initTestTelemetry(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/eventbus.test.ts b/packages/cli/test/integration/eventbus.test.ts index 7113c08dc3..00bd6d4752 100644 --- a/packages/cli/test/integration/eventbus.test.ts +++ b/packages/cli/test/integration/eventbus.test.ts @@ -81,12 +81,11 @@ async function confirmIdSent(id: string) { } beforeAll(async () => { - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['eventBus'] }); + globalOwnerRole = await testDb.getGlobalOwnerRole(); owner = await testDb.createUser({ globalRole: globalOwnerRole }); - app = await utils.initTestServer({ endpointGroups: ['eventBus'], applyAuth: true }); - unAuthOwnerAgent = utils.createAgent(app, { apiPath: 'internal', auth: false, @@ -104,7 +103,6 @@ beforeAll(async () => { mockedSyslog.createClient.mockImplementation(() => new syslog.Client()); utils.initConfigFile(); - utils.initTestLogger(); config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter'); config.set('eventBus.logWriter.keepLogCount', '1'); config.set('enterprise.features.logStreaming', true); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index e4397089c2..36b8924469 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -40,8 +40,7 @@ const defaultLdapConfig = { }; beforeAll(async () => { - await testDb.init(); - app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'] }); const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles(); @@ -56,8 +55,6 @@ beforeAll(async () => { ); utils.initConfigFile(); - utils.initTestLogger(); - utils.initTestTelemetry(); await utils.initLdapManager(); }); diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index c3fa668034..b8908915e1 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -19,17 +19,13 @@ let authAgent: AuthAgent; let license: License; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['license'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['license'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); authAgent = utils.createAuthAgent(app); - utils.initTestLogger(); - utils.initTestTelemetry(); - config.set('license.serverUrl', MOCK_SERVER_URL); config.set('license.autoRenewEnabled', true); config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 27b3695101..4335b62fb5 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -23,16 +23,12 @@ let globalMemberRole: Role; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['me'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); authAgent = utils.createAuthAgent(app); - - utils.initTestLogger(); - utils.initTestTelemetry(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/nodes.api.test.ts b/packages/cli/test/integration/nodes.api.test.ts index de09df8b80..e3496e87ac 100644 --- a/packages/cli/test/integration/nodes.api.test.ts +++ b/packages/cli/test/integration/nodes.api.test.ts @@ -47,16 +47,13 @@ let globalOwnerRole: Role; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['nodes'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['nodes'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); authAgent = utils.createAuthAgent(app); utils.initConfigFile(); - utils.initTestLogger(); - utils.initTestTelemetry(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index 8ae033e16b..546a6c6556 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -19,15 +19,11 @@ let globalOwnerRole: Role; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['owner'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); authAgent = utils.createAuthAgent(app); - - utils.initTestLogger(); - utils.initTestTelemetry(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index bc8ec8adb9..13e458ad11 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -21,14 +21,10 @@ let globalOwnerRole: Role; let globalMemberRole: Role; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['passwordReset'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); - - utils.initTestTelemetry(); - utils.initTestLogger(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index 7786b8c309..10b486cbaf 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -23,7 +23,6 @@ beforeAll(async () => { applyAuth: false, enablePublicAPI: true, }); - await testDb.init(); utils.initConfigFile(); @@ -36,8 +35,6 @@ beforeAll(async () => { saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); - utils.initTestLogger(); - utils.initTestTelemetry(); utils.initCredentialsTypes(); }); diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 580f8bfa22..c377c870af 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -18,13 +18,9 @@ beforeAll(async () => { applyAuth: false, enablePublicAPI: true, }); - await testDb.init(); globalOwnerRole = await testDb.getGlobalOwnerRole(); - utils.initTestTelemetry(); - utils.initTestLogger(); - await utils.initBinaryManager(); await utils.initNodeTypes(); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index c9978a9931..8c418e0d44 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -22,7 +22,6 @@ beforeAll(async () => { applyAuth: false, enablePublicAPI: true, }); - await testDb.init(); const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] = await testDb.getAllRoles(); @@ -31,8 +30,6 @@ beforeAll(async () => { globalMemberRole = fetchedGlobalMemberRole; workflowOwnerRole = fetchedWorkflowOwnerRole; - utils.initTestTelemetry(); - utils.initTestLogger(); utils.initConfigFile(); await utils.initNodeTypes(); workflowRunner = await utils.initActiveWorkflowRunner(); diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 58a2a8e6e9..adbd5986cb 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -22,7 +22,6 @@ import { toCronExpression, TriggerTime, } from 'n8n-workflow'; -import type { N8nApp } from '@/UserManagement/Interfaces'; import superagent from 'superagent'; import request from 'supertest'; import { URL } from 'url'; @@ -34,11 +33,6 @@ import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooksManager } from '@/InternalHooksManager'; import { NodeTypes } from '@/NodeTypes'; import * as ActiveWorkflowRunner from '@/ActiveWorkflowRunner'; -import { meNamespace as meEndpoints } from '@/UserManagement/routes/me'; -import { usersNamespace as usersEndpoints } from '@/UserManagement/routes/users'; -import { authenticationMethods as authEndpoints } from '@/UserManagement/routes/auth'; -import { ownerNamespace as ownerEndpoints } from '@/UserManagement/routes/owner'; -import { passwordResetNamespace as passwordResetEndpoints } from '@/UserManagement/routes/passwordReset'; import { nodesController } from '@/api/nodes.api'; import { workflowsController } from '@/workflows/workflows.controller'; import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants'; @@ -47,8 +41,8 @@ import { InstalledPackages } from '@db/entities/InstalledPackages'; import type { User } from '@db/entities/User'; import { getLogger } from '@/Logger'; import { loadPublicApiVersions } from '@/PublicApi/'; -import { issueJWT } from '@/UserManagement/auth/jwt'; -import { addRoutes as authMiddleware } from '@/UserManagement/routes'; +import { issueJWT } from '@/auth/jwt'; +import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; import { AUTHLESS_ENDPOINTS, COMMUNITY_NODE_VERSION, @@ -66,9 +60,19 @@ import type { } from './types'; import { licenseController } from '@/license/license.controller'; import { eventBusRouter } from '@/eventbus/eventBusRoutes'; +import { registerController } from '@/decorators'; +import { + AuthController, + MeController, + OwnerController, + PasswordResetController, + UsersController, +} from '@/controllers'; +import { setupAuthMiddlewares } from '@/middlewares'; +import * as testDb from '../shared/testDb'; import { v4 as uuid } from 'uuid'; -import { handleLdapInit } from '../../../src/Ldap/helpers'; +import { handleLdapInit } from '@/Ldap/helpers'; import { ldapController } from '@/Ldap/routes/ldap.controller.ee'; const loadNodesAndCredentials: INodesAndCredentials = { @@ -84,14 +88,15 @@ CredentialTypes(loadNodesAndCredentials); * Initialize a test server. */ export async function initTestServer({ - applyAuth, + applyAuth = true, endpointGroups, enablePublicAPI = false, }: { - applyAuth: boolean; + applyAuth?: boolean; endpointGroups?: EndpointGroup[]; enablePublicAPI?: boolean; }) { + await testDb.init(); const testServer = { app: express(), restEndpoint: REST_PATH_SEGMENT, @@ -99,6 +104,12 @@ export async function initTestServer({ externalHooks: {}, }; + const logger = getLogger(); + LoggerProxy.init(logger); + + // Pre-requisite: Mock the telemetry module before calling. + await InternalHooksManager.init('test-instance-id', mockNodeTypes); + testServer.app.use(bodyParser.json()); testServer.app.use(bodyParser.urlencoded({ extended: true })); @@ -106,7 +117,12 @@ export async function initTestServer({ config.set('userManagement.isInstanceOwnerSetUp', false); if (applyAuth) { - authMiddleware.apply(testServer, [AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT]); + setupAuthMiddlewares( + testServer.app, + AUTHLESS_ENDPOINTS, + REST_PATH_SEGMENT, + Db.collections.User, + ); } if (!endpointGroups) return testServer.app; @@ -147,36 +163,75 @@ export async function initTestServer({ } if (functionEndpoints.length) { - const map: Record void> = { - me: meEndpoints, - users: usersEndpoints, - auth: authEndpoints, - owner: ownerEndpoints, - passwordReset: passwordResetEndpoints, - }; + const externalHooks = ExternalHooks(); + const internalHooks = InternalHooksManager.getInstance(); + const mailer = UserManagementMailer.getInstance(); + const repositories = Db.collections; for (const group of functionEndpoints) { - map[group].apply(testServer); + switch (group) { + case 'auth': + registerController( + testServer.app, + config, + new AuthController({ config, logger, internalHooks, repositories }), + ); + break; + case 'me': + registerController( + testServer.app, + config, + new MeController({ logger, externalHooks, internalHooks, repositories }), + ); + break; + case 'passwordReset': + registerController( + testServer.app, + config, + new PasswordResetController({ + config, + logger, + externalHooks, + internalHooks, + repositories, + }), + ); + break; + case 'owner': + registerController( + testServer.app, + config, + new OwnerController({ config, logger, internalHooks, repositories }), + ); + break; + case 'users': + registerController( + testServer.app, + config, + new UsersController({ + config, + mailer, + externalHooks, + internalHooks, + repositories, + activeWorkflowRunner: ActiveWorkflowRunner.getInstance(), + logger, + }), + ); + } } } return testServer.app; } -/** - * Pre-requisite: Mock the telemetry module before calling. - */ -export function initTestTelemetry() { - void InternalHooksManager.init('test-instance-id', mockNodeTypes); -} - /** * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), * and `functionEndpoints` (legacy, namespaced inside a function). */ -const classifyEndpointGroups = (endpointGroups: string[]) => { - const routerEndpoints: string[] = []; - const functionEndpoints: string[] = []; +const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { + const routerEndpoints: EndpointGroup[] = []; + const functionEndpoints: EndpointGroup[] = []; const ROUTER_GROUP = [ 'credentials', @@ -559,13 +614,6 @@ export async function initNodeTypes() { }; } -/** - * Initialize a logger for test runs. - */ -export function initTestLogger() { - LoggerProxy.init(getLogger()); -} - /** * Initialize a BinaryManager for test runs. */ diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index bdecd1ae10..28f36c4364 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -33,8 +33,7 @@ let credentialOwnerRole: Role; let authAgent: AuthAgent; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['users'] }); const [ fetchedGlobalOwnerRole, @@ -49,9 +48,6 @@ beforeAll(async () => { credentialOwnerRole = fetchedCredentialOwnerRole; authAgent = utils.createAuthAgent(app); - - utils.initTestTelemetry(); - utils.initTestLogger(); }); beforeEach(async () => { @@ -241,60 +237,6 @@ test('DELETE /users/:id with transferId should perform transfer', async () => { expect(deletedUser).toBeNull(); }); -test('GET /resolve-signup-token should validate invite token', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const memberShell = await testDb.createUserShell(globalMemberRole); - - const response = await authAgent(owner) - .get('/resolve-signup-token') - .query({ inviterId: owner.id }) - .query({ inviteeId: memberShell.id }); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ - data: { - inviter: { - firstName: owner.firstName, - lastName: owner.lastName, - }, - }, - }); -}); - -test('GET /resolve-signup-token should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(owner); - - const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); - - const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id }); - - const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); - - const third = await authOwnerAgent.get('/resolve-signup-token').query({ - inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d', - inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d', - }); - - // user is already set up, so call should error - const fourth = await authOwnerAgent - .get('/resolve-signup-token') - .query({ inviterId: owner.id }) - .query({ inviteeId }); - - // cause inconsistent DB state - await Db.collections.User.update(owner.id, { email: '' }); - const fifth = await authOwnerAgent - .get('/resolve-signup-token') - .query({ inviterId: owner.id }) - .query({ inviteeId }); - - for (const response of [first, second, third, fourth, fifth]) { - expect(response.statusCode).toBe(400); - } -}); - test('POST /users/:id should fill out a user shell', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index f4a9bb4971..8c5fda07bb 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -24,11 +24,7 @@ let workflowRunner: ActiveWorkflowRunner; let sharingSpy: jest.SpyInstance; beforeAll(async () => { - app = await utils.initTestServer({ - endpointGroups: ['workflows'], - applyAuth: true, - }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['workflows'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); @@ -38,9 +34,6 @@ beforeAll(async () => { authAgent = utils.createAuthAgent(app); - utils.initTestLogger(); - utils.initTestTelemetry(); - isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); await utils.initNodeTypes(); diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index 9ea0b99295..2d56589531 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -15,16 +15,9 @@ let globalOwnerRole: Role; jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); beforeAll(async () => { - app = await utils.initTestServer({ - endpointGroups: ['workflows'], - applyAuth: true, - }); - await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['workflows'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); - - utils.initTestLogger(); - utils.initTestTelemetry(); }); beforeEach(async () => { diff --git a/packages/cli/test/unit/Events.test.ts b/packages/cli/test/unit/Events.test.ts index b502c0aef9..532b2a1dbc 100644 --- a/packages/cli/test/unit/Events.test.ts +++ b/packages/cli/test/unit/Events.test.ts @@ -3,7 +3,7 @@ import { InternalHooksManager } from '@/InternalHooksManager'; import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics'; import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow'; import { getLogger } from '@/Logger'; -import { StatisticsNames } from '@/databases/entities/WorkflowStatistics'; +import { StatisticsNames } from '@db/entities/WorkflowStatistics'; import { QueryFailedError } from 'typeorm'; const FAKE_USER_ID = 'abcde-fghij'; diff --git a/packages/cli/test/unit/PermissionChecker.test.ts b/packages/cli/test/unit/PermissionChecker.test.ts index 89a7b9f894..3514e5bf06 100644 --- a/packages/cli/test/unit/PermissionChecker.test.ts +++ b/packages/cli/test/unit/PermissionChecker.test.ts @@ -20,10 +20,10 @@ import { randomPositiveDigit, } from '../integration/shared/random'; -import { Role } from '@/databases/entities/Role'; +import { Role } from '@db/entities/Role'; import type { SaveCredentialFunction } from '../integration/shared/types'; -import { User } from '@/databases/entities/User'; -import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; +import { User } from '@db/entities/User'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; let mockNodeTypes: INodeTypes; let credentialOwnerRole: Role;