Parcourir la source

feat(server): Implement basics of User email verification flow

Relates to #33. Edge cases need checking and test should be written.
Michael Bromley il y a 7 ans
Parent
commit
00603e3088

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema.json


+ 70 - 12
server/src/api/resolvers/auth.resolver.ts

@@ -1,11 +1,18 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Request, Response } from 'express';
-import { LoginMutationArgs, LoginResult, Permission } from 'shared/generated-types';
+import {
+    LoginMutationArgs,
+    LoginResult,
+    Permission,
+    RegisterCustomerAccountMutationArgs,
+    VerifyCustomerEmailAddressMutationArgs,
+} from 'shared/generated-types';
 
 import { ConfigService } from '../../config/config.service';
 import { User } from '../../entity/user/user.entity';
 import { AuthService } from '../../service/services/auth.service';
 import { ChannelService } from '../../service/services/channel.service';
+import { CustomerService } from '../../service/services/customer.service';
 import { UserService } from '../../service/services/user.service';
 import { extractAuthToken } from '../common/extract-auth-token';
 import { RequestContext } from '../common/request-context';
@@ -19,6 +26,7 @@ export class AuthResolver {
         private authService: AuthService,
         private userService: UserService,
         private channelService: ChannelService,
+        private customerService: CustomerService,
         private configService: ConfigService,
     ) {}
 
@@ -34,17 +42,7 @@ export class AuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ): Promise<LoginResult> {
-        const session = await this.authService.authenticate(ctx, args.username, args.password);
-        setAuthToken({
-            req,
-            res,
-            authOptions: this.configService.authOptions,
-            rememberMe: args.rememberMe || false,
-            authToken: session.token,
-        });
-        return {
-            user: this.publiclyAccessibleUser(session.user),
-        };
+        return await this.createAuthenticatedSession(ctx, args, req, res);
     }
 
     @Mutation()
@@ -65,6 +63,44 @@ export class AuthResolver {
         return true;
     }
 
+    @Mutation()
+    @Allow(Permission.Public)
+    async registerCustomerAccount(
+        @Ctx() ctx: RequestContext,
+        @Args() args: RegisterCustomerAccountMutationArgs,
+    ) {
+        return this.customerService
+            .registerCustomerAccount(ctx, args.emailAddress, args.password)
+            .then(() => true);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    async verifyCustomerEmailAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: VerifyCustomerEmailAddressMutationArgs,
+        @Context('req') req: Request,
+        @Context('res') res: Response,
+    ) {
+        const customer = await this.customerService.verifyCustomerEmailAddress(
+            ctx,
+            args.token,
+            args.password,
+        );
+        if (customer && customer.user) {
+            return this.createAuthenticatedSession(
+                ctx,
+                {
+                    username: customer.user.identifier,
+                    password: args.password,
+                    rememberMe: true,
+                },
+                req,
+                res,
+            );
+        }
+    }
+
     /**
      * Returns information about the current authenticated user.
      */
@@ -76,6 +112,28 @@ export class AuthResolver {
         return user ? this.publiclyAccessibleUser(user) : null;
     }
 
+    /**
+     * Creates an authenticated session and sets the session token.
+     */
+    private async createAuthenticatedSession(
+        ctx: RequestContext,
+        args: LoginMutationArgs,
+        req: Request,
+        res: Response,
+    ) {
+        const session = await this.authService.authenticate(ctx, args.username, args.password);
+        setAuthToken({
+            req,
+            res,
+            authOptions: this.configService.authOptions,
+            rememberMe: args.rememberMe || false,
+            authToken: session.token,
+        });
+        return {
+            user: this.publiclyAccessibleUser(session.user),
+        };
+    }
+
     /**
      * Exposes a subset of the User properties which we want to expose to the public API.
      */

+ 4 - 0
server/src/api/types/auth.api.graphql

