Răsfoiți Sursa

fix(server): Correctly handle expired registration tokens

Relates to #33
Michael Bromley 7 ani în urmă
părinte
comite
4fc9c8ff99

+ 100 - 37
server/e2e/auth.e2e-spec.ts

@@ -29,10 +29,20 @@ import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
 
+let sendEmailFn: jest.Mock;
+const emailOptions = {
+    emailTemplatePath: 'src/email/templates',
+    emailTypes: defaultEmailTypes,
+    generator: new NoopEmailGenerator(),
+    transport: {
+        type: 'testing' as 'testing',
+        onSend: ctx => sendEmailFn(ctx),
+    },
+};
+
 describe('Authorization & permissions', () => {
     const client = new TestClient();
     const server = new TestServer();
-    let sendEmailFn: jest.Mock;
 
     beforeAll(async () => {
         const token = await server.init(
@@ -41,15 +51,7 @@ describe('Authorization & permissions', () => {
                 customerCount: 1,
             },
             {
-                emailOptions: {
-                    emailTemplatePath: 'src/email/templates',
-                    emailTypes: defaultEmailTypes,
-                    generator: new NoopEmailGenerator(),
-                    transport: {
-                        type: 'testing',
-                        onSend: ctx => sendEmailFn(ctx),
-                    },
-                },
+                emailOptions,
             },
         );
         await client.init();
@@ -270,14 +272,6 @@ describe('Authorization & permissions', () => {
         });
     });
 
-    function getVerificationTokenPromise(): Promise<string> {
-        return new Promise<string>(resolve => {
-            sendEmailFn.mockImplementation(ctx => {
-                resolve(ctx.event.user.verificationToken);
-            });
-        });
-    }
-
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
         try {
             const status = await client.queryStatus(operation, variables);
@@ -350,27 +344,96 @@ describe('Authorization & permissions', () => {
     function waitForSendEmailFn() {
         return new Promise(resolve => setTimeout(resolve, 10));
     }
+});
+
+describe('Expiring registration token', () => {
+    const client = new TestClient();
+    const server = new TestServer();
 
-    const REGISTER_ACCOUNT = gql`
-        mutation Register($input: RegisterCustomerInput!) {
-            registerCustomerAccount(input: $input)
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productCount: 1,
+                customerCount: 1,
+            },
+            {
+                emailOptions,
+                authOptions: {
+                    verificationTokenDuration: '1ms',
+                },
+            },
+        );
+        await client.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    beforeEach(() => {
+        sendEmailFn = jest.fn();
+    });
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('attempting to verify after token has expired throws', async () => {
+        const verificationTokenPromise = getVerificationTokenPromise();
+        const input: RegisterCustomerInput = {
+            firstName: 'Barry',
+            lastName: 'Wallace',
+            emailAddress: 'barry.wallace@test.com',
+        };
+        const result1 = await client.query(REGISTER_ACCOUNT, { input });
+
+        const verificationToken = await verificationTokenPromise;
+
+        expect(result1.registerCustomerAccount).toBe(true);
+        expect(sendEmailFn).toHaveBeenCalledTimes(1);
+        expect(verificationToken).toBeDefined();
+
+        await new Promise(resolve => setTimeout(resolve, 3));
+
+        try {
+            await client.query(VERIFY_EMAIL, {
+                password: 'test',
+                token: verificationToken,
+            });
+            fail('should have thrown');
+        } catch (err) {
+            expect(err.message).toEqual(
+                expect.stringContaining(
+                    `Verification token has expired. Use refreshCustomerVerification to send a new token.`,
+                ),
+            );
         }
-    `;
-
-    const VERIFY_EMAIL = gql`
-        mutation Verify($password: String!, $token: String!) {
-            verifyCustomerAccount(password: $password, token: $token) {
-                user {
-                    id
-                    identifier
-                }
+    });
+});
+
+function getVerificationTokenPromise(): Promise<string> {
+    return new Promise<string>(resolve => {
+        sendEmailFn.mockImplementation(ctx => {
+            resolve(ctx.event.user.verificationToken);
+        });
+    });
+}
+
+const REGISTER_ACCOUNT = gql`
+    mutation Register($input: RegisterCustomerInput!) {
+        registerCustomerAccount(input: $input)
+    }
+`;
+
+const VERIFY_EMAIL = gql`
+    mutation Verify($password: String!, $token: String!) {
+        verifyCustomerAccount(password: $password, token: $token) {
+            user {
+                id
+                identifier
             }
         }
-    `;
+    }
+`;
 
-    const REFRESH_TOKEN = gql`
-        mutation RefreshToken($emailAddress: String!) {
-            refreshCustomerVerification(emailAddress: $emailAddress)
-        }
-    `;
-});
+const REFRESH_TOKEN = gql`
+    mutation RefreshToken($emailAddress: String!) {
+        refreshCustomerVerification(emailAddress: $emailAddress)
+    }
+`;

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

@@ -51,6 +51,12 @@ export class VerificationTokenError extends I18nError {
     }
 }
 
+export class VerificationTokenExpiredError extends I18nError {
+    constructor() {
+        super('error.verification-token-has-expired', {}, 'EXPIRED_VERIFICATION_TOKEN');
+    }
+}
+
 export class NotVerifiedError extends I18nError {
     constructor() {
         super('error.email-address-not-verified', {}, 'NOT_VERIFIED');

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

@@ -17,6 +17,7 @@
     "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",
+    "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"
   }

+ 37 - 0
server/src/service/helpers/verification-token-generator/verification-token-generator.ts

@@ -0,0 +1,37 @@
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+
+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.
+ */
+@Injectable()
+export class VerificationTokenGenerator {
+    constructor(private configService: ConfigService) {}
+
+    /**
+     * Generates a verification token which encodes the time of generation and concatenates it with a
+     * random id.
+     */
+    generateVerificationToken() {
+        const now = new Date();
+        const base64Now = Buffer.from(now.toJSON()).toString('base64');
+        const id = generatePublicId();
+        return `${base64Now}_${id}`;
+    }
+
+    /**
+     * Checks the age of the verification token to see if it falls within the token duration
+     * as specified in the VendureConfig.
+     */
+    verifyVerificationToken(token: string): boolean {
+        const duration = ms(this.configService.authOptions.verificationTokenDuration);
+        const [generatedOn] = token.split('_');
+        const dateString = Buffer.from(generatedOn, 'base64').toString();
+        const date = new Date(dateString);
+        const elapsed = +new Date() - +date;
+        return elapsed < duration;
+    }
+}

+ 2 - 0
server/src/service/service.module.ts

@@ -14,6 +14,7 @@ import { PasswordCiper } from './helpers/password-cipher/password-ciper';
 import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
 import { TaxCalculator } from './helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
+import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator';
 import { AdministratorService } from './services/administrator.service';
 import { AssetService } from './services/asset.service';
 import { AuthService } from './services/auth.service';
@@ -84,6 +85,7 @@ const exportedProviders = [
         ListQueryBuilder,
         ShippingCalculator,
         AssetUpdater,
+        VerificationTokenGenerator,
     ],
     exports: exportedProviders,
 })

+ 8 - 29
server/src/service/services/user.service.ts

@@ -1,13 +1,13 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import ms from 'ms';
 import { Connection } from 'typeorm';
 
 import { ID } from '../../../../shared/shared-types';
-import { generatePublicId } from '../../common/generate-public-id';
+import { 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';
+import { VerificationTokenGenerator } from '../helpers/verification-token-generator/verification-token-generator';
 
 import { RoleService } from './role.service';
 
@@ -18,6 +18,7 @@ export class UserService {
         private configService: ConfigService,
         private roleService: RoleService,
         private passwordCipher: PasswordCiper,
+        private verificationTokenGenerator: VerificationTokenGenerator,
     ) {}
 
     async getUserById(userId: ID): Promise<User | undefined> {
@@ -38,7 +39,7 @@ export class UserService {
     async createCustomerUser(identifier: string, password?: string): Promise<User> {
         const user = new User();
         if (this.configService.authOptions.requireVerification) {
-            user.verificationToken = this.generateVerificationToken();
+            user.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
             user.verified = false;
         } else {
             user.verified = true;
@@ -64,7 +65,7 @@ export class UserService {
     }
 
     async setVerificationToken(user: User): Promise<User> {
-        user.verificationToken = this.generateVerificationToken();
+        user.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
         user.verified = false;
         return this.connection.manager.save(user);
     }
@@ -74,37 +75,15 @@ export class UserService {
             where: { verificationToken },
         });
         if (user) {
-            if (this.verifyVerificationToken(verificationToken)) {
+            if (this.verificationTokenGenerator.verifyVerificationToken(verificationToken)) {
                 user.passwordHash = await this.passwordCipher.hash(password);
                 user.verificationToken = null;
                 user.verified = true;
                 await this.connection.getRepository(User).save(user);
                 return user;
+            } else {
+                throw new VerificationTokenExpiredError();
             }
         }
     }
-
-    /**
-     * Generates a verification token which encodes the time of generation and concatenates it with a
-     * random id.
-     */
-    private generateVerificationToken() {
-        const now = new Date();
-        const base64Now = Buffer.from(now.toJSON()).toString('base64');
-        const id = generatePublicId();
-        return `${base64Now}_${id}`;
-    }
-
-    /**
-     * Checks the age of the verification token to see if it falls within the token duration
-     * as specified in the VendureConfig.
-     */
-    private verifyVerificationToken(token: string): boolean {
-        const duration = ms(this.configService.authOptions.verificationTokenDuration);
-        const [generatedOn] = token.split('_');
-        const dateString = Buffer.from(generatedOn, 'base64').toString();
-        const date = new Date(dateString);
-        const elapsed = +new Date() - +date;
-        return elapsed < duration;
-    }
 }