refactor(core): Update invitation endpoints to use DTOs (#12377)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-12-26 18:24:14 +01:00
committed by GitHub
parent 371a09de96
commit 7b2630d1a0
14 changed files with 282 additions and 171 deletions

View File

@@ -1,20 +1,20 @@
import { AcceptInvitationRequestDto, InviteUsersRequestDto } from '@n8n/api-types';
import { Response } from 'express';
import { Logger } from 'n8n-core';
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import type { User } from '@/databases/entities/user';
import { UserRepository } from '@/databases/repositories/user.repository';
import { Post, GlobalScope, RestController } from '@/decorators';
import { Post, GlobalScope, RestController, Body, Param } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { License } from '@/license';
import { PostHogClient } from '@/posthog';
import { UserRequest } from '@/requests';
import { AuthenticatedRequest, AuthlessRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service';
import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers';
@@ -39,7 +39,13 @@ export class InvitationController {
@Post('/', { rateLimit: { limit: 10 } })
@GlobalScope('user:create')
async inviteUser(req: UserRequest.Invite) {
async inviteUser(
req: AuthenticatedRequest,
_res: Response,
@Body invitations: InviteUsersRequestDto,
) {
if (invitations.length === 0) return [];
const isWithinUsersLimit = this.license.isWithinUsersLimit();
if (isSamlLicensedAndEnabled()) {
@@ -65,50 +71,15 @@ export class InvitationController {
throw new BadRequestError('You must set up your own account before inviting others');
}
if (!Array.isArray(req.body)) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the payload is not an array',
{
payload: req.body,
},
);
throw new BadRequestError('Invalid payload');
}
if (!req.body.length) return [];
req.body.forEach((invite) => {
if (typeof invite !== 'object' || !invite.email) {
throw new BadRequestError(
'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>',
);
}
if (!validator.isEmail(invite.email)) {
this.logger.debug('Invalid email in payload', { invalidEmail: invite.email });
throw new BadRequestError(
`Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`,
);
}
if (invite.role && !['global:member', 'global:admin'].includes(invite.role)) {
throw new BadRequestError(
`Cannot invite user with invalid role: ${invite.role}. Please ensure all invitees' roles are either 'global:member' or 'global:admin'.`,
);
}
if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) {
const attributes = invitations.map(({ email, role }) => {
if (role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) {
throw new ForbiddenError(
'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.',
);
}
return { email, role };
});
const attributes = req.body.map(({ email, role }) => ({
email,
role: role ?? 'global:member',
}));
const { usersInvited, usersCreated } = await this.userService.inviteUsers(req.user, attributes);
await this.externalHooks.run('user.invited', [usersCreated]);
@@ -120,20 +91,13 @@ export class InvitationController {
* Fill out user shell with first name, last name, and password.
*/
@Post('/:id/accept', { skipAuth: true })
async acceptInvitation(req: UserRequest.Update, res: Response) {
const { id: inviteeId } = req.params;
const { inviterId, firstName, lastName, password } = req.body;
if (!inviterId || !inviteeId || !firstName || !lastName || !password) {
this.logger.debug(
'Request to fill out a user shell failed because of missing properties in payload',
{ payload: req.body },
);
throw new BadRequestError('Invalid payload');
}
const validPassword = this.passwordUtility.validate(password);
async acceptInvitation(
req: AuthlessRequest,
res: Response,
@Body payload: AcceptInvitationRequestDto,
@Param('id') inviteeId: string,
) {
const { inviterId, firstName, lastName, password } = payload;
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
@@ -160,7 +124,7 @@ export class InvitationController {
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = await this.passwordUtility.hash(validPassword);
invitee.password = await this.passwordUtility.hash(password);
const updatedUser = await this.userRepository.save(invitee, { transaction: false });

View File

@@ -1,4 +1,5 @@
import {
passwordSchema,
PasswordUpdateRequestDto,
SettingsUpdateRequestDto,
UserUpdateRequestDto,
@@ -122,10 +123,6 @@ export class MeController {
);
}
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
throw new BadRequestError('Invalid payload.');
}
if (!user.password) {
throw new BadRequestError('Requesting user not set up.');
}
@@ -135,7 +132,12 @@ export class MeController {
throw new BadRequestError('Provided current password is incorrect.');
}
const validPassword = this.passwordUtility.validate(newPassword);
const passwordValidation = passwordSchema.safeParse(newPassword);
if (!passwordValidation.success) {
throw new BadRequestError(
passwordValidation.error.errors.map(({ message }) => message).join(' '),
);
}
if (user.mfaEnabled) {
if (typeof mfaCode !== 'string') {
@@ -148,7 +150,7 @@ export class MeController {
}
}
user.password = await this.passwordUtility.hash(validPassword);
user.password = await this.passwordUtility.hash(newPassword);
const updatedUser = await this.userRepository.save(user, { transaction: false });
this.logger.info('Password updated successfully', { userId: user.id });

View File

@@ -93,7 +93,7 @@ export class ControllerRegistry {
if (arg.type === 'param') args.push(req.params[arg.key]);
else if (['body', 'query'].includes(arg.type)) {
const paramType = argTypes[index] as ZodClass;
if (paramType && 'parse' in paramType) {
if (paramType && 'safeParse' in paramType) {
const output = paramType.safeParse(req[arg.type]);
if (output.success) args.push(output.data);
else {

View File

@@ -1,4 +1,4 @@
import { RoleChangeRequestDto } from '@n8n/api-types';
import { InviteUsersRequestDto, RoleChangeRequestDto } from '@n8n/api-types';
import type express from 'express';
import type { Response } from 'express';
import { Container } from 'typedi';
@@ -18,7 +18,7 @@ import {
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
type Create = UserRequest.Invite;
type Create = AuthenticatedRequest<{}, {}, InviteUsersRequestDto>;
type Delete = UserRequest.Delete;
type ChangeRole = AuthenticatedRequest<{ id: string }, {}, RoleChangeRequestDto, {}>;
@@ -82,8 +82,16 @@ export = {
createUser: [
globalScope('user:create'),
async (req: Create, res: Response) => {
const usersInvited = await Container.get(InvitationController).inviteUser(req);
const { data, error } = InviteUsersRequestDto.safeParse(req.body);
if (error) {
return res.status(400).json(error.errors[0]);
}
const usersInvited = await Container.get(InvitationController).inviteUser(
req,
res,
data as InviteUsersRequestDto,
);
return res.status(201).json(usersInvited);
},
],

View File

@@ -199,12 +199,6 @@ export declare namespace MeRequest {
// ----------------------------------
export declare namespace UserRequest {
export type Invite = AuthenticatedRequest<
{},
{},
Array<{ email: string; role?: AssignableRole }>
>;
export type InviteResponse = {
user: {
id: string;
@@ -231,19 +225,6 @@ export declare namespace UserRequest {
>;
export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
export type Reinvite = AuthenticatedRequest<{ id: string }>;
export type Update = AuthlessRequest<
{ id: string },
{},
{
inviterId: string;
firstName: string;
lastName: string;
password: string;
}
>;
}
// ----------------------------------

View File

@@ -49,57 +49,4 @@ describe('PasswordUtility', () => {
expect(isMatch).toBe(false);
});
});
describe('validate()', () => {
test('should throw on empty password', () => {
const check = () => passwordUtility.validate();
expect(check).toThrowError('Password is mandatory');
});
test('should return same password if valid', () => {
const validPassword = 'abcd1234X';
const validated = passwordUtility.validate(validPassword);
expect(validated).toBe(validPassword);
});
test('should require at least one uppercase letter', () => {
const invalidPassword = 'abcd1234';
const failingCheck = () => passwordUtility.validate(invalidPassword);
expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.');
});
test('should require at least one number', () => {
const validPassword = 'abcd1234X';
const invalidPassword = 'abcdEFGH';
const validated = passwordUtility.validate(validPassword);
expect(validated).toBe(validPassword);
const check = () => passwordUtility.validate(invalidPassword);
expect(check).toThrowError('Password must contain at least 1 number.');
});
test('should require a minimum length of 8 characters', () => {
const invalidPassword = 'a'.repeat(7);
const check = () => passwordUtility.validate(invalidPassword);
expect(check).toThrowError('Password must be 8 to 64 characters long.');
});
test('should require a maximum length of 64 characters', () => {
const invalidPassword = 'a'.repeat(65);
const check = () => passwordUtility.validate(invalidPassword);
expect(check).toThrowError('Password must be 8 to 64 characters long.');
});
});
});

View File

@@ -1,12 +1,6 @@
import { compare, hash } from 'bcryptjs';
import { Service as Utility } from 'typedi';
import {
MAX_PASSWORD_CHAR_LENGTH as maxLength,
MIN_PASSWORD_CHAR_LENGTH as minLength,
} from '@/constants';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
const SALT_ROUNDS = 10;
@Utility()
@@ -18,29 +12,4 @@ export class PasswordUtility {
async compare(plaintext: string, hashed: string) {
return await compare(plaintext, hashed);
}
/** @deprecated. All input validation should move to DTOs */
validate(plaintext?: string) {
if (!plaintext) throw new BadRequestError('Password is mandatory');
const errorMessages: string[] = [];
if (plaintext.length < minLength || plaintext.length > maxLength) {
errorMessages.push(`Password must be ${minLength} to ${maxLength} characters long.`);
}
if (!/\d/.test(plaintext)) {
errorMessages.push('Password must contain at least 1 number.');
}
if (!/[A-Z]/.test(plaintext)) {
errorMessages.push('Password must contain at least 1 uppercase letter.');
}
if (errorMessages.length > 0) {
throw new BadRequestError(errorMessages.join(' '));
}
return plaintext;
}
}