From 4a09fd5212cf6f1dbd9c096cd8ca47876a92dade 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, 5 Aug 2022 10:38:38 +0200 Subject: [PATCH] User Management: switch over to decorators to define routes (#3827) --- packages/cli/src/ResponseHelper.ts | 8 +- .../controllers/AuthController.ts | 191 ++++++ .../controllers/MeController.ts | 185 ++++++ .../controllers/OwnerController.ts | 111 ++++ .../controllers/PasswordResetController.ts | 211 +++++++ .../controllers/UserController.ts | 485 +++++++++++++++ .../src/UserManagement/controllers/index.ts | 6 + packages/cli/src/UserManagement/decorators.ts | 78 +++ .../src/UserManagement/middlewares/auth.ts | 56 ++ .../src/UserManagement/middlewares/index.ts | 2 + .../cli/src/UserManagement/routes/auth.ts | 124 ---- .../cli/src/UserManagement/routes/index.ts | 68 +-- packages/cli/src/UserManagement/routes/me.ts | 208 ------- .../cli/src/UserManagement/routes/owner.ts | 121 ---- .../UserManagement/routes/passwordReset.ts | 220 ------- .../cli/src/UserManagement/routes/users.ts | 574 ------------------ packages/cli/test/integration/shared/utils.ts | 41 +- 17 files changed, 1364 insertions(+), 1325 deletions(-) create mode 100644 packages/cli/src/UserManagement/controllers/AuthController.ts create mode 100644 packages/cli/src/UserManagement/controllers/MeController.ts create mode 100644 packages/cli/src/UserManagement/controllers/OwnerController.ts create mode 100644 packages/cli/src/UserManagement/controllers/PasswordResetController.ts create mode 100644 packages/cli/src/UserManagement/controllers/UserController.ts create mode 100644 packages/cli/src/UserManagement/controllers/index.ts create mode 100644 packages/cli/src/UserManagement/decorators.ts create mode 100644 packages/cli/src/UserManagement/middlewares/auth.ts create mode 100644 packages/cli/src/UserManagement/middlewares/index.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 diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index da54dfc52b4c8..13ebee7835f42 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -146,9 +146,11 @@ const isUniqueConstraintError = (error: Error) => * @returns */ -export function send(processFunction: (req: Request, res: Response) => Promise, raw = false) { - // eslint-disable-next-line consistent-return - return async (req: Request, res: Response) => { +export function send( + processFunction: (req: R, res: S) => Promise, + raw = false, +) { + return async (req: R, res: S) => { try { const data = await processFunction(req, res); diff --git a/packages/cli/src/UserManagement/controllers/AuthController.ts b/packages/cli/src/UserManagement/controllers/AuthController.ts new file mode 100644 index 0000000000000..af7ed4495d872 --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/AuthController.ts @@ -0,0 +1,191 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import type { Request, Response } from 'express'; +import { In } from 'typeorm'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { issueCookie, resolveJwt } from '../auth/jwt'; +import { AUTH_COOKIE_NAME } from '../../constants'; +import type { User } from '../../databases/entities/User'; +import { Get, Post, RestController } from '../decorators'; +import type { PublicUser } from '../Interfaces'; +import type { LoginRequest, UserRequest } from '../../requests'; +import { compareHash, sanitizeUser } from '../UserManagementHelper'; +import * as config from '../../../config'; + +@RestController() +export class AuthController { + /** + * 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'); + } + + let user: User | undefined; + try { + user = await Db.collections.User.findOne( + { email }, + { + relations: ['globalRole'], + }, + ); + } catch (error) { + throw new Error('Unable to access database.'); + } + + if (!user || !user.password || !(await compareHash(password, user.password))) { + // password is empty until user signs up + const error = new Error('Wrong username or password. Do you have caps lock on?'); + // @ts-ignore + error.httpStatusCode = 401; + throw error; + } + + await issueCookie(res, user); + + return sanitizeUser(user); + } + + /** + * Manually check the `n8n-auth` cookie. + */ + @Get('/login') + async loginCurrentUser(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')) { + const error = new Error('Not logged in'); + // @ts-ignore + error.httpStatusCode = 401; + throw error; + } + + try { + user = await Db.collections.User.findOneOrFail({ relations: ['globalRole'] }); + } catch (error) { + throw new Error( + 'No users found in database - did you wipe the users table? Create at least one user.', + ); + } + + if (user.email || user.password) { + throw new Error('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) { + Logger.debug( + 'Request to resolve signup token failed because of missing user IDs in query string', + { inviterId, inviteeId }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + // 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.ResponseError('Invalid userId', undefined, 400); + } + } + + 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.ResponseError('Invalid invite URL', undefined, 400); + } + + 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.ResponseError( + 'The invitation was likely either deleted or already claimed', + undefined, + 400, + ); + } + + const inviter = users.find((user) => user.id === inviterId); + + if (!inviter || !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.ResponseError('Invalid request', undefined, 400); + } + + void InternalHooksManager.getInstance().onUserInviteEmailClick({ + user_id: inviteeId, + }); + + 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/UserManagement/controllers/MeController.ts b/packages/cli/src/UserManagement/controllers/MeController.ts new file mode 100644 index 0000000000000..73b90b23d3fd7 --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/MeController.ts @@ -0,0 +1,185 @@ +/* eslint-disable import/no-cycle */ +import type { Response } from 'express'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import { randomBytes } from 'crypto'; +import { Delete, Get, Patch, Post, RestController } from '../decorators'; +import type { AuthenticatedRequest, MeRequest } from '../../requests'; +import type { PublicUser } from '../Interfaces'; +import { compareHash, hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper'; +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { User } from '../../databases/entities/User'; +import { validateEntity } from '../../GenericHelpers'; +import { issueCookie } from '../auth/jwt'; + +@RestController('/me') +export class MeController { + /** + * 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) { + Logger.debug('Request to update user email failed because of missing email in payload', { + userId: req.user.id, + payload: req.body, + }); + throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); + } + + 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.ResponseError('Invalid email address', undefined, 400); + } + + 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_id: req.user.id, + fields_changed: updatedkeys, + }); + + 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 ResponseHelper.ResponseError('Invalid payload.', undefined, 400); + } + + if (!req.user.password) { + throw new ResponseHelper.ResponseError('Requesting user not set up.'); + } + + const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password); + if (!isCurrentPwCorrect) { + throw new ResponseHelper.ResponseError( + 'Provided current password is incorrect.', + undefined, + 400, + ); + } + + 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_id: req.user.id, + fields_changed: ['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) { + Logger.debug('Request to store user personalization survey failed because of empty payload', { + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + 'Personalization answers are mandatory', + undefined, + 400, + ); + } + + 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 + */ + @Post('/api-key') + async createAPIKey(req: AuthenticatedRequest) { + const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; + + await Db.collections.User.update(req.user.id, { apiKey }); + + const telemetryData = { + user_id: req.user.id, + public_api: false, + }; + + void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData); + + 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 Db.collections.User.update(req.user.id, { apiKey: null }); + + const telemetryData = { + user_id: req.user.id, + public_api: false, + }; + + void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData); + + return { success: true }; + } +} diff --git a/packages/cli/src/UserManagement/controllers/OwnerController.ts b/packages/cli/src/UserManagement/controllers/OwnerController.ts new file mode 100644 index 0000000000000..4cff3cab03ac1 --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/OwnerController.ts @@ -0,0 +1,111 @@ +/* eslint-disable import/no-cycle */ +import type { Response } from 'express'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import { issueCookie } from '../auth/jwt'; +import { Post, RestController } from '../decorators'; +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper'; +import { validateEntity } from '../../GenericHelpers'; +import type { AuthenticatedRequest, OwnerRequest } from '../../requests'; +import * as config from '../../../config'; + +@RestController('/owner') +export class OwnerController { + /** + * 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 (config.getEnv('userManagement.isInstanceOwnerSetUp')) { + Logger.debug( + 'Request to claim instance ownership failed because instance owner already exists', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + if (!email || !validator.isEmail(email)) { + Logger.debug('Request to claim instance ownership failed because of invalid email', { + userId, + invalidEmail: email, + }); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + 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.ResponseError('First and last names are mandatory', undefined, 400); + } + + let owner = await Db.collections.User.findOne(userId, { + relations: ['globalRole'], + }); + + 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.ResponseError('Invalid request', undefined, 400); + } + + 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 + */ + @Post('/skip-setup') + async skipSetup(_req: AuthenticatedRequest, _res: 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/controllers/PasswordResetController.ts b/packages/cli/src/UserManagement/controllers/PasswordResetController.ts new file mode 100644 index 0000000000000..57d3a34741eee --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/PasswordResetController.ts @@ -0,0 +1,211 @@ +/* eslint-disable import/no-cycle */ +import type { Response } from 'express'; +import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import type { PasswordResetRequest } from '../../requests'; +import { Get, Post, RestController } from '../decorators'; +import * as UserManagementMailer from '../email'; +import { getInstanceBaseUrl, hashPassword, validatePassword } from '../UserManagementHelper'; +import { Db, ResponseHelper, InternalHooksManager } from '../..'; +import { issueCookie } from '../auth/jwt'; +import * as config from '../../../config'; + +@RestController() +export class PasswordResetController { + /** + * Send a password reset email. + * + * Authless endpoint. + */ + @Post('/forgot-password') + async forgotPassword(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.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + + 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.ResponseError('Email is mandatory', undefined, 400); + } + + if (!validator.isEmail(email)) { + Logger.debug( + 'Request to send password reset email failed because of invalid email in payload', + { invalidEmail: email }, + ); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + // User should just be able to reset password if one is already present + const user = await Db.collections.User.findOne({ email, password: Not(IsNull()) }); + + if (!user || !user.password) { + Logger.debug( + 'Request to send password reset email failed because no user was found for the provided email', + { invalidEmail: email }, + ); + return; + } + + 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 = await UserManagementMailer.getInstance(); + await mailer.passwordReset({ + email, + firstName, + lastName, + passwordResetUrl: url.toString(), + domain: baseUrl, + }); + } catch (error) { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: user.id, + message_type: 'Reset password', + public_api: false, + }); + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + `Please contact your administrator: ${error.message}`, + undefined, + 500, + ); + } + } + + 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_id: id, + }); + } + + /** + * 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) { + 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.ResponseError('', undefined, 400); + } + + // Timestamp is saved in seconds + const currentTimestamp = Math.floor(Date.now() / 1000); + + const user = await Db.collections.User.findOne({ + 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.ResponseError('', undefined, 404); + } + + Logger.info('Reset-password token resolved successfully', { userId: id }); + void InternalHooksManager.getInstance().onUserPasswordResetEmailClick({ + user_id: id, + }); + } + + /** + * 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) { + 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.ResponseError( + 'Missing user ID or password or reset password token', + undefined, + 400, + ); + } + + const validPassword = validatePassword(password); + + // Timestamp is saved in seconds + const currentTimestamp = Math.floor(Date.now() / 1000); + + const user = await Db.collections.User.findOne({ + id: userId, + 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, + resetPasswordToken, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 404); + } + + 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); + } +} diff --git a/packages/cli/src/UserManagement/controllers/UserController.ts b/packages/cli/src/UserManagement/controllers/UserController.ts new file mode 100644 index 0000000000000..7aa90a117902f --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/UserController.ts @@ -0,0 +1,485 @@ +/* eslint-disable import/no-cycle */ +import { Response } from 'express'; +import { In } from 'typeorm'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import type { UserRequest } from '../../requests'; +import { Delete, Get, Post, RestController } from '../decorators'; +import { ActiveWorkflowRunner, Db, ITelemetryUserDeletionData, ResponseHelper } from '../..'; +import { User } from '../../databases/entities/User'; +import { SharedWorkflow } from '../../databases/entities/SharedWorkflow'; +import { SharedCredentials } from '../../databases/entities/SharedCredentials'; +import { InternalHooksManager } from '../../InternalHooksManager'; +import * as UserManagementMailer from '../email'; +import { + isUserManagementDisabled, + getInstanceBaseUrl, + validatePassword, + hashPassword, + sanitizeUser, + isEmailSetUp, +} from '../UserManagementHelper'; +import * as config from '../../../config'; +import { issueCookie } from '../auth/jwt'; + +@RestController('/users') +export class UserController { + /** + * Send email invite(s) to one or multiple users and create user shell(s). + */ + @Post('/') + async sendEmailInvites(req: UserRequest.Invite) { + if (config.getEnv('userManagement.emails.mode') === '') { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because emailing was not set up', + ); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + `There is a problem with your SMTP setup! ${error.message}`, + undefined, + 500, + ); + } + } + + // 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.ResponseError('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.ResponseError( + 'You must set up your own account before inviting others', + undefined, + 400, + ); + } + + 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.ResponseError('Invalid payload', undefined, 400); + } + + 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.ResponseError( + 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', + undefined, + 400, + ); + } + + if (!validator.isEmail(invite.email)) { + Logger.debug('Invalid email in payload', { invalidEmail: invite.email }); + throw new ResponseHelper.ResponseError( + `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, + undefined, + 400, + ); + } + createUsers[invite.email.toLowerCase()] = null; + }); + + const role = await Db.collections.Role.findOne({ 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.ResponseError( + 'Members role not found in database - inconsistent state', + undefined, + 500, + ); + } + + // 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; + }), + ); + }); + + void InternalHooksManager.getInstance().onUserInvite({ + user_id: req.user.id, + target_user_id: Object.values(createUsers) as string[], + public_api: false, + }); + } catch (error) { + Logger.error('Failed to create user shells', { userShells: createUsers }); + throw new ResponseHelper.ResponseError('An error occurred during user creation'); + } + + Logger.info('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]) => { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${id}`; + const result = await mailer?.invite({ + email, + inviteAcceptUrl, + domain: baseUrl, + }); + const resp: { user: { id: string | null; email: string }; error?: string } = { + user: { + id, + email, + }, + }; + if (result?.success) { + 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, + }); + } else { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: req.user.id, + message_type: 'New user invite', + public_api: false, + }); + Logger.error('Failed to send email', { + userId: req.user.id, + inviteAcceptUrl, + domain: baseUrl, + email, + }); + resp.error = `Email could not be sent`; + } + return resp; + }), + ); + + 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. + * + * Authless endpoint. + */ + @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) { + Logger.debug( + 'Request to fill out a user shell failed because of missing properties in payload', + { payload: req.body }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + 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.ResponseError('Invalid payload or URL', undefined, 400); + } + + 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.ResponseError( + 'This invite has been accepted already', + undefined, + 400, + ); + } + + 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({ + user_id: invitee.id, + }); + + return sanitizeUser(updatedUser); + } + + @Get('/') + async listUsers() { + const users = await Db.collections.User.find({ relations: ['globalRole'] }); + return users.map((user) => sanitizeUser(user, ['personalizationAnswers'])); + } + + /** + * 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) { + Logger.debug( + 'Request to delete a user failed because it attempted to delete the requesting user', + { userId: req.user.id }, + ); + throw new ResponseHelper.ResponseError('Cannot delete your own user', undefined, 400); + } + + const { transferId } = req.query; + + if (transferId === idToDelete) { + throw new ResponseHelper.ResponseError( + 'Request to delete a user failed because the user to delete and the transferee are the same user', + undefined, + 400, + ); + } + + const users = await Db.collections.User.find({ + where: { id: In([transferId, idToDelete]) }, + }); + + if (!users.length || (transferId && users.length !== 2)) { + throw new ResponseHelper.ResponseError( + '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', + undefined, + 404, + ); + } + + const userToDelete = users.find((user) => user.id === req.params.id) as User; + + if (transferId) { + const transferee = users.find((user) => user.id === transferId); + await Db.transaction(async (transactionManager) => { + await transactionManager.update( + SharedWorkflow, + { user: userToDelete }, + { user: transferee }, + ); + await transactionManager.update( + SharedCredentials, + { user: userToDelete }, + { user: transferee }, + ); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + + return { success: true }; + } + + const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ + Db.collections.SharedWorkflow.find({ + relations: ['workflow'], + where: { user: userToDelete }, + }), + Db.collections.SharedCredentials.find({ + relations: ['credentials'], + where: { user: userToDelete }, + }), + ]); + + await Db.transaction(async (transactionManager) => { + const ownedWorkflows = await Promise.all( + ownedSharedWorkflows.map(async ({ workflow }) => { + if (workflow.active) { + // deactivate before deleting + await ActiveWorkflowRunner.getInstance().remove(workflow.id.toString()); + } + return workflow; + }), + ); + await transactionManager.remove(ownedWorkflows); + await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials)); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + + 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; + } + + void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false); + + return { success: true }; + } + + /** + * Resend email invite to user. + */ + @Post('/:id/reinvite') + async reinviteUser(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.ResponseError( + 'Email sending must be set up in order to invite other users', + undefined, + 500, + ); + } + + const reinvitee = await Db.collections.User.findOne({ 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.ResponseError('Could not find user', undefined, 404); + } + + if (reinvitee.password) { + Logger.debug( + 'Request to reinvite a user failed because the invite had already been accepted', + { userId: reinvitee.id }, + ); + throw new ResponseHelper.ResponseError( + 'User has already accepted the invite', + undefined, + 400, + ); + } + + const baseUrl = getInstanceBaseUrl(); + const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`; + + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError(error.message, undefined, 500); + } + } + + const result = await mailer?.invite({ + email: reinvitee.email, + inviteAcceptUrl, + domain: baseUrl, + }); + + if (!result?.success) { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: req.user.id, + message_type: 'Resend invite', + public_api: false, + }); + Logger.error('Failed to send email', { + email: reinvitee.email, + inviteAcceptUrl, + domain: baseUrl, + }); + throw new ResponseHelper.ResponseError( + `Failed to send email to ${reinvitee.email}`, + undefined, + 500, + ); + } + + void InternalHooksManager.getInstance().onUserReinvite({ + user_id: req.user.id, + target_user_id: reinvitee.id, + public_api: false, + }); + + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: reinvitee.id, + message_type: 'Resend invite', + public_api: false, + }); + + return { success: true }; + } +} diff --git a/packages/cli/src/UserManagement/controllers/index.ts b/packages/cli/src/UserManagement/controllers/index.ts new file mode 100644 index 0000000000000..e5cc9e72dbd5b --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/index.ts @@ -0,0 +1,6 @@ +/* eslint-disable import/no-cycle */ +export * from './AuthController'; +export * from './MeController'; +export * from './OwnerController'; +export * from './PasswordResetController'; +export * from './UserController'; diff --git a/packages/cli/src/UserManagement/decorators.ts b/packages/cli/src/UserManagement/decorators.ts new file mode 100644 index 0000000000000..2d9fe1d87cbf9 --- /dev/null +++ b/packages/cli/src/UserManagement/decorators.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable import/no-cycle */ +import { Application, Request, RequestHandler as Middleware, Response, Router } from 'express'; +import { ResponseHelper } from '..'; +import config from '../../config'; + +export interface RouteMetadata { + method: Method; + path: string; + handlerName: string | symbol; +} + +const Keys = { + ROUTES: Symbol('routes'), + BASE_PATH: Symbol('base_path'), +} as const; + +type Method = 'get' | 'post' | 'patch' | 'put' | 'delete'; + +export const RestController = + (basePath: `/${string}` = '/'): ClassDecorator => + (target: object) => { + Reflect.defineMetadata(Keys.BASE_PATH, basePath, target); + }; + +const RouteFactory = + (...methods: Method[]) => + (path: `/${string}`): MethodDecorator => + (target, handlerName) => { + const ControllerClass = target.constructor; + const routes: RouteMetadata[] = Reflect.getMetadata(Keys.ROUTES, ControllerClass) ?? []; + methods.forEach((method) => { + routes.push({ method, path, handlerName }); + }); + + Reflect.defineMetadata(Keys.ROUTES, routes, ControllerClass); + }; + +export const Get = RouteFactory('get'); +export const Post = RouteFactory('post'); +export const Patch = RouteFactory('patch'); +export const Put = RouteFactory('put'); +export const Delete = RouteFactory('delete'); + +type RequestHandler = (req: Request, res: Response) => Promise; + +interface Controller { + new (...args: unknown[]): T; +} + +export const registerController = ( + app: Application, + ControllerClass: Controller, + ...middlewares: Middleware[] +): void => { + const instance = new ControllerClass() as unknown as Record; + const routes: RouteMetadata[] = Reflect.getMetadata(Keys.ROUTES, ControllerClass); + if (routes.length) { + const router = Router({ mergeParams: true }); + const restEndpoint: string = config.getEnv('endpoints.rest'); + const basePath: string = Reflect.getMetadata(Keys.BASE_PATH, ControllerClass); + const prefix = `/${[restEndpoint, basePath].join('/')}`.replace(/\/+/g, '/'); + + routes.forEach(({ method, path, handlerName }) => { + router[method]( + path, + ...middlewares, + ResponseHelper.send(async (req: Request, res: Response) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + instance[String(handlerName)].call(instance, req, res), + ), + ); + }); + + app.use(prefix, router); + } +}; diff --git a/packages/cli/src/UserManagement/middlewares/auth.ts b/packages/cli/src/UserManagement/middlewares/auth.ts new file mode 100644 index 0000000000000..81bbf4a2412b7 --- /dev/null +++ b/packages/cli/src/UserManagement/middlewares/auth.ts @@ -0,0 +1,56 @@ +/* eslint-disable import/no-cycle */ +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 * as 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/middlewares/index.ts b/packages/cli/src/UserManagement/middlewares/index.ts new file mode 100644 index 0000000000000..b73e939959ed2 --- /dev/null +++ b/packages/cli/src/UserManagement/middlewares/index.ts @@ -0,0 +1,2 @@ +/* eslint-disable import/no-cycle */ +export * from './auth'; diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts deleted file mode 100644 index a5d865eac083a..0000000000000 --- a/packages/cli/src/UserManagement/routes/auth.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable import/no-cycle */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { Request, Response } from 'express'; -import { IDataObject } from 'n8n-workflow'; -import { Db, ResponseHelper } from '../..'; -import { AUTH_COOKIE_NAME } from '../../constants'; -import { issueCookie, resolveJwt } from '../auth/jwt'; -import { N8nApp, PublicUser } from '../Interfaces'; -import { compareHash, sanitizeUser } from '../UserManagementHelper'; -import { User } from '../../databases/entities/User'; -import type { LoginRequest } from '../../requests'; -import config = require('../../../config'); - -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 => { - if (!req.body.email) { - throw new Error('Email is required to log in'); - } - - if (!req.body.password) { - throw new Error('Password is required to log in'); - } - - let user; - try { - user = await Db.collections.User.findOne( - { - email: req.body.email, - }, - { - relations: ['globalRole'], - }, - ); - } catch (error) { - throw new Error('Unable to access database.'); - } - - if (!user || !user.password || !(await compareHash(req.body.password, user.password))) { - // password is empty until user signs up - const error = new Error('Wrong username or password. Do you have caps lock on?'); - // @ts-ignore - error.httpStatusCode = 401; - throw error; - } - - await issueCookie(res, user); - - return sanitizeUser(user); - }), - ); - - /** - * 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')) { - const error = new Error('Not logged in'); - // @ts-ignore - error.httpStatusCode = 401; - throw error; - } - - try { - user = await Db.collections.User.findOneOrFail({ relations: ['globalRole'] }); - } catch (error) { - throw new Error( - 'No users found in database - did you wipe the users table? Create at least one user.', - ); - } - - if (user.email || user.password) { - throw new Error('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/index.ts b/packages/cli/src/UserManagement/routes/index.ts index 0a8400e4efdf5..3a6f8d9db6bb0 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -4,21 +4,10 @@ /* eslint-disable import/no-cycle */ import cookieParser from 'cookie-parser'; import passport from 'passport'; -import { Strategy } from 'passport-jwt'; import { NextFunction, Request, Response } from 'express'; -import jwt from 'jsonwebtoken'; import { LoggerProxy as Logger } from 'n8n-workflow'; - -import { JwtPayload, N8nApp } from '../Interfaces'; -import { authenticationMethods } from './auth'; -import * as config from '../../../config'; -import { AUTH_COOKIE_NAME } from '../../constants'; -import { issueCookie, resolveJwtContent } from '../auth/jwt'; -import { meNamespace } from './me'; -import { usersNamespace } from './users'; -import { passwordResetNamespace } from './passwordReset'; +import { N8nApp } from '../Interfaces'; import { AuthenticatedRequest } from '../../requests'; -import { ownerNamespace } from './owner'; import { isAuthExcluded, isPostUsersId, @@ -26,32 +15,20 @@ import { isUserManagementDisabled, } from '../UserManagementHelper'; import { Db } from '../..'; +import { registerController } from '../decorators'; +import { + AuthController, + MeController, + OwnerController, + PasswordResetController, + UserController, +} from '../controllers'; +import { jwtAuth, refreshExpiringCookie } from '../middlewares'; export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { // needed for testing; not adding overhead since it directly returns if req.cookies exists this.app.use(cookieParser()); - - const options = { - jwtFromRequest: (req: Request) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null; - }, - secretOrKey: config.getEnv('userManagement.jwtSecret'), - }; - - passport.use( - new Strategy(options, async function validateCookieContents(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' }); - } - }), - ); - - this.app.use(passport.initialize()); + this.app.use(jwtAuth()); this.app.use(async (req: Request, res: Response, next: NextFunction) => { if ( @@ -124,22 +101,11 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint next(); }); - // middleware to refresh cookie before it expires - this.app.use(async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - const cookieAuth = options.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(); - }); + this.app.use(refreshExpiringCookie); - authenticationMethods.apply(this); - ownerNamespace.apply(this); - meNamespace.apply(this); - passwordResetNamespace.apply(this); - usersNamespace.apply(this); + registerController(this.app, AuthController); + registerController(this.app, MeController); + registerController(this.app, OwnerController); + registerController(this.app, PasswordResetController); + registerController(this.app, UserController); } diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts deleted file mode 100644 index 07ca87dcec92d..0000000000000 --- a/packages/cli/src/UserManagement/routes/me.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable import/no-cycle */ - -import express from 'express'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; - -import { randomBytes } from 'crypto'; -import { Db, InternalHooksManager, ResponseHelper } from '../..'; -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 '../../databases/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 => { - if (!req.body.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.ResponseError('Email is mandatory', undefined, 400); - } - - if (!validator.isEmail(req.body.email)) { - Logger.debug('Request to update user email failed because of invalid email in payload', { - userId: req.user.id, - invalidEmail: req.body.email, - }); - throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); - } - - 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_id: req.user.id, - fields_changed: updatedkeys, - }); - - 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.ResponseError('Invalid payload.', undefined, 400); - } - - if (!req.user.password) { - throw new ResponseHelper.ResponseError('Requesting user not set up.'); - } - - const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password); - if (!isCurrentPwCorrect) { - throw new ResponseHelper.ResponseError( - 'Provided current password is incorrect.', - undefined, - 400, - ); - } - - 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_id: req.user.id, - fields_changed: ['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.ResponseError( - 'Personalization answers are mandatory', - undefined, - 400, - ); - } - - 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, - }); - - const telemetryData = { - user_id: req.user.id, - public_api: false, - }; - - void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData); - - 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, - }); - - const telemetryData = { - user_id: req.user.id, - public_api: false, - }; - - void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData); - - 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 f97237a9c691a..0000000000000 --- a/packages/cli/src/UserManagement/routes/owner.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable import/no-cycle */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import express from 'express'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; - -import { Db, InternalHooksManager, ResponseHelper } from '../..'; -import * as 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.ResponseError('Invalid request', undefined, 400); - } - - if (!email || !validator.isEmail(email)) { - Logger.debug('Request to claim instance ownership failed because of invalid email', { - userId, - invalidEmail: email, - }); - throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); - } - - 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.ResponseError( - 'First and last names are mandatory', - undefined, - 400, - ); - } - - let owner = await Db.collections.User.findOne(userId, { - relations: ['globalRole'], - }); - - 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.ResponseError('Invalid request', undefined, 400); - } - - 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 be40c3240cf73..0000000000000 --- a/packages/cli/src/UserManagement/routes/passwordReset.ts +++ /dev/null @@ -1,220 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable import/no-cycle */ - -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 { Db, InternalHooksManager, ResponseHelper } from '../..'; -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 * as config from '../../../config'; - -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.ResponseError( - 'Email sending must be set up in order to request a password reset email', - undefined, - 500, - ); - } - - 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.ResponseError('Email is mandatory', undefined, 400); - } - - if (!validator.isEmail(email)) { - Logger.debug( - 'Request to send password reset email failed because of invalid email in payload', - { invalidEmail: email }, - ); - throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); - } - - // User should just be able to reset password if one is already present - const user = await Db.collections.User.findOne({ email, password: Not(IsNull()) }); - - if (!user || !user.password) { - Logger.debug( - 'Request to send password reset email failed because no user was found for the provided email', - { invalidEmail: email }, - ); - return; - } - - 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 = await UserManagementMailer.getInstance(); - await mailer.passwordReset({ - email, - firstName, - lastName, - passwordResetUrl: url.toString(), - domain: baseUrl, - }); - } catch (error) { - void InternalHooksManager.getInstance().onEmailFailed({ - user_id: user.id, - message_type: 'Reset password', - public_api: false, - }); - if (error instanceof Error) { - throw new ResponseHelper.ResponseError( - `Please contact your administrator: ${error.message}`, - undefined, - 500, - ); - } - } - - 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_id: id, - }); - }), - ); - - /** - * 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.ResponseError('', undefined, 400); - } - - // Timestamp is saved in seconds - const currentTimestamp = Math.floor(Date.now() / 1000); - - const user = await Db.collections.User.findOne({ - 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.ResponseError('', undefined, 404); - } - - Logger.info('Reset-password token resolved successfully', { userId: id }); - void InternalHooksManager.getInstance().onUserPasswordResetEmailClick({ - user_id: id, - }); - }), - ); - - /** - * 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.ResponseError( - 'Missing user ID or password or reset password token', - undefined, - 400, - ); - } - - const validPassword = validatePassword(password); - - // Timestamp is saved in seconds - const currentTimestamp = Math.floor(Date.now() / 1000); - - const user = await Db.collections.User.findOne({ - id: userId, - 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, - resetPasswordToken, - }, - ); - throw new ResponseHelper.ResponseError('', undefined, 404); - } - - 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); - }), - ); -} diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts deleted file mode 100644 index d82edc5a2c978..0000000000000 --- a/packages/cli/src/UserManagement/routes/users.ts +++ /dev/null @@ -1,574 +0,0 @@ -/* eslint-disable no-restricted-syntax */ -/* eslint-disable import/no-cycle */ -import { Response } from 'express'; -import { In } from 'typeorm'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; - -import { Db, InternalHooksManager, ITelemetryUserDeletionData, ResponseHelper } from '../..'; -import { N8nApp, PublicUser } from '../Interfaces'; -import { UserRequest } from '../../requests'; -import { - getInstanceBaseUrl, - hashPassword, - isEmailSetUp, - isUserManagementDisabled, - sanitizeUser, - validatePassword, -} from '../UserManagementHelper'; -import { User } from '../../databases/entities/User'; -import { SharedWorkflow } from '../../databases/entities/SharedWorkflow'; -import { SharedCredentials } from '../../databases/entities/SharedCredentials'; -import * as UserManagementMailer from '../email/UserManagementMailer'; - -import * as config from '../../../config'; -import { issueCookie } from '../auth/jwt'; - -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) => { - if (config.getEnv('userManagement.emails.mode') === '') { - Logger.debug( - 'Request to send email invite(s) to user(s) failed because emailing was not set up', - ); - throw new ResponseHelper.ResponseError( - 'Email sending must be set up in order to request a password reset email', - undefined, - 500, - ); - } - - let mailer: UserManagementMailer.UserManagementMailer | undefined; - try { - mailer = await UserManagementMailer.getInstance(); - } catch (error) { - if (error instanceof Error) { - throw new ResponseHelper.ResponseError( - `There is a problem with your SMTP setup! ${error.message}`, - undefined, - 500, - ); - } - } - - // 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.ResponseError('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.ResponseError( - 'You must set up your own account before inviting others', - undefined, - 400, - ); - } - - 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.ResponseError('Invalid payload', undefined, 400); - } - - 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.ResponseError( - 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', - undefined, - 400, - ); - } - - if (!validator.isEmail(invite.email)) { - Logger.debug('Invalid email in payload', { invalidEmail: invite.email }); - throw new ResponseHelper.ResponseError( - `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, - undefined, - 400, - ); - } - createUsers[invite.email.toLowerCase()] = null; - }); - - const role = await Db.collections.Role.findOne({ 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.ResponseError( - 'Members role not found in database - inconsistent state', - undefined, - 500, - ); - } - - // 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; - }), - ); - }); - - void InternalHooksManager.getInstance().onUserInvite({ - user_id: req.user.id, - target_user_id: Object.values(createUsers) as string[], - public_api: false, - }); - } catch (error) { - Logger.error('Failed to create user shells', { userShells: createUsers }); - throw new ResponseHelper.ResponseError('An error occurred during user creation'); - } - - Logger.info('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]) => { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${id}`; - const result = await mailer?.invite({ - email, - inviteAcceptUrl, - domain: baseUrl, - }); - const resp: { user: { id: string | null; email: string }; error?: string } = { - user: { - id, - email, - }, - }; - if (result?.success) { - 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, - }); - } else { - void InternalHooksManager.getInstance().onEmailFailed({ - user_id: req.user.id, - message_type: 'New user invite', - public_api: false, - }); - Logger.error('Failed to send email', { - userId: req.user.id, - inviteAcceptUrl, - domain: baseUrl, - email, - }); - resp.error = `Email could not be sent`; - } - return resp; - }), - ); - - 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.ResponseError('Invalid payload', undefined, 400); - } - - // 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.ResponseError('Invalid userId', undefined, 400); - } - } - - 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.ResponseError('Invalid invite URL', undefined, 400); - } - - 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.ResponseError( - 'The invitation was likely either deleted or already claimed', - undefined, - 400, - ); - } - - const inviter = users.find((user) => user.id === inviterId); - - if (!inviter || !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.ResponseError('Invalid request', undefined, 400); - } - - void InternalHooksManager.getInstance().onUserInviteEmailClick({ - user_id: inviteeId, - }); - - 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.ResponseError('Invalid payload', undefined, 400); - } - - 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.ResponseError('Invalid payload or URL', undefined, 400); - } - - 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.ResponseError( - 'This invite has been accepted already', - undefined, - 400, - ); - } - - 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({ - user_id: invitee.id, - }); - - return sanitizeUser(updatedUser); - }), - ); - - this.app.get( - `/${this.restEndpoint}/users`, - ResponseHelper.send(async () => { - const users = await Db.collections.User.find({ relations: ['globalRole'] }); - - return users.map((user): PublicUser => sanitizeUser(user, ['personalizationAnswers'])); - }), - ); - - /** - * 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.ResponseError('Cannot delete your own user', undefined, 400); - } - - const { transferId } = req.query; - - if (transferId === idToDelete) { - throw new ResponseHelper.ResponseError( - 'Request to delete a user failed because the user to delete and the transferee are the same user', - undefined, - 400, - ); - } - - const users = await Db.collections.User.find({ - where: { id: In([transferId, idToDelete]) }, - }); - - if (!users.length || (transferId && users.length !== 2)) { - throw new ResponseHelper.ResponseError( - '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', - undefined, - 404, - ); - } - - const userToDelete = users.find((user) => user.id === req.params.id) as User; - - if (transferId) { - const transferee = users.find((user) => user.id === transferId); - await Db.transaction(async (transactionManager) => { - await transactionManager.update( - SharedWorkflow, - { user: userToDelete }, - { user: transferee }, - ); - await transactionManager.update( - SharedCredentials, - { user: userToDelete }, - { user: transferee }, - ); - await transactionManager.delete(User, { id: userToDelete.id }); - }); - - return { success: true }; - } - - const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ - Db.collections.SharedWorkflow.find({ - relations: ['workflow'], - where: { user: userToDelete }, - }), - Db.collections.SharedCredentials.find({ - relations: ['credentials'], - where: { user: userToDelete }, - }), - ]); - - 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.toString()); - } - return workflow; - }), - ); - await transactionManager.remove(ownedWorkflows); - await transactionManager.remove( - ownedSharedCredentials.map(({ credentials }) => credentials), - ); - await transactionManager.delete(User, { id: userToDelete.id }); - }); - - 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; - } - - void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false); - - 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.ResponseError( - 'Email sending must be set up in order to invite other users', - undefined, - 500, - ); - } - - const reinvitee = await Db.collections.User.findOne({ 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.ResponseError('Could not find user', undefined, 404); - } - - if (reinvitee.password) { - Logger.debug( - 'Request to reinvite a user failed because the invite had already been accepted', - { userId: reinvitee.id }, - ); - throw new ResponseHelper.ResponseError( - 'User has already accepted the invite', - undefined, - 400, - ); - } - - const baseUrl = getInstanceBaseUrl(); - const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`; - - let mailer: UserManagementMailer.UserManagementMailer | undefined; - try { - mailer = await UserManagementMailer.getInstance(); - } catch (error) { - if (error instanceof Error) { - throw new ResponseHelper.ResponseError(error.message, undefined, 500); - } - } - - const result = await mailer?.invite({ - email: reinvitee.email, - inviteAcceptUrl, - domain: baseUrl, - }); - - if (!result?.success) { - void InternalHooksManager.getInstance().onEmailFailed({ - user_id: req.user.id, - message_type: 'Resend invite', - public_api: false, - }); - Logger.error('Failed to send email', { - email: reinvitee.email, - inviteAcceptUrl, - domain: baseUrl, - }); - throw new ResponseHelper.ResponseError( - `Failed to send email to ${reinvitee.email}`, - undefined, - 500, - ); - } - - void InternalHooksManager.getInstance().onUserReinvite({ - user_id: req.user.id, - target_user_id: reinvitee.id, - public_api: false, - }); - - void InternalHooksManager.getInstance().onUserTransactionalEmail({ - user_id: reinvitee.id, - message_type: 'Resend invite', - public_api: false, - }); - - return { success: true }; - }), - ); -} diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 47f5d6f8aea4d..c1ca312529fd9 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -34,11 +34,6 @@ import { InternalHooksManager, NodeTypes, } from '../../../src'; -import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me'; -import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users'; -import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; -import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; -import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; import { issueJWT } from '../../../src/UserManagement/auth/jwt'; import { getLogger } from '../../../src/Logger'; import { credentialsController } from '../../../src/credentials/credentials.controller'; @@ -56,6 +51,14 @@ import type { N8nApp } from '../../../src/UserManagement/Interfaces'; import { workflowsController } from '../../../src/api/workflows.api'; import { nodesController } from '../../../src/api/nodes.api'; import { randomName } from './random'; +import { registerController } from '../../../src/UserManagement/decorators'; +import { + AuthController, + MeController, + OwnerController, + PasswordResetController, +} from '../../../src/UserManagement/controllers'; +import { UserController } from '../../../src/UserManagement/controllers/UserController'; /** * Initialize a test server. @@ -93,7 +96,7 @@ export async function initTestServer({ testServer.externalHooks = ExternalHooks(); } - const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups); + const [routerEndpoints] = classifyEndpointGroups(endpointGroups); if (routerEndpoints.length) { const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint); @@ -113,19 +116,11 @@ export async function initTestServer({ } } - if (functionEndpoints.length) { - const map: Record void> = { - me: meEndpoints, - users: usersEndpoints, - auth: authEndpoints, - owner: ownerEndpoints, - passwordReset: passwordResetEndpoints, - }; - - for (const group of functionEndpoints) { - map[group].apply(testServer); - } - } + registerController(testServer.app, MeController); + registerController(testServer.app, UserController); + registerController(testServer.app, AuthController); + registerController(testServer.app, OwnerController); + registerController(testServer.app, PasswordResetController); return testServer.app; } @@ -140,20 +135,18 @@ export function initTestTelemetry() { } /** - * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), - * and `functionEndpoints` (legacy, namespaced inside a function). + * Identify endpoint groups as `routerEndpoints` (newest, using `express.Router`) */ const classifyEndpointGroups = (endpointGroups: string[]) => { const routerEndpoints: string[] = []; - const functionEndpoints: string[] = []; const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi']; endpointGroups.forEach((group) => - (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), + ROUTER_GROUP.includes(group) && routerEndpoints.push(group), ); - return [routerEndpoints, functionEndpoints]; + return [routerEndpoints]; }; // ----------------------------------