diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index cda715a7c7ac7..5fa51319b6580 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -12,6 +12,7 @@ import { } from './Interfaces'; import { NodeMailer } from './NodeMailer'; +// TODO: make function fully async (remove sync functions) async function getTemplate(configKeyName: string, defaultFilename: string) { const templateOverride = (await GenericHelpers.getConfigValue( `userManagement.emails.templates.${configKeyName}`, diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts index 3b196970369bd..d6f1caac7da13 100644 --- a/packages/cli/src/UserManagement/routes/users.ts +++ b/packages/cli/src/UserManagement/routes/users.ts @@ -104,7 +104,7 @@ export function usersNamespace(this: N8nApp): void { 400, ); } - createUsers[invite.email] = null; + createUsers[invite.email.toLowerCase()] = null; }); const role = await Db.collections.Role.findOne({ scope: 'global', name: 'member' }); diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index ee539c4f65310..12f4ec64c1f4e 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -12,6 +12,7 @@ import { ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, + BeforeInsert, } from 'typeorm'; import { IsEmail, IsString, Length } from 'class-validator'; import * as config from '../../../config'; @@ -20,7 +21,7 @@ import { Role } from './Role'; import { SharedWorkflow } from './SharedWorkflow'; import { SharedCredentials } from './SharedCredentials'; import { NoXss } from '../utils/customValidators'; -import { answersFormatter } from '../utils/transformers'; +import { answersFormatter, lowerCaser } from '../utils/transformers'; export const MIN_PASSWORD_LENGTH = 8; @@ -62,7 +63,11 @@ export class User { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ length: 254, nullable: true }) + @Column({ + length: 254, + nullable: true, + transformer: lowerCaser, + }) @Index({ unique: true }) @IsEmail() email: string; @@ -119,8 +124,10 @@ export class User { }) updatedAt: Date; + @BeforeInsert() @BeforeUpdate() - setUpdateDate(): void { + preUpsertHook(): void { + this.email = this.email?.toLowerCase(); this.updatedAt = new Date(); } diff --git a/packages/cli/src/databases/mysqldb/migrations/1648740597343-LowerCaseUserEmail.ts b/packages/cli/src/databases/mysqldb/migrations/1648740597343-LowerCaseUserEmail.ts new file mode 100644 index 0000000000000..a627ed830ae6a --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1648740597343-LowerCaseUserEmail.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); + +export class LowerCaseUserEmail1648740597343 implements MigrationInterface { + name = 'LowerCaseUserEmail1648740597343'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(` + UPDATE ${tablePrefix}user + SET email = LOWER(email); + `); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index 16ee37da4666e..6c859556d0ce1 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -12,6 +12,7 @@ import { AddWaitColumnId1626183952959 } from './1626183952959-AddWaitColumn'; import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes'; import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; +import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -28,4 +29,5 @@ export const mysqlMigrations = [ UpdateWorkflowCredentials1630451444017, AddExecutionEntityIndexes1644424784709, CreateUserManagement1646992772331, + LowerCaseUserEmail1648740597343, ]; diff --git a/packages/cli/src/databases/postgresdb/migrations/1648740597343-LowerCaseUserEmail.ts b/packages/cli/src/databases/postgresdb/migrations/1648740597343-LowerCaseUserEmail.ts new file mode 100644 index 0000000000000..02a12d047f02e --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1648740597343-LowerCaseUserEmail.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); + +export class LowerCaseUserEmail1648740597343 implements MigrationInterface { + name = 'LowerCaseUserEmail1648740597343'; + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query(` + UPDATE ${tablePrefix}user + SET email = LOWER(email); + `); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index 903b6a7e723b9..3c1fb23d4f63c 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -10,6 +10,7 @@ import { UpdateWorkflowCredentials1630419189837 } from './1630419189837-UpdateWo import { AddExecutionEntityIndexes1644422880309 } from './1644422880309-AddExecutionEntityIndexes'; import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit'; import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; +import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -24,4 +25,5 @@ export const postgresMigrations = [ AddExecutionEntityIndexes1644422880309, IncreaseTypeVarcharLimit1646834195327, CreateUserManagement1646992772331, + LowerCaseUserEmail1648740597343, ]; diff --git a/packages/cli/src/databases/sqlite/migrations/1648740597343-LowerCaseUserEmail.ts b/packages/cli/src/databases/sqlite/migrations/1648740597343-LowerCaseUserEmail.ts new file mode 100644 index 0000000000000..2eb789875081b --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1648740597343-LowerCaseUserEmail.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; + +export class LowerCaseUserEmail1648740597343 implements MigrationInterface { + name = 'LowerCaseUserEmail1648740597343'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query(` + UPDATE "${tablePrefix}user" + SET email = LOWER(email); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 2f938fe0d15bd..347c403aea03c 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -11,6 +11,7 @@ import { AddWaitColumn1621707690587 } from './1621707690587-AddWaitColumn'; import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes'; import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; +import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -24,6 +25,7 @@ const sqliteMigrations = [ UpdateWorkflowCredentials1630330987096, AddExecutionEntityIndexes1644421939510, CreateUserManagement1646992772331, + LowerCaseUserEmail1648740597343, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/utils/transformers.ts b/packages/cli/src/databases/utils/transformers.ts index f843c1c2f795e..c2a9d3e0d20ec 100644 --- a/packages/cli/src/databases/utils/transformers.ts +++ b/packages/cli/src/databases/utils/transformers.ts @@ -2,8 +2,13 @@ import { IPersonalizationSurveyAnswers } from '../../Interfaces'; export const idStringifier = { - from: (value: number): string | number => (value ? value.toString() : value), - to: (value: string): number | string => (value ? Number(value) : value), + from: (value: number): string | number => (typeof value === 'number' ? value.toString() : value), + to: (value: string): number | string => (typeof value === 'string' ? Number(value) : value), +}; + +export const lowerCaser = { + from: (value: string): string => value, + to: (value: string): string => (typeof value === 'string' ? value.toLowerCase() : value), }; /** diff --git a/packages/cli/test/integration/auth.endpoints.test.ts b/packages/cli/test/integration/auth.endpoints.test.ts index fd24d49c0e7c1..0bbb5b7e582ed 100644 --- a/packages/cli/test/integration/auth.endpoints.test.ts +++ b/packages/cli/test/integration/auth.endpoints.test.ts @@ -1,4 +1,3 @@ -import { hashSync, genSaltSync } from 'bcryptjs'; import express from 'express'; import validator from 'validator'; import { v4 as uuid } from 'uuid'; @@ -60,38 +59,47 @@ afterAll(async () => { test('POST /login should log user in', async () => { const authlessAgent = utils.createAgent(app); - const response = await authlessAgent.post('/login').send({ - email: TEST_USER.email, - password: TEST_USER.password, - }); - - expect(response.statusCode).toBe(200); - - const { - id, - email, - firstName, - lastName, - password, - personalizationAnswers, - globalRole, - resetPasswordToken, - } = response.body.data; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(TEST_USER.email); - expect(firstName).toBe(TEST_USER.firstName); - expect(lastName).toBe(TEST_USER.lastName); - expect(password).toBeUndefined(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeDefined(); + await Promise.all( + [ + { + email: TEST_USER.email, + password: TEST_USER.password, + }, + { + email: TEST_USER.email.toUpperCase(), + password: TEST_USER.password, + }, + ].map(async (payload) => { + const response = await authlessAgent.post('/login').send(payload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + expect(lastName).toBe(TEST_USER.lastName); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + }), + ); }); test('GET /login should receive logged in user', async () => { diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 85c435a5b8030..186f09ddea2f6 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -91,7 +91,7 @@ describe('Owner shell', () => { } = response.body.data; expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(validPayload.email); + expect(email).toBe(validPayload.email.toLowerCase()); expect(firstName).toBe(validPayload.firstName); expect(lastName).toBe(validPayload.lastName); expect(personalizationAnswers).toBeNull(); @@ -103,7 +103,7 @@ describe('Owner shell', () => { const storedOwnerShell = await Db.collections.User!.findOneOrFail(id); - expect(storedOwnerShell.email).toBe(validPayload.email); + expect(storedOwnerShell.email).toBe(validPayload.email.toLowerCase()); expect(storedOwnerShell.firstName).toBe(validPayload.firstName); expect(storedOwnerShell.lastName).toBe(validPayload.lastName); } @@ -245,7 +245,7 @@ describe('Member', () => { } = response.body.data; expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(validPayload.email); + expect(email).toBe(validPayload.email.toLowerCase()); expect(firstName).toBe(validPayload.firstName); expect(lastName).toBe(validPayload.lastName); expect(personalizationAnswers).toBeNull(); @@ -257,7 +257,7 @@ describe('Member', () => { const storedMember = await Db.collections.User!.findOneOrFail(id); - expect(storedMember.email).toBe(validPayload.email); + expect(storedMember.email).toBe(validPayload.email.toLowerCase()); expect(storedMember.firstName).toBe(validPayload.firstName); expect(storedMember.lastName).toBe(validPayload.lastName); } @@ -400,7 +400,7 @@ describe('Owner', () => { } = response.body.data; expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(validPayload.email); + expect(email).toBe(validPayload.email.toLowerCase()); expect(firstName).toBe(validPayload.firstName); expect(lastName).toBe(validPayload.lastName); expect(personalizationAnswers).toBeNull(); @@ -412,19 +412,13 @@ describe('Owner', () => { const storedOwner = await Db.collections.User!.findOneOrFail(id); - expect(storedOwner.email).toBe(validPayload.email); + expect(storedOwner.email).toBe(validPayload.email.toLowerCase()); expect(storedOwner.firstName).toBe(validPayload.firstName); expect(storedOwner.lastName).toBe(validPayload.lastName); } }); }); -const TEST_USER = { - email: randomEmail(), - firstName: randomName(), - lastName: randomName(), -}; - const SURVEY = [ 'codingSkill', 'companyIndustry', @@ -444,7 +438,7 @@ const VALID_PATCH_ME_PAYLOADS = [ password: randomValidPassword(), }, { - email: randomEmail(), + email: randomEmail().toUpperCase(), firstName: randomName(), lastName: randomName(), password: randomValidPassword(), diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index 3f97f298b38ca..e479196999b75 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -30,6 +30,12 @@ beforeAll(async () => { }); beforeEach(async () => { + jest.mock('../../config'); + + config.set('userManagement.isInstanceOwnerSetUp', false); +}); + +afterEach(async () => { await testDb.truncate(['User'], testDbName); }); @@ -88,6 +94,29 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () expect(isInstanceOwnerSetUpSetting).toBe(true); }); +test('POST /owner should create owner with lowercased email', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const newOwnerData = { + email: randomEmail().toUpperCase(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }; + + const response = await authOwnerAgent.post('/owner').send(newOwnerData); + + expect(response.statusCode).toBe(200); + + const { id, email } = response.body.data; + + expect(email).toBe(newOwnerData.email.toLowerCase()); + + const storedOwner = await Db.collections.User!.findOneOrFail(id); + expect(storedOwner.email).toBe(newOwnerData.email.toLowerCase()); +}); + test('POST /owner should fail with invalid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index 8b41428862250..ef1ffdbf2f390 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -19,6 +19,7 @@ jest.mock('../../src/telemetry'); let app: express.Application; let testDbName = ''; let globalOwnerRole: Role; +let globalMemberRole: Role; beforeAll(async () => { app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); @@ -26,6 +27,7 @@ beforeAll(async () => { testDbName = initResult.testDbName; globalOwnerRole = await testDb.getGlobalOwnerRole(); + globalMemberRole = await testDb.getGlobalMemberRole(); utils.initTestTelemetry(); utils.initTestLogger(); @@ -50,17 +52,22 @@ test('POST /forgot-password should send password reset email', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const authlessAgent = utils.createAgent(app); + const member = await testDb.createUser({ email: 'test@test.com', globalRole: globalMemberRole }); await utils.configureSmtp(); - const response = await authlessAgent.post('/forgot-password').send({ email: owner.email }); + await Promise.all( + [{ email: owner.email }, { email: member.email.toUpperCase() }].map(async (payload) => { + const response = await authlessAgent.post('/forgot-password').send(payload); - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({}); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({}); - const storedOwner = await Db.collections.User!.findOneOrFail({ email: owner.email }); - expect(storedOwner.resetPasswordToken).toBeDefined(); - expect(storedOwner.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000)); + const user = await Db.collections.User!.findOneOrFail({ email: payload.email }); + expect(user.resetPasswordToken).toBeDefined(); + expect(user.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000)); + }), + ); }); test('POST /forgot-password should fail if emailing is not set up', async () => { diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index eaee6d4db8ffb..f139cd4351f09 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -481,13 +481,15 @@ test('POST /users should fail if user management is disabled', async () => { expect(response.statusCode).toBe(500); }); -test('POST /users should email invites and create user shells', async () => { +test('POST /users should email invites and create user shells but ignore existing', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const memberShell = await testDb.createUserShell(globalMemberRole); const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); await utils.configureSmtp(); - const testEmails = [randomEmail(), randomEmail(), randomEmail()]; + const testEmails = [randomEmail(), randomEmail().toUpperCase(), memberShell.email, member.email]; const payload = testEmails.map((e) => ({ email: e })); @@ -495,27 +497,31 @@ test('POST /users should email invites and create user shells', async () => { expect(response.statusCode).toBe(200); - await Promise.all( - response.body.data.map(async ({ user, error }: { user: User; error: Error }) => { - const { id, email: receivedEmail } = user; - - expect(validator.isUUID(id)).toBe(true); - expect(testEmails.some((e) => e === receivedEmail)).toBe(true); - if (error) { - expect(error).toBe('Email could not be sent'); - } - - const storedUser = await Db.collections.User!.findOneOrFail(id); - const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = - storedUser; - - expect(firstName).toBeNull(); - expect(lastName).toBeNull(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeNull(); - expect(resetPasswordToken).toBeNull(); - }), - ); + for (const { + user: { id, email: receivedEmail }, + error, + } of response.body.data) { + expect(validator.isUUID(id)).toBe(true); + expect(id).not.toBe(member.id); + + const lowerCasedEmail = receivedEmail.toLowerCase(); + expect(receivedEmail).toBe(lowerCasedEmail); + expect(payload.some(({ email }) => email.toLowerCase() === lowerCasedEmail)).toBe(true); + + if (error) { + expect(error).toBe('Email could not be sent'); + } + + const storedUser = await Db.collections.User!.findOneOrFail(id); + const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = + storedUser; + + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeNull(); + expect(resetPasswordToken).toBeNull(); + } }); test('POST /users should fail with invalid inputs', async () => {