@@ -5,6 +5,10 @@ type Query {
 type Mutation {
     login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
     logout: Boolean!
+    "Register a Customer account with the given credentials"
+    registerCustomerAccount(emailAddress: String!, password: String!): Boolean!
+    "Verify a Customer email address with the token sent to that address"
+    verifyCustomerEmailAddress(token: String!, password: String!): LoginResult!
 }
 
 type LoginResult {

+ 4 - 4
server/src/common/generate-public-id.ts

@@ -6,15 +6,15 @@ import generate = require('nanoid/generate');
  *
  * The restriction to only uppercase letters and numbers is intended to make
  * reading and reciting the generated string easier and less error-prone for people.
- * Note that the letters "O" and "I" are also omitted because they are easily
- * confused with 0 and 1 respectively.
+ * Note that the letters "O" and "I" and number 0 are also omitted because they are easily
+ * confused.
  *
  * There is a trade-off between the length of the string and the probability
  * of collisions (the same ID being generated twice). We are using a length of
  * 16, which according to calculations (https://zelark.github.io/nano-id-cc/)
- * would require IDs to be generated at a rate of 1000/hour for 29k years to
+ * would require IDs to be generated at a rate of 1000/hour for 23k years to
  * reach a probability of 1% that a collision would occur.
  */
 export function generatePublicId(): string {
-    return generate('0123456789ABCDEFGHJKLMNPQRSTUVWXYZ', 16);
+    return generate('123456789ABCDEFGHJKLMNPQRSTUVWXYZ', 16);
 }

+ 2 - 0
server/src/config/default-config.ts

@@ -35,6 +35,8 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         sessionSecret: 'session-secret',
         authTokenHeaderKey: 'vendure-auth-token',
         sessionDuration: '7d',
+        requireVerification: true,
+        verificationTokenDuration: '7d',
     },
     apiPath: API_PATH,
     entityIdStrategy: new AutoIncrementIdStrategy(),

+ 2 - 1
server/src/config/email/email-options.ts

@@ -53,6 +53,7 @@ export interface EmailGenerator<T extends string = any, E extends VendureEvent =
     generate(
         subject: string,
         body: string,
-        context: EmailContext<T, E>,
+        templateContext: any,
+        emailContext: EmailContext<T, E>,
     ): GeneratedEmailContext<T, E> | Promise<GeneratedEmailContext<T, E>>;
 }

+ 6 - 1
server/src/config/email/noop-email-generator.ts

@@ -6,7 +6,12 @@ import { EmailGenerator } from './email-options';
  * Simply passes through the subject and template content without modification.
  */
 export class NoopEmailGenerator implements EmailGenerator {
-    generate(subject: string, template: string, context: EmailContext): GeneratedEmailContext {
+    generate(
+        subject: string,
+        template: string,
+        templateContext: any,
+        context: EmailContext,
+    ): GeneratedEmailContext {
         return new GeneratedEmailContext(context, subject, template);
     }
 }

+ 11 - 0
server/src/config/vendure-config.ts

@@ -65,6 +65,17 @@ export interface AuthOptions {
      * [zeit/ms](https://github.com/zeit/ms.js).  Eg: 60, "2 days", "10h", "7d"
      */
     sessionDuration?: string | number;
+    /**
+     * Determines whether new User accounts require verification of their email address.
+     */
+    requireVerification?: boolean;
+    /**
+     * Sets the length of time that a verification token is valid for.
+     *
+     * Expressed as a string describing a time span
+     * [zeit/ms](https://github.com/zeit/ms.js).  Eg: 60, "2 days", "10h", "7d"
+     */
+    verificationTokenDuration?: string | number;
 }
 
 export interface OrderProcessOptions<T extends string> {

+ 26 - 1
server/src/email/default-email-types.ts

@@ -4,9 +4,10 @@ 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';
 
-export type DefaultEmailType = 'order-confirmation';
+export type DefaultEmailType = 'order-confirmation' | 'email-verification';
 
 export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
     'order-confirmation': configEmailType({
@@ -36,4 +37,28 @@ export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
             },
         },
     }),
+    'email-verification': configEmailType({
+        triggerEvent: AccountRegistrationEvent,
+        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 }),
+                    subject: `Please verify your email address`,
+                    templatePath: path.join(
+                        __dirname,
+                        'templates',
+                        'email-verification',
+                        'email-verification.hbs',
+                    ),
+                },
+            },
+        },
+    }),
 };

+ 6 - 1
server/src/email/email.module.ts

