fix(core): Make email for UM case insensitive (#3078)

* 🚧 lowercasing email

*  add tests for case insensitive email

* 🐘 add migration to lowercase email

* 🚚 rename migration

* 🐛 fix package.lock

* 🐛 fix double import

* 📋 add todo
This commit is contained in:
Ben Hesseldieck 2022-04-15 08:11:35 +02:00 committed by GitHub
parent d3fecb9f6d
commit 8532b0030d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 197 additions and 74 deletions

View file

@ -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}`,

View file

@ -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' });

View file

@ -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();
}

View file

@ -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<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(`
UPDATE ${tablePrefix}user
SET email = LOWER(email);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -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,
];

View file

@ -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<void> {
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<void> {}
}

View file

@ -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,
];

View file

@ -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<void> {
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<void> {}
}

View file

@ -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 };

View file

@ -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),
};
/**

View file

@ -1,4 +1,3 @@
import { hashSync, genSaltSync } from 'bcryptjs';
import express from 'express';
import validator from 'validator';
import { v4 as uuid } from 'uuid';
@ -60,10 +59,18 @@ afterAll(async () => {
test('POST /login should log user in', async () => {
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent.post('/login').send({
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);
@ -84,7 +91,6 @@ test('POST /login should log user in', async () => {
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');
@ -92,6 +98,8 @@ test('POST /login should log user in', async () => {
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
}),
);
});
test('GET /login should receive logged in user', async () => {

View file

@ -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(),

View file

@ -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 });

View file

@ -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({});
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 () => {

View file

@ -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,12 +497,17 @@ 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;
for (const {
user: { id, email: receivedEmail },
error,
} of response.body.data) {
expect(validator.isUUID(id)).toBe(true);
expect(testEmails.some((e) => e === receivedEmail)).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');
}
@ -514,8 +521,7 @@ test('POST /users should email invites and create user shells', async () => {
expect(personalizationAnswers).toBeNull();
expect(password).toBeNull();
expect(resetPasswordToken).toBeNull();
}),
);
}
});
test('POST /users should fail with invalid inputs', async () => {