Преглед изворни кода

feat(core): More flexible customer registration flow

Relates to #392. This change allows a new Customer to register their password either at the time of registering, or when verifying the email address.
Michael Bromley пре 5 година
родитељ
комит
92350e697f

+ 1 - 1
packages/common/src/generated-shop-types.ts

@@ -1498,7 +1498,7 @@ export type MutationDeleteCustomerAddressArgs = {
 
 export type MutationVerifyCustomerAccountArgs = {
     token: Scalars['String'];
-    password: Scalars['String'];
+    password?: Maybe<Scalars['String']>;
 };
 
 export type MutationUpdateCustomerPasswordArgs = {

+ 2 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1498,7 +1498,7 @@ export type MutationDeleteCustomerAddressArgs = {
 
 export type MutationVerifyCustomerAccountArgs = {
     token: Scalars['String'];
-    password: Scalars['String'];
+    password?: Maybe<Scalars['String']>;
 };
 
 export type MutationUpdateCustomerPasswordArgs = {
@@ -2423,7 +2423,7 @@ export type RegisterMutationVariables = {
 export type RegisterMutation = { __typename?: 'Mutation' } & Pick<Mutation, 'registerCustomerAccount'>;
 
 export type VerifyMutationVariables = {
-    password: Scalars['String'];
+    password?: Maybe<Scalars['String']>;
     token: Scalars['String'];
 };
 

+ 1 - 1
packages/core/e2e/graphql/shop-definitions.ts

@@ -112,7 +112,7 @@ export const REGISTER_ACCOUNT = gql`
     }
 `;
 export const VERIFY_EMAIL = gql`
-    mutation Verify($password: String!, $token: String!) {
+    mutation Verify($password: String, $token: String!) {
         verifyCustomerAccount(password: $password, token: $token) {
             user {
                 id

+ 78 - 19
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -105,7 +105,7 @@ describe('Shop auth & accounts', () => {
         await server.destroy();
     });
 
-    describe('customer account creation', () => {
+    describe('customer account creation with deferred password', () => {
         const password = 'password';
         const emailAddress = 'test1@test.com';
         let verificationToken: string;
@@ -115,23 +115,7 @@ describe('Shop auth & accounts', () => {
             sendEmailFn = jest.fn();
         });
 
-        it(
-            'errors if a password is provided',
-            assertThrowsWithMessage(async () => {
-                const input: RegisterCustomerInput = {
-                    firstName: 'Sofia',
-                    lastName: 'Green',
-                    emailAddress: 'sofia.green@test.com',
-                    password: 'test',
-                };
-                const result = await shopClient.query<Register.Mutation, Register.Variables>(
-                    REGISTER_ACCOUNT,
-                    { input },
-                );
-            }, 'Do not provide a password when `authOptions.requireVerification` is set to "true"'),
-        );
-
-        it('register a new account', async () => {
+        it('register a new account without password', async () => {
             const verificationTokenPromise = getVerificationTokenPromise();
             const input: RegisterCustomerInput = {
                 firstName: 'Sean',
@@ -244,7 +228,18 @@ describe('Shop auth & accounts', () => {
             ),
         );
 
-        it('verification succeeds with correct token', async () => {
+        it(
+            'verification fails with no password',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query<Verify.Mutation, Verify.Variables>(VERIFY_EMAIL, {
+                        token: verificationToken,
+                    }),
+                `A password must be provided as it was not set during registration`,
+            ),
+        );
+
+        it('verification succeeds with password and correct token', async () => {
             const result = await shopClient.query<Verify.Mutation, Verify.Variables>(VERIFY_EMAIL, {
                 password,
                 token: verificationToken,
@@ -313,6 +308,70 @@ describe('Shop auth & accounts', () => {
         });
     });
 
+    describe('customer account creation with up-front password', () => {
+        const password = 'password';
+        const emailAddress = 'test2@test.com';
+        let verificationToken: string;
+
+        it('register a new account with password', async () => {
+            const verificationTokenPromise = getVerificationTokenPromise();
+            const input: RegisterCustomerInput = {
+                firstName: 'Lu',
+                lastName: 'Tester',
+                phoneNumber: '443324',
+                emailAddress,
+                password,
+            };
+            const result = await shopClient.query<Register.Mutation, Register.Variables>(REGISTER_ACCOUNT, {
+                input,
+            });
+
+            verificationToken = await verificationTokenPromise;
+
+            expect(result.registerCustomerAccount).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(verificationToken).toBeDefined();
+
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+                {
+                    options: {
+                        filter: {
+                            emailAddress: {
+                                eq: emailAddress,
+                            },
+                        },
+                    },
+                },
+            );
+
+            expect(
+                pick(customers.items[0], ['firstName', 'lastName', 'emailAddress', 'phoneNumber']),
+            ).toEqual(pick(input, ['firstName', 'lastName', 'emailAddress', 'phoneNumber']));
+        });
+        it(
+            'verification fails with password',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query<Verify.Mutation, Verify.Variables>(VERIFY_EMAIL, {
+                        token: verificationToken,
+                        password: 'new password',
+                    }),
+                `A password has already been set during registration`,
+            ),
+        );
+
+        it('verification succeeds with no password and correct token', async () => {
+            const a = 1;
+            const result = await shopClient.query<Verify.Mutation, Verify.Variables>(VERIFY_EMAIL, {
+                token: verificationToken,
+            });
+
+            expect(result.verifyCustomerAccount.user.identifier).toBe('test2@test.com');
+            const { activeCustomer } = await shopClient.query<GetActiveCustomer.Query>(GET_ACTIVE_CUSTOMER);
+        });
+    });
+
     describe('password reset', () => {
         let passwordResetToken: string;
         let customer: GetCustomer.Customer;

+ 1 - 1
packages/core/src/api/resolvers/admin/auth.resolver.ts

@@ -48,7 +48,7 @@ export class AuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ): Promise<LoginResult> {
-        return this.createAuthenticatedSession(ctx, args, req, res);
+        return this.authenticateAndCreateSession(ctx, args, req, res);
     }
 
     @Mutation()

+ 4 - 4
packages/core/src/api/resolvers/base/base-auth.resolver.ts

@@ -38,7 +38,7 @@ export class BaseAuthResolver {
         req: Request,
         res: Response,
     ): Promise<LoginResult> {
-        return await this.createAuthenticatedSession(
+        return await this.authenticateAndCreateSession(
             ctx,
             {
                 input: { [NATIVE_AUTH_STRATEGY_NAME]: args },
@@ -85,12 +85,12 @@ export class BaseAuthResolver {
     /**
      * Creates an authenticated session and sets the session token.
      */
-    protected async createAuthenticatedSession(
+    protected async authenticateAndCreateSession(
         ctx: RequestContext,
         args: MutationAuthenticateArgs,
         req: Request,
         res: Response,
-    ) {
+    ): Promise<LoginResult> {
         const [method, data] = Object.entries(args.input)[0];
         const { apiType } = ctx;
         const session = await this.authService.authenticate(ctx, apiType, method, data);
@@ -130,7 +130,7 @@ export class BaseAuthResolver {
     /**
      * Exposes a subset of the User properties which we want to expose to the public API.
      */
-    private publiclyAccessibleUser(user: User): CurrentUser {
+    protected publiclyAccessibleUser(user: User): CurrentUser {
         return {
             id: user.id as string,
             identifier: user.identifier,

+ 22 - 18
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -31,13 +31,14 @@ import { CustomerService } from '../../../service/services/customer.service';
 import { HistoryService } from '../../../service/services/history.service';
 import { UserService } from '../../../service/services/user.service';
 import { RequestContext } from '../../common/request-context';
+import { setSessionToken } from '../../common/set-session-token';
 import { Allow } from '../../decorators/allow.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { BaseAuthResolver } from '../base/base-auth.resolver';
 
 @Resolver()
 export class ShopAuthResolver extends BaseAuthResolver {
-    private nativeAuthStrategyIsConfigured = false;
+    private readonly nativeAuthStrategyIsConfigured: boolean;
 
     constructor(
         authService: AuthService,
@@ -49,7 +50,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
     ) {
         super(authService, userService, administratorService, configService);
         this.nativeAuthStrategyIsConfigured = !!this.configService.authOptions.shopAuthenticationStrategy.find(
-            strategy => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
+            (strategy) => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
         );
     }
 
@@ -73,7 +74,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ): Promise<LoginResult> {
-        return this.createAuthenticatedSession(ctx, args, req, res);
+        return this.authenticateAndCreateSession(ctx, args, req, res);
     }
 
     @Mutation()
@@ -109,27 +110,30 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Args() args: MutationVerifyCustomerAccountArgs,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ) {
+    ): Promise<LoginResult> {
         this.requireNativeAuthStrategy();
+        const { token, password } = args;
         const customer = await this.customerService.verifyCustomerEmailAddress(
             ctx,
-            args.token,
-            args.password,
+            token,
+            password || undefined,
         );
         if (customer && customer.user) {
-            return super.createAuthenticatedSession(
+            const session = await this.authService.createAuthenticatedSessionForUser(
                 ctx,
-                {
-                    input: {
-                        [NATIVE_AUTH_STRATEGY_NAME]: {
-                            username: customer.user.identifier,
-                            password: args.password,
-                        },
-                    },
-                },
+                customer.user,
+                NATIVE_AUTH_STRATEGY_NAME,
+            );
+            setSessionToken({
                 req,
                 res,
-            );
+                authOptions: this.configService.authOptions,
+                rememberMe: true,
+                sessionToken: session.token,
+            });
+            return {
+                user: this.publiclyAccessibleUser(session.user),
+            };
         } else {
             throw new VerificationTokenError();
         }
@@ -163,7 +167,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         const { token, password } = args;
         const customer = await this.customerService.resetPassword(ctx, token, password);
         if (customer && customer.user) {
-            return super.createAuthenticatedSession(
+            return super.authenticateAndCreateSession(
                 ctx,
                 {
                     input: {
@@ -230,7 +234,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
     private requireNativeAuthStrategy() {
         if (!this.nativeAuthStrategyIsConfigured) {
             const authStrategyNames = this.configService.authOptions.shopAuthenticationStrategy
-                .map(s => s.name)
+                .map((s) => s.name)
                 .join(', ');
             const errorMessage =
                 'This GraphQL operation requires that the NativeAuthenticationStrategy be configured for the Shop API.\n' +

+ 1 - 1
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -59,7 +59,7 @@ type Mutation {
     "Delete an existing Address"
     deleteCustomerAddress(id: ID!): Boolean!
     "Verify a Customer email address with the token sent to that address. Only applicable if `authOptions.requireVerification` is set to true."
-    verifyCustomerAccount(token: String!, password: String!): LoginResult!
+    verifyCustomerAccount(token: String!, password: String): LoginResult!
     "Update the password of the active Customer"
     updateCustomerPassword(currentPassword: String!, newPassword: String!): Boolean
     """

+ 3 - 2
packages/core/src/i18n/messages/en.json

@@ -63,8 +63,10 @@
     "order-items-limit-exceeded": "Cannot add items. An order may consist of a maximum of { maxItems } items",
     "order-lines-must-belong-to-same-order": "OrderLines must all belong to a single Order",
     "payment-may-only-be-added-in-arrangingpayment-state": "A Payment may only be added when Order is in \"ArrangingPayment\" state",
+    "password-already-set-during-registration": "A password has already been set during registration",
     "password-reset-token-has-expired": "Password reset token has expired.",
     "password-reset-token-not-recognized": "Password reset token not recognized",
+    "password-required-for-verification": "A password must be provided as it was not set during registration",
     "permission-invalid": "The permission \"{ permission }\" is not valid",
     "products-cannot-be-removed-from-default-channel": "Products cannot be removed from the default Channel",
     "product-id-or-slug-must-be-provided": "Either the Product id or slug must be provided",
@@ -80,8 +82,7 @@
     "stockonhand-cannot-be-negative": "stockOnHand cannot be a negative value",
     "verification-token-has-expired": "Verification token has expired. Use refreshCustomerVerification to send a new token.",
     "verification-token-not-recognized": "Verification token not recognized",
-    "unauthorized": "The credentials did not match. Please check and try again",
-    "unexpected-password-on-registration": "Do not provide a password when `authOptions.requireVerification` is set to \"true\""
+    "unauthorized": "The credentials did not match. Please check and try again"
   },
   "message": {
     "asset-to-be-deleted-is-featured": "The selected {assetCount, plural, one {Asset is} other {Assets are}} featured by {products, plural, =0 {} one {1 Product} other {# Products}} {variants, plural, =0 {} one { 1 ProductVariant} other { # ProductVariants}} {collections, plural, =0 {} one { 1 Collection} other { # Collections}}",

+ 9 - 1
packages/core/src/service/services/auth.service.ts

@@ -48,6 +48,14 @@ export class AuthService {
         if (!user) {
             throw new UnauthorizedError();
         }
+        return this.createAuthenticatedSessionForUser(ctx, user, authenticationStrategy.name);
+    }
+
+    async createAuthenticatedSessionForUser(
+        ctx: RequestContext,
+        user: User,
+        authenticationStrategyName: string,
+    ): Promise<AuthenticatedSession> {
         if (!user.roles || !user.roles[0]?.channels) {
             const userWithRoles = await this.connection
                 .getRepository(User)
@@ -70,7 +78,7 @@ export class AuthService {
         const session = await this.sessionService.createNewAuthenticatedSession(
             ctx,
             user,
-            authenticationStrategy,
+            authenticationStrategyName,
         );
         this.eventBus.publish(new LoginEvent(ctx, user));
         return session;

+ 3 - 7
packages/core/src/service/services/customer.service.ts

@@ -131,7 +131,7 @@ export class CustomerService {
         if (password && password !== '') {
             const verificationToken = customer.user.getNativeAuthenticationMethod().verificationToken;
             if (verificationToken) {
-                customer.user = await this.userService.verifyUserByToken(verificationToken, password);
+                customer.user = await this.userService.verifyUserByToken(verificationToken);
             }
         } else {
             this.eventBus.publish(new AccountRegistrationEvent(ctx, customer.user));
@@ -161,11 +161,7 @@ export class CustomerService {
     }
 
     async registerCustomerAccount(ctx: RequestContext, input: RegisterCustomerInput): Promise<boolean> {
-        if (this.configService.authOptions.requireVerification) {
-            if (input.password) {
-                throw new UserInputError(`error.unexpected-password-on-registration`);
-            }
-        } else {
+        if (!this.configService.authOptions.requireVerification) {
             if (!input.password) {
                 throw new UserInputError(`error.missing-password-on-registration`);
             }
@@ -241,7 +237,7 @@ export class CustomerService {
     async verifyCustomerEmailAddress(
         ctx: RequestContext,
         verificationToken: string,
-        password: string,
+        password?: string,
     ): Promise<Customer | undefined> {
         const user = await this.userService.verifyUserByToken(verificationToken, password);
         if (user) {

+ 2 - 2
packages/core/src/service/services/session.service.ts

@@ -65,7 +65,7 @@ export class SessionService implements EntitySubscriberInterface {
     async createNewAuthenticatedSession(
         ctx: RequestContext,
         user: User,
-        authenticationStrategy: AuthenticationStrategy,
+        authenticationStrategyName: string,
     ): Promise<AuthenticatedSession> {
         const token = await this.generateSessionToken();
         const guestOrder =
@@ -79,7 +79,7 @@ export class SessionService implements EntitySubscriberInterface {
                 token,
                 user,
                 activeOrder,
-                authenticationStrategy: authenticationStrategy.name,
+                authenticationStrategy: authenticationStrategyName,
                 expires: this.getExpiryDate(this.sessionDurationInMs),
                 invalidated: false,
             }),

+ 13 - 2
packages/core/src/service/services/user.service.ts

@@ -9,6 +9,7 @@ import {
     InternalServerError,
     PasswordResetTokenExpiredError,
     UnauthorizedError,
+    UserInputError,
     VerificationTokenExpiredError,
 } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
@@ -103,17 +104,27 @@ export class UserService {
         return this.connection.manager.save(user);
     }
 
-    async verifyUserByToken(verificationToken: string, password: string): Promise<User | undefined> {
+    async verifyUserByToken(verificationToken: string, password?: string): Promise<User | undefined> {
         const user = await this.connection
             .getRepository(User)
             .createQueryBuilder('user')
             .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethod')
+            .addSelect('authenticationMethod.passwordHash')
             .where('authenticationMethod.verificationToken = :verificationToken', { verificationToken })
             .getOne();
         if (user) {
             if (this.verificationTokenGenerator.verifyVerificationToken(verificationToken)) {
                 const nativeAuthMethod = user.getNativeAuthenticationMethod();
-                nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
+                if (!password) {
+                    if (!nativeAuthMethod.passwordHash) {
+                        throw new UserInputError(`error.password-required-for-verification`);
+                    }
+                } else {
+                    if (!!nativeAuthMethod.passwordHash) {
+                        throw new UserInputError(`error.password-already-set-during-registration`);
+                    }
+                    nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
+                }
                 nativeAuthMethod.verificationToken = null;
                 user.verified = true;
                 await this.connection.manager.save(nativeAuthMethod);

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
schema-shop.json


Неке датотеке нису приказане због велике количине промена