From ad7eab8b1972a26fc2253f1d8f5f8d6310712db2 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 15 Mar 2023 14:45:22 +0100 Subject: [PATCH] feat(core): Normalize email addresses for native auth Fixes #1515 --- .../e2e/graphql/generated-e2e-admin-types.ts | 2 +- .../core/e2e/graphql/shared-definitions.ts | 1 + packages/core/e2e/shop-auth.e2e-spec.ts | 78 ++++++++++++++++++- .../service/services/administrator.service.ts | 3 +- .../core/src/service/services/user.service.ts | 13 ++-- 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/core/e2e/graphql/generated-e2e-admin-types.ts b/packages/core/e2e/graphql/generated-e2e-admin-types.ts index 5d5dbf1151..db383a7edf 100644 --- a/packages/core/e2e/graphql/generated-e2e-admin-types.ts +++ b/packages/core/e2e/graphql/generated-e2e-admin-types.ts @@ -8286,7 +8286,7 @@ export type GetCustomerListQuery = { lastName: string; emailAddress: string; phoneNumber?: string | null; - user?: { id: string; verified: boolean } | null; + user?: { id: string; identifier: string; verified: boolean } | null; }>; }; }; diff --git a/packages/core/e2e/graphql/shared-definitions.ts b/packages/core/e2e/graphql/shared-definitions.ts index a030d1a01a..80ef8bf69d 100644 --- a/packages/core/e2e/graphql/shared-definitions.ts +++ b/packages/core/e2e/graphql/shared-definitions.ts @@ -137,6 +137,7 @@ export const GET_CUSTOMER_LIST = gql` phoneNumber user { id + identifier verified } } diff --git a/packages/core/e2e/shop-auth.e2e-spec.ts b/packages/core/e2e/shop-auth.e2e-spec.ts index 059268a114..4742fb9b0d 100644 --- a/packages/core/e2e/shop-auth.e2e-spec.ts +++ b/packages/core/e2e/shop-auth.e2e-spec.ts @@ -1050,7 +1050,7 @@ describe('Expiring tokens', () => { }); describe('Registration without email verification', () => { - const { server, shopClient } = createTestEnvironment( + const { server, shopClient, adminClient } = createTestEnvironment( mergeConfig(testConfig(), { plugins: [TestEmailPlugin as any], authOptions: { @@ -1066,6 +1066,7 @@ describe('Registration without email verification', () => { productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'), customerCount: 1, }); + await adminClient.asSuperAdmin(); }, TEST_SETUP_TIMEOUT_MS); beforeEach(() => { @@ -1127,6 +1128,81 @@ describe('Registration without email verification', () => { ); expect(result.me.identifier).toBe(userEmailAddress); }); + + it('can login case insensitive', async () => { + await shopClient.asUserWithCredentials(userEmailAddress.toUpperCase(), 'test'); + + const result = await shopClient.query( + gql` + query GetMe { + me { + identifier + } + } + `, + ); + expect(result.me.identifier).toBe(userEmailAddress); + }); + + it('normalizes customer & user email addresses', async () => { + const input: RegisterCustomerInput = { + firstName: 'Bobbington', + lastName: 'Jarrolds', + emailAddress: 'BOBBINGTON.J@Test.com', + password: 'test', + }; + const { registerCustomerAccount } = await shopClient.query< + CodegenShop.RegisterMutation, + CodegenShop.RegisterMutationVariables + >(REGISTER_ACCOUNT, { + input, + }); + successErrorGuard.assertSuccess(registerCustomerAccount); + + const { customers } = await adminClient.query< + Codegen.GetCustomerListQuery, + Codegen.GetCustomerListQueryVariables + >(GET_CUSTOMER_LIST, { + options: { + filter: { + firstName: { eq: 'Bobbington' }, + }, + }, + }); + + expect(customers.items[0].emailAddress).toBe('bobbington.j@test.com'); + expect(customers.items[0].user?.identifier).toBe('bobbington.j@test.com'); + }); + + it('registering with same email address with different casing does not create new user', async () => { + const input: RegisterCustomerInput = { + firstName: 'Glen', + lastName: 'Beardsley', + emailAddress: userEmailAddress.toUpperCase(), + password: 'test', + }; + const { registerCustomerAccount } = await shopClient.query< + CodegenShop.RegisterMutation, + CodegenShop.RegisterMutationVariables + >(REGISTER_ACCOUNT, { + input, + }); + successErrorGuard.assertSuccess(registerCustomerAccount); + + const { customers } = await adminClient.query< + Codegen.GetCustomerListQuery, + Codegen.GetCustomerListQueryVariables + >(GET_CUSTOMER_LIST, { + options: { + filter: { + firstName: { eq: 'Glen' }, + }, + }, + }); + + expect(customers.items[0].emailAddress).toBe(userEmailAddress); + expect(customers.items[0].user?.identifier).toBe(userEmailAddress); + }); }); describe('Updating email address without email verification', () => { diff --git a/packages/core/src/service/services/administrator.service.ts b/packages/core/src/service/services/administrator.service.ts index db6781e1ad..b7849ccd62 100644 --- a/packages/core/src/service/services/administrator.service.ts +++ b/packages/core/src/service/services/administrator.service.ts @@ -10,7 +10,7 @@ import { In, IsNull } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; import { RelationPaths } from '../../api/index'; import { EntityNotFoundError, InternalServerError, UserInputError } from '../../common/error/errors'; -import { idsAreEqual } from '../../common/index'; +import { idsAreEqual, normalizeEmailAddress } from '../../common/index'; import { ListQueryOptions } from '../../common/types/common-types'; import { ConfigService } from '../../config'; import { TransactionalConnection } from '../../connection/transactional-connection'; @@ -127,6 +127,7 @@ export class AdministratorService { async create(ctx: RequestContext, input: CreateAdministratorInput): Promise { await this.checkActiveUserCanGrantRoles(ctx, input.roleIds); const administrator = new Administrator(input); + administrator.emailAddress = normalizeEmailAddress(input.emailAddress); administrator.user = await this.userService.createAdminUser(ctx, input.emailAddress, input.password); let createdAdministrator = await this.connection .getRepository(ctx, Administrator) diff --git a/packages/core/src/service/services/user.service.ts b/packages/core/src/service/services/user.service.ts index cd0de7ac97..96f90bb9b6 100644 --- a/packages/core/src/service/services/user.service.ts +++ b/packages/core/src/service/services/user.service.ts @@ -17,6 +17,7 @@ import { VerificationTokenExpiredError, VerificationTokenInvalidError, } from '../../common/error/generated-graphql-shop-errors'; +import { normalizeEmailAddress } from '../../common/index'; import { ConfigService } from '../../config/config.service'; import { TransactionalConnection } from '../../connection/transactional-connection'; import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity'; @@ -72,7 +73,9 @@ export class UserService { .leftJoinAndSelect('user.roles', 'roles') .leftJoinAndSelect('roles.channels', 'channels') .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethods') - .where('user.identifier = :identifier', { identifier: emailAddress }) + .where('LOWER(user.identifier) = :identifier', { + identifier: normalizeEmailAddress(emailAddress), + }) .andWhere('user.deletedAt IS NULL') .getOne() .then(result => result ?? undefined); @@ -88,7 +91,7 @@ export class UserService { password?: string, ): Promise { const user = new User(); - user.identifier = identifier; + user.identifier = normalizeEmailAddress(identifier); const customerRole = await this.roleService.getCustomerRole(ctx); user.roles = [customerRole]; const addNativeAuthResult = await this.addNativeAuthenticationMethod(ctx, user, identifier, password); @@ -138,7 +141,7 @@ export class UserService { } else { authenticationMethod.passwordHash = ''; } - authenticationMethod.identifier = identifier; + authenticationMethod.identifier = normalizeEmailAddress(identifier); authenticationMethod.user = user; await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(authenticationMethod); user.authenticationMethods = [...(user.authenticationMethods ?? []), authenticationMethod]; @@ -151,14 +154,14 @@ export class UserService { */ async createAdminUser(ctx: RequestContext, identifier: string, password: string): Promise { const user = new User({ - identifier, + identifier: normalizeEmailAddress(identifier), verified: true, }); const authenticationMethod = await this.connection .getRepository(ctx, NativeAuthenticationMethod) .save( new NativeAuthenticationMethod({ - identifier, + identifier: normalizeEmailAddress(identifier), passwordHash: await this.passwordCipher.hash(password), }), );