fix(core): Do not return inviteAcceptUrl in response if email was sent (#7465)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-10-19 13:58:06 +02:00 committed by GitHub
parent ab6a9bbac2
commit 55c6a1b0d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 74 additions and 102 deletions

View file

@ -310,7 +310,6 @@ export class Server extends AbstractServer {
new MeController(logger, externalHooks, internalHooks, userService), new MeController(logger, externalHooks, internalHooks, userService),
new NodeTypesController(config, nodeTypes), new NodeTypesController(config, nodeTypes),
new PasswordResetController( new PasswordResetController(
config,
logger, logger,
externalHooks, externalHooks,
internalHooks, internalHooks,

View file

@ -12,13 +12,6 @@ import { License } from '@/License';
import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { getWebhookBaseUrl } from '@/WebhookHelpers';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
export function isEmailSetUp(): boolean {
const smtp = config.getEnv('userManagement.emails.mode') === 'smtp';
const host = !!config.getEnv('userManagement.emails.smtp.host');
return smtp && host;
}
export function isSharingEnabled(): boolean { export function isSharingEnabled(): boolean {
return Container.get(License).isSharingEnabled(); return Container.get(License).isSharingEnabled();
} }

View file

@ -34,14 +34,17 @@ async function getTemplate(
@Service() @Service()
export class UserManagementMailer { export class UserManagementMailer {
readonly isEmailSetUp: boolean;
private mailer: NodeMailer | undefined; private mailer: NodeMailer | undefined;
constructor() { constructor() {
// Other implementations can be used in the future. this.isEmailSetUp =
if (
config.getEnv('userManagement.emails.mode') === 'smtp' && config.getEnv('userManagement.emails.mode') === 'smtp' &&
config.getEnv('userManagement.emails.smtp.host') !== '' config.getEnv('userManagement.emails.smtp.host') !== '';
) {
// Other implementations can be used in the future.
if (this.isEmailSetUp) {
this.mailer = new NodeMailer(); this.mailer = new NodeMailer();
} }
} }

View file

@ -17,7 +17,6 @@ import { UserManagementMailer } from '@/UserManagement/email';
import { Response } from 'express'; import { Response } from 'express';
import { ILogger } from 'n8n-workflow'; import { ILogger } from 'n8n-workflow';
import { Config } from '@/config';
import { PasswordResetRequest } from '@/requests'; import { PasswordResetRequest } from '@/requests';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
@ -35,7 +34,6 @@ import { MfaService } from '@/Mfa/mfa.service';
@RestController() @RestController()
export class PasswordResetController { export class PasswordResetController {
constructor( constructor(
private readonly config: Config,
private readonly logger: ILogger, private readonly logger: ILogger,
private readonly externalHooks: IExternalHooksClass, private readonly externalHooks: IExternalHooksClass,
private readonly internalHooks: IInternalHooksClass, private readonly internalHooks: IInternalHooksClass,
@ -50,7 +48,7 @@ export class PasswordResetController {
*/ */
@Post('/forgot-password') @Post('/forgot-password')
async forgotPassword(req: PasswordResetRequest.Email) { async forgotPassword(req: PasswordResetRequest.Email) {
if (this.config.getEnv('userManagement.emails.mode') === '') { if (!this.mailer.isEmailSetUp) {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because emailing was not set up', 'Request to send password reset email failed because emailing was not set up',
); );

View file

@ -10,7 +10,6 @@ import {
generateUserInviteUrl, generateUserInviteUrl,
getInstanceBaseUrl, getInstanceBaseUrl,
hashPassword, hashPassword,
isEmailSetUp,
validatePassword, validatePassword,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
@ -184,7 +183,7 @@ export class UsersController {
} }
const inviteAcceptUrl = generateUserInviteUrl(req.user.id, id); const inviteAcceptUrl = generateUserInviteUrl(req.user.id, id);
const resp: { const resp: {
user: { id: string | null; email: string; inviteAcceptUrl: string; emailSent: boolean }; user: { id: string | null; email: string; inviteAcceptUrl?: string; emailSent: boolean };
error?: string; error?: string;
} = { } = {
user: { user: {
@ -202,6 +201,7 @@ export class UsersController {
}); });
if (result.emailSent) { if (result.emailSent) {
resp.user.emailSent = true; resp.user.emailSent = true;
delete resp.user.inviteAcceptUrl;
void this.internalHooks.onUserTransactionalEmail({ void this.internalHooks.onUserTransactionalEmail({
user_id: id, user_id: id,
message_type: 'New user invite', message_type: 'New user invite',
@ -619,7 +619,7 @@ export class UsersController {
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
} }
if (!isEmailSetUp()) { if (!this.mailer.isEmailSetUp) {
this.logger.error('Request to reinvite a user failed because email sending was not set up'); this.logger.error('Request to reinvite a user failed because email sending was not set up');
throw new InternalServerError('Email sending must be set up in order to invite other users'); throw new InternalServerError('Email sending must be set up in order to invite other users');
} }

View file

@ -16,7 +16,7 @@ import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { CredentialTypes } from '@/CredentialTypes'; import { CredentialTypes } from '@/CredentialTypes';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { License } from '@/License'; import { License } from '@/License';
import { getInstanceBaseUrl, isEmailSetUp } from '@/UserManagement/UserManagementHelper'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import * as WebhookHelpers from '@/WebhookHelpers'; import * as WebhookHelpers from '@/WebhookHelpers';
import { LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';
import config from '@/config'; import config from '@/config';
@ -28,6 +28,7 @@ import {
getWorkflowHistoryLicensePruneTime, getWorkflowHistoryLicensePruneTime,
getWorkflowHistoryPruneTime, getWorkflowHistoryPruneTime,
} from '@/workflows/workflowHistory/workflowHistoryHelper.ee'; } from '@/workflows/workflowHistory/workflowHistoryHelper.ee';
import { UserManagementMailer } from '@/UserManagement/email';
@Service() @Service()
export class FrontendService { export class FrontendService {
@ -38,6 +39,7 @@ export class FrontendService {
private readonly credentialTypes: CredentialTypes, private readonly credentialTypes: CredentialTypes,
private readonly credentialsOverwrites: CredentialsOverwrites, private readonly credentialsOverwrites: CredentialsOverwrites,
private readonly license: License, private readonly license: License,
private readonly mailer: UserManagementMailer,
) { ) {
this.initSettings(); this.initSettings();
} }
@ -103,7 +105,7 @@ export class FrontendService {
userManagement: { userManagement: {
quota: this.license.getUsersLimit(), quota: this.license.getUsersLimit(),
showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'), showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'),
smtpSetup: isEmailSetUp(), smtpSetup: this.mailer.isEmailSetUp,
authenticationMethod: getCurrentAuthenticationMethod(), authenticationMethod: getCurrentAuthenticationMethod(),
}, },
sso: { sso: {

View file

@ -1,7 +1,9 @@
import type { SuperAgentTest } from 'supertest'; import type { SuperAgentTest } from 'supertest';
import type { Entry as LdapUser } from 'ldapts'; import type { Entry as LdapUser } from 'ldapts';
import { Not } from 'typeorm'; import { Not } from 'typeorm';
import { jsonParse } from 'n8n-workflow'; import { type ILogger, jsonParse, LoggerProxy } from 'n8n-workflow';
import { mock } from 'jest-mock-extended';
import config from '@/config'; import config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
@ -17,17 +19,13 @@ import { randomEmail, randomName, uniqueId } from './../shared/random';
import * as testDb from './../shared/testDb'; import * as testDb from './../shared/testDb';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
import { LoggerProxy } from 'n8n-workflow';
import { getLogger } from '@/Logger';
jest.mock('@/telemetry'); jest.mock('@/telemetry');
jest.mock('@/UserManagement/email/NodeMailer');
let globalMemberRole: Role; let globalMemberRole: Role;
let owner: User; let owner: User;
let authOwnerAgent: SuperAgentTest; let authOwnerAgent: SuperAgentTest;
LoggerProxy.init(getLogger()); LoggerProxy.init(mock<ILogger>());
const defaultLdapConfig = { const defaultLdapConfig = {
...LDAP_DEFAULT_CONFIGURATION, ...LDAP_DEFAULT_CONFIGURATION,
@ -80,7 +78,6 @@ beforeEach(async () => {
jest.mock('@/telemetry'); jest.mock('@/telemetry');
config.set('userManagement.isInstanceOwnerSetUp', true); config.set('userManagement.isInstanceOwnerSetUp', true);
config.set('userManagement.emails.mode', '');
}); });
const createLdapConfig = async (attributes: Partial<LdapConfig> = {}): Promise<LdapConfig> => { const createLdapConfig = async (attributes: Partial<LdapConfig> = {}): Promise<LdapConfig> => {

View file

@ -1,11 +1,17 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { compare } from 'bcryptjs'; import { compare } from 'bcryptjs';
import { Container } from 'typedi';
import { License } from '@/License'; import { License } from '@/License';
import * as Db from '@/Db'; import * as Db from '@/Db';
import config from '@/config'; import config from '@/config';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { ExternalHooks } from '@/ExternalHooks';
import { JwtService } from '@/services/jwt.service';
import { UserManagementMailer } from '@/UserManagement/email';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
import { import {
randomEmail, randomEmail,
@ -15,12 +21,7 @@ import {
randomValidPassword, randomValidPassword,
} from './shared/random'; } from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { ExternalHooks } from '@/ExternalHooks';
import { JwtService } from '@/services/jwt.service';
import { Container } from 'typedi';
jest.mock('@/UserManagement/email/NodeMailer');
config.set('userManagement.jwtSecret', randomString(5, 10)); config.set('userManagement.jwtSecret', randomString(5, 10));
let globalOwnerRole: Role; let globalOwnerRole: Role;
@ -29,6 +30,7 @@ let owner: User;
let member: User; let member: User;
const externalHooks = utils.mockInstance(ExternalHooks); const externalHooks = utils.mockInstance(ExternalHooks);
const mailer = utils.mockInstance(UserManagementMailer, { isEmailSetUp: true });
const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] }); const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] });
const jwtService = Container.get(JwtService); const jwtService = Container.get(JwtService);
@ -43,6 +45,7 @@ beforeEach(async () => {
owner = await testDb.createUser({ globalRole: globalOwnerRole }); owner = await testDb.createUser({ globalRole: globalOwnerRole });
member = await testDb.createUser({ globalRole: globalMemberRole }); member = await testDb.createUser({ globalRole: globalMemberRole });
externalHooks.run.mockReset(); externalHooks.run.mockReset();
jest.replaceProperty(mailer, 'isEmailSetUp', true);
}); });
describe('POST /forgot-password', () => { describe('POST /forgot-password', () => {
@ -52,8 +55,6 @@ describe('POST /forgot-password', () => {
globalRole: globalMemberRole, globalRole: globalMemberRole,
}); });
config.set('userManagement.emails.mode', 'smtp');
await Promise.all( await Promise.all(
[{ email: owner.email }, { email: member.email.toUpperCase() }].map(async (payload) => { [{ email: owner.email }, { email: member.email.toUpperCase() }].map(async (payload) => {
const response = await testServer.authlessAgent.post('/forgot-password').send(payload); const response = await testServer.authlessAgent.post('/forgot-password').send(payload);
@ -65,7 +66,7 @@ describe('POST /forgot-password', () => {
}); });
test('should fail if emailing is not set up', async () => { test('should fail if emailing is not set up', async () => {
config.set('userManagement.emails.mode', ''); jest.replaceProperty(mailer, 'isEmailSetUp', false);
await testServer.authlessAgent await testServer.authlessAgent
.post('/forgot-password') .post('/forgot-password')
@ -75,7 +76,6 @@ describe('POST /forgot-password', () => {
test('should fail if SAML is authentication method', async () => { test('should fail if SAML is authentication method', async () => {
await setCurrentAuthenticationMethod('saml'); await setCurrentAuthenticationMethod('saml');
config.set('userManagement.emails.mode', 'smtp');
const member = await testDb.createUser({ const member = await testDb.createUser({
email: 'test@test.com', email: 'test@test.com',
globalRole: globalMemberRole, globalRole: globalMemberRole,
@ -91,7 +91,6 @@ describe('POST /forgot-password', () => {
test('should succeed if SAML is authentication method and requestor is owner', async () => { test('should succeed if SAML is authentication method and requestor is owner', async () => {
await setCurrentAuthenticationMethod('saml'); await setCurrentAuthenticationMethod('saml');
config.set('userManagement.emails.mode', 'smtp');
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/forgot-password') .post('/forgot-password')
@ -104,8 +103,6 @@ describe('POST /forgot-password', () => {
}); });
test('should fail with invalid inputs', async () => { test('should fail with invalid inputs', async () => {
config.set('userManagement.emails.mode', 'smtp');
const invalidPayloads = [ const invalidPayloads = [
randomEmail(), randomEmail(),
[randomEmail()], [randomEmail()],
@ -121,8 +118,6 @@ describe('POST /forgot-password', () => {
}); });
test('should fail if user is not found', async () => { test('should fail if user is not found', async () => {
config.set('userManagement.emails.mode', 'smtp');
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/forgot-password') .post('/forgot-password')
.send({ email: randomEmail() }); .send({ email: randomEmail() });
@ -132,10 +127,6 @@ describe('POST /forgot-password', () => {
}); });
describe('GET /resolve-password-token', () => { describe('GET /resolve-password-token', () => {
beforeEach(() => {
config.set('userManagement.emails.mode', 'smtp');
});
test('should succeed with valid inputs', async () => { test('should succeed with valid inputs', async () => {
const resetPasswordToken = jwtService.signData({ sub: owner.id }); const resetPasswordToken = jwtService.signData({ sub: owner.id });

View file

@ -256,7 +256,6 @@ export const setupTestServer = ({
app, app,
config, config,
new PasswordResetController( new PasswordResetController(
config,
logger, logger,
externalHooks, externalHooks,
internalHooks, internalHooks,

View file

@ -2,7 +2,6 @@ import validator from 'validator';
import { Not } from 'typeorm'; import { Not } from 'typeorm';
import type { SuperAgentTest } from 'supertest'; import type { SuperAgentTest } from 'supertest';
import config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
@ -10,7 +9,6 @@ import type { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { compareHash } from '@/UserManagement/UserManagementHelper'; import { compareHash } from '@/UserManagement/UserManagementHelper';
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer'; import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
import { NodeMailer } from '@/UserManagement/email/NodeMailer';
import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { import {
@ -23,14 +21,14 @@ import {
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
jest.mock('@/UserManagement/email/NodeMailer');
let globalMemberRole: Role; let globalMemberRole: Role;
let workflowOwnerRole: Role; let workflowOwnerRole: Role;
let credentialOwnerRole: Role; let credentialOwnerRole: Role;
let owner: User; let owner: User;
let authOwnerAgent: SuperAgentTest; let authOwnerAgent: SuperAgentTest;
const mailer = utils.mockInstance(UserManagementMailer, { isEmailSetUp: true });
const testServer = utils.setupTestServer({ endpointGroups: ['users'] }); const testServer = utils.setupTestServer({ endpointGroups: ['users'] });
beforeAll(async () => { beforeAll(async () => {
@ -54,10 +52,7 @@ beforeEach(async () => {
await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials']); await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials']);
await Db.collections.User.delete({ id: Not(owner.id) }); await Db.collections.User.delete({ id: Not(owner.id) });
jest.mock('@/config'); mailer.invite.mockResolvedValue({ emailSent: true });
config.set('userManagement.emails.mode', 'smtp');
config.set('userManagement.emails.smtp.host', '');
}); });
describe('DELETE /users/:id', () => { describe('DELETE /users/:id', () => {
@ -313,11 +308,8 @@ describe('POST /users/:id', () => {
}); });
describe('POST /users', () => { describe('POST /users', () => {
beforeEach(() => {
config.set('userManagement.emails.mode', 'smtp');
});
test('should succeed if emailing is not set up', async () => { test('should succeed if emailing is not set up', async () => {
mailer.invite.mockResolvedValueOnce({ emailSent: false });
const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]);
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
@ -342,11 +334,12 @@ describe('POST /users', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
for (const { for (const {
user: { id, email: receivedEmail }, user: { id, email: receivedEmail, inviteAcceptUrl },
error, error,
} of response.body.data) { } of response.body.data) {
expect(validator.isUUID(id)).toBe(true); expect(validator.isUUID(id)).toBe(true);
expect(id).not.toBe(member.id); expect(id).not.toBe(member.id);
expect(inviteAcceptUrl).toBeUndefined();
const lowerCasedEmail = receivedEmail.toLowerCase(); const lowerCasedEmail = receivedEmail.toLowerCase();
expect(receivedEmail).toBe(lowerCasedEmail); expect(receivedEmail).toBe(lowerCasedEmail);
@ -401,14 +394,6 @@ describe('POST /users', () => {
}); });
describe('POST /users/:id/reinvite', () => { describe('POST /users/:id/reinvite', () => {
beforeEach(() => {
config.set('userManagement.emails.mode', 'smtp');
// those configs are needed to make sure the reinvite email is sent,because of this check isEmailSetUp()
config.set('userManagement.emails.smtp.host', 'host');
config.set('userManagement.emails.smtp.auth.user', 'user');
config.set('userManagement.emails.smtp.auth.pass', 'pass');
});
test('should send reinvite, but fail if user already accepted invite', async () => { test('should send reinvite, but fail if user already accepted invite', async () => {
const email = randomEmail(); const email = randomEmail();
const payload = [{ email }]; const payload = [{ email }];
@ -428,38 +413,3 @@ describe('POST /users/:id/reinvite', () => {
expect(reinviteMemberResponse.statusCode).toBe(400); expect(reinviteMemberResponse.statusCode).toBe(400);
}); });
}); });
describe('UserManagementMailer expect NodeMailer.verifyConnection', () => {
let mockInit: jest.SpyInstance<Promise<void>, []>;
let mockVerifyConnection: jest.SpyInstance<Promise<void>, []>;
beforeAll(() => {
mockVerifyConnection = jest
.spyOn(NodeMailer.prototype, 'verifyConnection')
.mockImplementation(async () => {});
mockInit = jest.spyOn(NodeMailer.prototype, 'init').mockImplementation(async () => {});
});
afterAll(() => {
mockVerifyConnection.mockRestore();
mockInit.mockRestore();
});
test('not be called when SMTP not set up', async () => {
const userManagementMailer = new UserManagementMailer();
// NodeMailer.verifyConnection gets called only explicitly
await expect(async () => userManagementMailer.verifyConnection()).rejects.toThrow();
expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(0);
});
test('to be called when SMTP set up', async () => {
// host needs to be set, otherwise smtp is skipped
config.set('userManagement.emails.smtp.host', 'host');
config.set('userManagement.emails.mode', 'smtp');
const userManagementMailer = new UserManagementMailer();
// NodeMailer.verifyConnection gets called only explicitly
expect(async () => userManagementMailer.verifyConnection()).not.toThrow();
});
});

View file

@ -0,0 +1,40 @@
import config from '@/config';
import { NodeMailer } from '@/UserManagement/email/NodeMailer';
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
describe('UserManagementMailer', () => {
describe('expect NodeMailer.verifyConnection', () => {
let mockInit: jest.SpyInstance<Promise<void>, []>;
let mockVerifyConnection: jest.SpyInstance<Promise<void>, []>;
beforeAll(() => {
mockVerifyConnection = jest
.spyOn(NodeMailer.prototype, 'verifyConnection')
.mockImplementation(async () => {});
mockInit = jest.spyOn(NodeMailer.prototype, 'init').mockImplementation(async () => {});
});
afterAll(() => {
mockVerifyConnection.mockRestore();
mockInit.mockRestore();
});
test('not be called when SMTP not set up', async () => {
const userManagementMailer = new UserManagementMailer();
// NodeMailer.verifyConnection gets called only explicitly
await expect(async () => userManagementMailer.verifyConnection()).rejects.toThrow();
expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(0);
});
test('to be called when SMTP set up', async () => {
// host needs to be set, otherwise smtp is skipped
config.set('userManagement.emails.smtp.host', 'host');
config.set('userManagement.emails.mode', 'smtp');
const userManagementMailer = new UserManagementMailer();
// NodeMailer.verifyConnection gets called only explicitly
expect(async () => userManagementMailer.verifyConnection()).not.toThrow();
});
});
});