Browse Source

feat(server): Implement password reset flow for shop API

Closes #68
Michael Bromley 7 years ago
parent
commit
54e6bf6495

File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


+ 73 - 1
schema.json

@@ -16210,7 +16210,7 @@
           },
           {
             "name": "updateCustomerPassword",
-            "description": null,
+            "description": "Update the password of the active Customer",
             "args": [
               {
                 "name": "currentPassword",
@@ -16248,6 +16248,78 @@
             },
             "isDeprecated": false,
             "deprecationReason": null
+          },
+          {
+            "name": "requestPasswordReset",
+            "description": "Requests a password reset email to be sent",
+            "args": [
+              {
+                "name": "emailAddress",
+                "description": null,
+                "type": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "SCALAR",
+                    "name": "String",
+                    "ofType": null
+                  }
+                },
+                "defaultValue": null
+              }
+            ],
+            "type": {
+              "kind": "SCALAR",
+              "name": "Boolean",
+              "ofType": null
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
+          {
+            "name": "resetPassword",
+            "description": "Resets a Customer's password based on the provided token",
+            "args": [
+              {
+                "name": "token",
+                "description": null,
+                "type": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "SCALAR",
+                    "name": "String",
+                    "ofType": null
+                  }
+                },
+                "defaultValue": null
+              },
+              {
+                "name": "password",
+                "description": null,
+                "type": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "SCALAR",
+                    "name": "String",
+                    "ofType": null
+                  }
+                },
+                "defaultValue": null
+              }
+            ],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "OBJECT",
+                "name": "LoginResult",
+                "ofType": null
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
           }
         ],
         "inputFields": null,

+ 123 - 2
server/e2e/shop-auth.e2e-spec.ts

@@ -1,3 +1,4 @@
+/* tslint:disable:no-non-null-assertion */
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -6,8 +7,9 @@ import {
     CREATE_ADMINISTRATOR,
     CREATE_ROLE,
 } from '../../admin-ui/src/app/data/definitions/administrator-definitions';
+import { GET_CUSTOMER } from '../../admin-ui/src/app/data/definitions/customer-definitions';
 import { RegisterCustomerInput } from '../../shared/generated-shop-types';
-import { CreateAdministrator, CreateRole, Permission } from '../../shared/generated-types';
+import { CreateAdministrator, CreateRole, GetCustomer, Permission } from '../../shared/generated-types';
 import { NoopEmailGenerator } from '../src/config/email/noop-email-generator';
 import { defaultEmailTypes } from '../src/email/default-email-types';
 