@@ -56,7 +56,12 @@ export class EmailModule implements OnModuleInit {
                 type,
                 emailContext,
             );
-            const generatedEmailContext = await generator.generate(subject, body, templateContext);
+            const generatedEmailContext = await generator.generate(
+                subject,
+                body,
+                templateContext,
+                emailContext,
+            );
             await this.emailSender.send(generatedEmailContext, transport);
         }
     }

+ 9 - 4
server/src/email/handlebars-mjml-generator.ts

@@ -18,13 +18,18 @@ export class HandlebarsMjmlGenerator implements EmailGenerator {
         this.registerHelpers();
     }
 
-    generate(subject: string, template: string, context: EmailContext): GeneratedEmailContext {
+    generate(
+        subject: string,
+        template: string,
+        templateContext: any,
+        emailContext: EmailContext,
+    ): GeneratedEmailContext {
         const compiledTemplate = Handlebars.compile(template);
         const compiledSubject = Handlebars.compile(subject);
-        const subjectResult = compiledSubject(context);
-        const mjml = compiledTemplate(context);
+        const subjectResult = compiledSubject(templateContext);
+        const mjml = compiledTemplate(templateContext);
         const bodyResult = mjml2html(mjml);
-        return new GeneratedEmailContext(context, subjectResult, bodyResult.html);
+        return new GeneratedEmailContext(emailContext, subjectResult, bodyResult.html);
     }
 
     private registerPartials(partialsPath: string) {

+ 26 - 0
server/src/email/templates/email-verification/email-verification.hbs

@@ -0,0 +1,26 @@
+{{> header title="Verify Your Email Address" }}
+
+<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">
+            Thank you for creating an account with [company name]! Click the button below to complete the registration process:
+        </mj-text>
+
+        <mj-button font-family="Helvetica"
+                   background-color="#f45e43"
+                   color="white"
+                   href="http://localhost:4201/verify?token={{ user.verificationToken }}">
+            Verify Me!
+        </mj-button>
+
+    </mj-column>
+</mj-section>
+
+
+{{> footer }}

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

@@ -16,6 +16,12 @@ export class User extends VendureEntity implements HasCustomFields {
 
     @Column() passwordHash: string;
 
+    @Column({ default: false })
+    verified: boolean;
+
+    @Column({ type: 'varchar', nullable: true })
+    verificationToken: string | null;
+
     @ManyToMany(type => Role)
     @JoinTable()
     roles: Role[];

+ 13 - 0
server/src/event-bus/events/account-registration-event.ts

@@ -0,0 +1,13 @@
+import { RequestContext } from '../../api/common/request-context';
+import { User } from '../../entity/user/user.entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * This event is fired when a new user registers an account, either as a stand-alone signup or after
+ * placing an order.
+ */
+export class AccountRegistrationEvent extends VendureEvent {
+    constructor(public ctx: RequestContext, public user: User) {
+        super();
+    }
+}

+ 32 - 1
server/src/service/services/customer.service.ts

@@ -9,10 +9,13 @@ import {
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
+import { RequestContext } from '../../api/common/request-context';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound, normalizeEmailAddress } from '../../common/utils';
 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 { I18nError } from '../../i18n/i18n-error';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -26,6 +29,7 @@ export class CustomerService {
         @InjectConnection() private connection: Connection,
         private userService: UserService,
         private listQueryBuilder: ListQueryBuilder,
+        private eventBus: EventBus,
     ) {}
 
     findAll(options: ListQueryOptions<Customer> | undefined): Promise<PaginatedList<Customer>> {
@@ -75,6 +79,33 @@ export class CustomerService {
         return this.connection.getRepository(Customer).save(customer);
     }
 
+    async registerCustomerAccount(
+        ctx: RequestContext,
+        emailAddress: string,
+        password: string,
+    ): Promise<Customer> {
+        const customer = await this.createOrUpdate({ emailAddress });
+        const user = await this.userService.createCustomerUser(emailAddress, password);
+        customer.user = user;
+        await this.connection.getRepository(Customer).save(customer);
+        if (!user.verified) {
+            this.eventBus.publish(new AccountRegistrationEvent(ctx, user));
+        }
+        return customer;
+    }
+
+    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;
+        }
+    }
+
     async update(input: UpdateCustomerInput): Promise<Customer> {
         const customer = await getEntityOrThrow(this.connection, Customer, input.id);
         const updatedCustomer = patchEntity(customer, input);
@@ -85,7 +116,7 @@ export class CustomerService {
     /**
      * For guest checkouts, we assume that a matching email address is the same customer.
      */
-    async createOrUpdate(input: CreateCustomerInput): Promise<Customer> {
+    async createOrUpdate(input: Partial<CreateCustomerInput> & { emailAddress: string }): Promise<Customer> {
         input.emailAddress = normalizeEmailAddress(input.emailAddress);
         let customer: Customer;
         const existing = await this.connection.getRepository(Customer).findOne({

+ 15 - 1
server/src/service/services/user.service.ts

@@ -49,6 +49,20 @@ export class UserService {
         return this.connection.manager.save(user);
     }
 
+    async verifyUserByToken(verificationToken: string, password: string): Promise<User | undefined> {
+        const user = await this.connection.getRepository(User).findOne({
+            where: { verificationToken },
+        });
+        if (user && this.passwordCipher.check(password, user.passwordHash)) {
+            if (this.verifyVerificationToken(verificationToken)) {
+                user.verificationToken = null;
+                user.verified = true;
+                await this.connection.getRepository(User).save(user);
+                return user;
+            }
+        }
+    }
+
     /**
      * Generates a verification token which encodes the time of generation and concatenates it with a
      * random id.
@@ -64,7 +78,7 @@ export class UserService {
      * 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 {
+    private verifyVerificationToken(token: string): boolean {
         const duration = ms(this.configService.authOptions.verificationTokenDuration);
         const [generatedOn] = token.split('_');
         const dateString = Buffer.from(generatedOn, 'base64').toString();

+ 34 - 0
shared/generated-types.ts

@@ -575,6 +575,8 @@ export interface Mutation {
     createAssets: Asset[];
     login: LoginResult;
     logout: boolean;
+    registerCustomerAccount: boolean;
+    verifyCustomerEmailAddress: LoginResult;
     createChannel: Channel;
     updateChannel: Channel;
     createCountry: Country;
@@ -1409,6 +1411,14 @@ export interface LoginMutationArgs {
     password: string;
     rememberMe?: boolean | null;
 }
+export interface RegisterCustomerAccountMutationArgs {
+    emailAddress: string;
+    password: string;
+}
+export interface VerifyCustomerEmailAddressMutationArgs {
+    token: string;
+    password: string;
+}
 export interface CreateChannelMutationArgs {
     input: CreateChannelInput;
 }
@@ -3606,6 +3616,8 @@ export namespace MutationResolvers {
         createAssets?: CreateAssetsResolver<Asset[], any, Context>;
         login?: LoginResolver<LoginResult, any, Context>;
         logout?: LogoutResolver<boolean, any, Context>;
+        registerCustomerAccount?: RegisterCustomerAccountResolver<boolean, any, Context>;
+        verifyCustomerEmailAddress?: VerifyCustomerEmailAddressResolver<LoginResult, any, Context>;
         createChannel?: CreateChannelResolver<Channel, any, Context>;
         updateChannel?: UpdateChannelResolver<Channel, any, Context>;
         createCountry?: CreateCountryResolver<Country, any, Context>;
@@ -3719,6 +3731,28 @@ export namespace MutationResolvers {
     }
 
     export type LogoutResolver<R = boolean, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type RegisterCustomerAccountResolver<R = boolean, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        RegisterCustomerAccountArgs
+    >;
+    export interface RegisterCustomerAccountArgs {
+        emailAddress: string;
+        password: string;
+    }
+
+    export type VerifyCustomerEmailAddressResolver<R = LoginResult, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        VerifyCustomerEmailAddressArgs
+    >;
+    export interface VerifyCustomerEmailAddressArgs {
+        token: string;
+        password: string;
+    }
+
     export type CreateChannelResolver<R = Channel, Parent = any, Context = any> = Resolver<
         R,
         Parent,

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff