diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index da54dfc52b4c8..d74927e6e279d 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -146,9 +146,12 @@ const isUniqueConstraintError = (error: Error) => * @returns */ -export function send(processFunction: (req: Request, res: Response) => Promise, raw = false) { +export function send( + processFunction: (req: R, res: S) => Promise, + raw = false, +) { // eslint-disable-next-line consistent-return - return async (req: Request, res: Response) => { + 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..61da31265d4ec --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/AuthController.ts @@ -0,0 +1,116 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import type { Request, Response } from 'express'; +import type { IDataObject } from 'n8n-workflow'; +import { Db } 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 } 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); + } + + /** + * Log out a user. + * + * Authless endpoint. + */ + @Post('/logout') + logout(req: Request, res: Response): IDataObject { + 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..737cceb849dce --- /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..53debbf8ba61f --- /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/index.ts b/packages/cli/src/UserManagement/controllers/index.ts new file mode 100644 index 0000000000000..29602abcc4337 --- /dev/null +++ b/packages/cli/src/UserManagement/controllers/index.ts @@ -0,0 +1,5 @@ +/* eslint-disable import/no-cycle */ +export * from './AuthController'; +export * from './MeController'; +export * from './OwnerController'; +export * from './PasswordResetController'; diff --git a/packages/cli/src/UserManagement/decorators.ts b/packages/cli/src/UserManagement/decorators.ts new file mode 100644 index 0000000000000..64be099385761 --- /dev/null +++ b/packages/cli/src/UserManagement/decorators.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable import/no-cycle */ +import { Application, Request, Response, Router } from 'express'; +import { posix } from 'path'; +import { ResponseHelper } from '..'; +import config from '../../config'; + +interface RequestHandler extends Function { + async(req: Request, res: Response): Promise; +} + +export interface IRouter { + method: Method; + path: string; + handlerName: string | symbol; +} + +const Keys = { + ROUTES: Symbol('routes'), + BASE_PATH: Symbol('base_path'), +} as const; + +export enum Method { + GET = 'get', + POST = 'post', + PATCH = 'patch', + DELETE = 'delete', +} + +export const RestController = + (basePath = '/'): ClassDecorator => + (target: object) => { + Reflect.defineMetadata(Keys.BASE_PATH, basePath, target); + }; + +const RouteFactory = + (...methods: Method[]) => + (path = '/'): MethodDecorator => + (target, handlerName) => { + const ControllerClass = target.constructor; + const routers: IRouter[] = Reflect.hasMetadata(Keys.ROUTES, ControllerClass) + ? (Reflect.getMetadata(Keys.ROUTES, ControllerClass) as IRouter[]) + : []; + methods.forEach((method) => { + routers.push({ method, path, handlerName }); + }); + + Reflect.defineMetadata(Keys.ROUTES, routers, ControllerClass); + }; + +export const Get = RouteFactory(Method.GET); +export const Post = RouteFactory(Method.POST); +export const Patch = RouteFactory(Method.PATCH); +export const Delete = RouteFactory(Method.DELETE); + +export interface Controller extends Function { + new (...args: unknown[]): T; +} + +export const registerController = (app: Application, ControllerClass: Controller): void => { + const instance = new ControllerClass() as unknown as { + [handleName: string]: RequestHandler; + }; + const routes: IRouter[] = Reflect.getMetadata(Keys.ROUTES, ControllerClass); + if (routes.length > 0) { + const router = Router(); + const restEndpoint: string = config.get('endpoints.rest'); + const basePath: string = Reflect.getMetadata(Keys.BASE_PATH, ControllerClass); + const prefix = `/${posix.join(restEndpoint, basePath)}`; + + routes.forEach(({ method, path, handlerName }) => { + router[method]( + path, + 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/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..dff1aa27fa416 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -10,15 +10,11 @@ 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 { AuthenticatedRequest } from '../../requests'; -import { ownerNamespace } from './owner'; import { isAuthExcluded, isPostUsersId, @@ -26,6 +22,13 @@ import { isUserManagementDisabled, } from '../UserManagementHelper'; import { Db } from '../..'; +import { registerController } from '../decorators'; +import { + AuthController, + MeController, + OwnerController, + PasswordResetController, +} from '../controllers'; export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { // needed for testing; not adding overhead since it directly returns if req.cookies exists @@ -137,9 +140,10 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint next(); }); - authenticationMethods.apply(this); - ownerNamespace.apply(this); - meNamespace.apply(this); - passwordResetNamespace.apply(this); + registerController(this.app, AuthController); + registerController(this.app, MeController); + registerController(this.app, OwnerController); + registerController(this.app, PasswordResetController); + usersNamespace.apply(this); } 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/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 47f5d6f8aea4d..b8911abe17690 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -34,11 +34,7 @@ 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 +52,13 @@ 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'; /** * Initialize a test server. @@ -114,17 +117,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); + usersEndpoints.apply(testServer); + registerController(testServer.app, AuthController); + registerController(testServer.app, OwnerController); + registerController(testServer.app, PasswordResetController); } return testServer.app;