@@ -29,6 +31,7 @@ const emailOptions = {
 
 describe('Shop auth & accounts', () => {
     const shopClient = new TestShopClient();
+    const adminClient = new TestAdminClient();
     const server = new TestServer();
 
     beforeAll(async () => {
@@ -42,6 +45,7 @@ describe('Shop auth & accounts', () => {
             },
         );
         await shopClient.init();
+        await adminClient.init();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -189,6 +193,68 @@ describe('Shop auth & accounts', () => {
         );
     });
 
+    describe('password reset', () => {
+        let passwordResetToken: string;
+        let customer: GetCustomer.Customer;
+
+        beforeAll(async () => {
+            const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+                id: 'T_1',
+            });
+            customer = result.customer!;
+        });
+
+        beforeEach(() => {
+            sendEmailFn = jest.fn();
+        });
+
+        it('requestPasswordReset silently fails with invalid identifier', async () => {
+            const result = await shopClient.query(REQUEST_PASSWORD_RESET, {
+                identifier: 'invalid-identifier',
+            });
+
+            await waitForSendEmailFn();
+            expect(result.requestPasswordReset).toBe(true);
+            expect(sendEmailFn).not.toHaveBeenCalled();
+            expect(passwordResetToken).not.toBeDefined();
+        });
+
+        it('requestPasswordReset sends reset token', async () => {
+            const passwordResetTokenPromise = getPasswordResetTokenPromise();
+            const result = await shopClient.query(REQUEST_PASSWORD_RESET, {
+                identifier: customer.emailAddress,
+            });
+
+            passwordResetToken = await passwordResetTokenPromise;
+
+            expect(result.requestPasswordReset).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(passwordResetToken).toBeDefined();
+        });
+
+        it(
+            'resetPassword fails with wrong token',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(RESET_PASSWORD, {
+                        password: 'newPassword',
+                        token: 'bad-token',
+                    }),
+                `Password reset token not recognized`,
+            ),
+        );
+
+        it('resetPassword works with valid token', async () => {
+            const result = await shopClient.query(RESET_PASSWORD, {
+                token: passwordResetToken,
+                password: 'newPassword',
+            });
+
+            const loginResult = await shopClient.asUserWithCredentials(customer.emailAddress, 'newPassword');
+            expect(loginResult.user.identifier).toBe(customer.emailAddress);
+        });
+    });
+
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
         try {
             const status = await shopClient.queryStatus(operation, variables);
@@ -263,8 +329,9 @@ describe('Shop auth & accounts', () => {
     }
 });
 
-describe('Expiring registration token', () => {
+describe('Expiring tokens', () => {
     const shopClient = new TestShopClient();
+    const adminClient = new TestAdminClient();
     const server = new TestServer();
 
     beforeAll(async () => {
@@ -281,6 +348,7 @@ describe('Expiring registration token', () => {
             },
         );
         await shopClient.init();
+        await adminClient.init();
     }, TEST_SETUP_TIMEOUT_MS);
 
     beforeEach(() => {
@@ -316,6 +384,34 @@ describe('Expiring registration token', () => {
             });
         }, `Verification token has expired. Use refreshCustomerVerification to send a new token.`),
     );
+
+    it(
+        'attempting to reset password after token has expired throws',
+        assertThrowsWithMessage(async () => {
+            const { customer } = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(
+                GET_CUSTOMER,
+                { id: 'T_1' },
+            );
+
+            const passwordResetTokenPromise = getPasswordResetTokenPromise();
+            const result = await shopClient.query(REQUEST_PASSWORD_RESET, {
+                identifier: customer!.emailAddress,
+            });
+
+            const passwordResetToken = await passwordResetTokenPromise;
+
+            expect(result.requestPasswordReset).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalledTimes(1);
+            expect(passwordResetToken).toBeDefined();
+
+            await new Promise(resolve => setTimeout(resolve, 3));
+
+            return shopClient.query(RESET_PASSWORD, {
+                password: 'test',
+                token: passwordResetToken,
+            });
+        }, `Password reset token has expired.`),
+    );
 });
 
 describe('Registration without email verification', () => {
@@ -396,6 +492,14 @@ function getVerificationTokenPromise(): Promise<string> {
     });
 }
 
+function getPasswordResetTokenPromise(): Promise<string> {
+    return new Promise<string>(resolve => {
+        sendEmailFn.mockImplementation(ctx => {
+            resolve(ctx.event.user.passwordResetToken);
+        });
+    });
+}
+
 const REGISTER_ACCOUNT = gql`
     mutation Register($input: RegisterCustomerInput!) {
         registerCustomerAccount(input: $input)
@@ -418,3 +522,20 @@ const REFRESH_TOKEN = gql`
         refreshCustomerVerification(emailAddress: $emailAddress)
     }
 `;
+
+const REQUEST_PASSWORD_RESET = gql`
+    mutation RequestPasswordReset($identifier: String!) {
+        requestPasswordReset(emailAddress: $identifier)
+    }
+`;
+
+const RESET_PASSWORD = gql`
+    mutation ResetPassword($token: String!, $password: String!) {
+        resetPassword(token: $token, password: $password) {
+            user {
+                id
+                identifier
+            }
+        }
+    }
+`;

+ 36 - 6
server/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -7,10 +7,12 @@ import {
     Permission,
     RefreshCustomerVerificationMutationArgs,
     RegisterCustomerAccountMutationArgs,
+    RequestPasswordResetMutationArgs,
+    ResetPasswordMutationArgs,
     UpdateCustomerPasswordMutationArgs,
     VerifyCustomerAccountMutationArgs,
 } from '../../../../../shared/generated-shop-types';
-import { VerificationTokenError } from '../../../common/error/errors';
+import { PasswordResetTokenError, VerificationTokenError } from '../../../common/error/errors';
 import { ConfigService } from '../../../config/config.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
@@ -71,11 +73,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ) {
-        const customer = await this.customerService.verifyCustomerEmailAddress(
-            ctx,
-            args.token,
-            args.password,
-        );
+        const customer = await this.customerService.verifyCustomerEmailAddress(args.token, args.password);
         if (customer && customer.user) {
             return super.createAuthenticatedSession(
                 ctx,
@@ -101,6 +99,38 @@ export class ShopAuthResolver extends BaseAuthResolver {
         return this.customerService.refreshVerificationToken(ctx, args.emailAddress).then(() => true);
     }
 
+    @Mutation()
+    @Allow(Permission.Public)
+    async requestPasswordReset(@Ctx() ctx: RequestContext, @Args() args: RequestPasswordResetMutationArgs) {
+        return this.customerService.requestPasswordReset(ctx, args.emailAddress).then(() => true);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    async resetPassword(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ResetPasswordMutationArgs,
+        @Context('req') req: Request,
+        @Context('res') res: Response,
+    ) {
+        const { token, password } = args;
+        const customer = await this.customerService.resetPassword(token, password);
+        if (customer && customer.user) {
+            return super.createAuthenticatedSession(
+                ctx,
+                {
+                    username: customer.user.identifier,
+                    password: args.password,
+                    rememberMe: true,
+                },
+                req,
+                res,
+            );
+        } else {
+            throw new PasswordResetTokenError();
+        }
+    }
+
     @Mutation()
     @Allow(Permission.Owner)
     async updateCustomerPassword(

+ 4 - 0
server/src/api/schema/shop-api/shop.api.graphql

@@ -43,6 +43,10 @@ type Mutation {
     verifyCustomerAccount(token: String!, password: String!): LoginResult!
     "Update the password of the active Customer"
     updateCustomerPassword(currentPassword: String!, newPassword: String!): Boolean
+    "Requests a password reset email to be sent"
+    requestPasswordReset(emailAddress: String!): Boolean
+    "Resets a Customer's password based on the provided token"
+    resetPassword(token: String!, password: String!): LoginResult!
 }
 
 input RegisterCustomerInput {

+ 12 - 0
server/src/common/error/errors.ts

@@ -63,6 +63,18 @@ export class VerificationTokenExpiredError extends I18nError {
     }
 }
 
+export class PasswordResetTokenError extends I18nError {
+    constructor() {
+        super('error.password-reset-token-not-recognized', {}, 'BAD_PASSWORD_RESET_TOKEN');
+    }
+}
+
+export class PasswordResetTokenExpiredError extends I18nError {
+    constructor() {
+        super('error.password-reset-token-has-expired', {}, 'EXPIRED_PASSWORD_RESET_TOKEN');
+    }
+}
+
 export class NotVerifiedError extends I18nError {
     constructor() {
         super('error.email-address-not-verified', {}, 'NOT_VERIFIED');

+ 24 - 5
server/src/email/default-email-types.ts

@@ -1,12 +1,9 @@
-import path from 'path';
-
-import { LanguageCode } from '../../../shared/generated-types';
-import { DEFAULT_CHANNEL_CODE } from '../../../shared/shared-constants';
 import { configEmailType, EmailTypes } from '../config/email/email-options';
 import { AccountRegistrationEvent } from '../event-bus/events/account-registration-event';
 import { OrderStateTransitionEvent } from '../event-bus/events/order-state-transition-event';
+import { PasswordResetEvent } from '../event-bus/events/password-reset-event';
 
-export type DefaultEmailType = 'order-confirmation' | 'email-verification';
+export type DefaultEmailType = 'order-confirmation' | 'email-verification' | 'password-reset';
 
 const SHOPFRONT_URL = 'http://localhost:4201/';
 
@@ -55,4 +52,26 @@ export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
             },
         },
     }),
+    'password-reset': configEmailType({
+        triggerEvent: PasswordResetEvent,
+        createContext: e => {
+            return {
+                recipient: e.user.identifier,
+                languageCode: e.ctx.languageCode,
+                channelCode: e.ctx.channel.code,
+            };
+        },
+        templates: {
+            defaultChannel: {
+                defaultLanguage: {
+                    templateContext: emailContext => ({
+                        user: emailContext.event.user,
+                        passwordResetUrl: SHOPFRONT_URL + 'reset-password',
+                    }),
+                    subject: `Forgotten password reset`,
+                    templatePath: 'password-reset/password-reset.hbs',
+                },
+            },
+        },
+    }),
 };

+ 30 - 0
server/src/email/templates/password-reset/password-reset.hbs

@@ -0,0 +1,30 @@
+{{> header title="Forgotten password reset" }}
+
+<mj-section background-color="#fafafa">
+    <mj-column>
+
+        <mj-text font-style="italic"
+                 font-size="20px"
+                 font-family="Helvetica Neue"
+                 color="#626262">Verify Your Email Address</mj-text>
+
+        <mj-text color="#525252">
+            Someone requested a new password for your account.
+        </mj-text>
+
+        <mj-button font-family="Helvetica"
+                   background-color="#f45e43"
+                   color="white"
+                   href="{{ passwordResetUrl }}?token={{ user.passwordResetToken }}">
+            Reset password
+        </mj-button>
+
+        <mj-text color="#525252">
+        If you didn't make this request then you can safely ignore this email - nothing has been changed on your account.
+        </mj-text>
+
+    </mj-column>
+</mj-section>
+
+
+{{> footer }}

+ 3 - 0
server/src/entity/user/user.entity.ts

@@ -29,6 +29,9 @@ export class User extends VendureEntity implements HasCustomFields {
     @Column({ type: 'varchar', nullable: true })
     verificationToken: string | null;
 
+    @Column({ type: 'varchar', nullable: true })
+    passwordResetToken: string | null;
+
     @ManyToMany(type => Role)
     @JoinTable()
     roles: Role[];

+ 15 - 0
server/src/event-bus/events/password-reset-event.ts

@@ -0,0 +1,15 @@
+import { RequestContext } from '../../api/common/request-context';
+import { User } from '../../entity/user/user.entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired when a Customer requests a password reset email.
+ *
+ * @docsCategory events
+ */
+export class PasswordResetEvent extends VendureEvent {
+    constructor(public ctx: RequestContext, public user: User) {
+        super();
+    }
+}

+ 2 - 0
server/src/i18n/messages/en.json

@@ -19,6 +19,8 @@
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem",
     "payment-may-only-be-added-in-arrangingpayment-state": "A Payment may only be added when Order is in \"ArrangingPayment\" state",
+    "password-reset-token-has-expired": "Password reset token has expired.",
+    "password-reset-token-not-recognized": "Password reset token not recognized",
     "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",

+ 2 - 1
server/src/service/helpers/verification-token-generator/verification-token-generator.ts

@@ -5,7 +5,8 @@ import { generatePublicId } from '../../../common/generate-public-id';
 import { ConfigService } from '../../../config/config.service';
 
 /**
- * This class is responsible for generating and verifying the tokens issued when new accounts are registered.
+ * This class is responsible for generating and verifying the tokens issued when new accounts are registered
+ * or when a password reset is requested.
  */
 @Injectable()
 export class VerificationTokenGenerator {

+ 17 - 4
server/src/service/services/customer.service.ts

@@ -21,6 +21,7 @@ import { Address } from '../../entity/address/address.entity';
 import { Customer } from '../../entity/customer/customer.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { AccountRegistrationEvent } from '../../event-bus/events/account-registration-event';
+import { PasswordResetEvent } from '../../event-bus/events/password-reset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { patchEntity } from '../helpers/utils/patch-entity';
@@ -130,7 +131,7 @@ export class CustomerService {
         return true;
     }
 
-    async refreshVerificationToken(ctx: RequestContext, emailAddress: string) {
+    async refreshVerificationToken(ctx: RequestContext, emailAddress: string): Promise<void> {
         const user = await this.userService.getUserByEmailAddress(emailAddress);
         if (user) {
             await this.userService.setVerificationToken(user);
@@ -141,14 +142,26 @@ export class CustomerService {
     }
 
     async verifyCustomerEmailAddress(
-        ctx: RequestContext,
         verificationToken: string,
         password: string,
     ): Promise<Customer | undefined> {
         const user = await this.userService.verifyUserByToken(verificationToken, password);
         if (user) {
-            const customer = await this.findOneByUserId(user.id);
-            return customer;
+            return this.findOneByUserId(user.id);
+        }
+    }
+
+    async requestPasswordReset(ctx: RequestContext, emailAddress: string): Promise<void> {
+        const user = await this.userService.setPasswordResetToken(emailAddress);
+        if (user) {
+            this.eventBus.publish(new PasswordResetEvent(ctx, user));
+        }
+    }
+
+    async resetPassword(passwordResetToken: string, password: string): Promise<Customer | undefined> {
+        const user = await this.userService.resetPasswordByToken(passwordResetToken, password);
+        if (user) {
+            return this.findOneByUserId(user.id);
         }
     }
 

+ 30 - 3
server/src/service/services/user.service.ts

@@ -3,7 +3,11 @@ import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
 import { ID } from '../../../../shared/shared-types';
-import { UnauthorizedError, VerificationTokenExpiredError } from '../../common/error/errors';
+import {
+    PasswordResetTokenExpiredError,
+    UnauthorizedError,
+    VerificationTokenExpiredError,
+} from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
 import { User } from '../../entity/user/user.entity';
 import { PasswordCiper } from '../helpers/password-cipher/password-ciper';
@@ -79,14 +83,37 @@ export class UserService {
                 user.passwordHash = await this.passwordCipher.hash(password);
                 user.verificationToken = null;
                 user.verified = true;
-                await this.connection.getRepository(User).save(user);
-                return user;
+                return this.connection.getRepository(User).save(user);
             } else {
                 throw new VerificationTokenExpiredError();
             }
         }
     }
 
+    async setPasswordResetToken(emailAddress: string): Promise<User | undefined> {
+        const user = await this.getUserByEmailAddress(emailAddress);
+        if (!user) {
+            return;
+        }
+        user.passwordResetToken = await this.verificationTokenGenerator.generateVerificationToken();
+        return this.connection.getRepository(User).save(user);
+    }
+
+    async resetPasswordByToken(passwordResetToken: string, password: string): Promise<User | undefined> {
+        const user = await this.connection.getRepository(User).findOne({
+            where: { passwordResetToken },
+        });
+        if (user) {
+            if (this.verificationTokenGenerator.verifyVerificationToken(passwordResetToken)) {
+                user.passwordHash = await this.passwordCipher.hash(password);
+                user.passwordResetToken = null;
+                return this.connection.getRepository(User).save(user);
+            } else {
+                throw new PasswordResetTokenExpiredError();
+            }
+        }
+    }
+
     async updatePassword(user: User, currentPassword: string, newPassword: string): Promise<boolean> {
         const matches = await this.passwordCipher.check(currentPassword, user.passwordHash);
         if (!matches) {

+ 14 - 2
shared/generated-shop-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-02-26T09:46:27+01:00
+// Generated in 2019-02-26T12:18:47+01:00
 export type Maybe<T> = T | null;
 
 export interface OrderListOptions {
@@ -1523,8 +1523,12 @@ export interface Mutation {
     deleteCustomerAddress: boolean;
     /** Verify a Customer email address with the token sent to that address. Only applicable if `authOptions.requireVerification` is set to true. */
     verifyCustomerAccount: LoginResult;
-
+    /** Update the password of the active Customer */
     updateCustomerPassword?: Maybe<boolean>;
+    /** Requests a password reset email to be sent */
+    requestPasswordReset?: Maybe<boolean>;
+    /** Resets a Customer's password based on the provided token */
+    resetPassword: LoginResult;
 }
 
 export interface LoginResult {
@@ -1776,3 +1780,11 @@ export interface UpdateCustomerPasswordMutationArgs {
 
     newPassword: string;
 }
+export interface RequestPasswordResetMutationArgs {
+    emailAddress: string;
+}
+export interface ResetPasswordMutationArgs {
+    token: string;
+
+    password: string;
+}

+ 1 - 1
shared/generated-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-02-26T09:46:29+01:00
+// Generated in 2019-02-26T12:18:48+01:00
 export type Maybe<T> = T | null;
 
 

Some files were not shown because too many files changed in this diff