Browse Source

refactor(core): Dynamically generate `AuthenticationInput` type

Michael Bromley 5 years ago
parent
commit
5890e97020

+ 9 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -171,6 +171,10 @@ export type AssignProductsToChannelInput = {
   priceFactor?: Maybe<Scalars['Float']>;
 };
 
+export type AuthenticationInput = {
+  native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
    __typename?: 'BooleanCustomFieldConfig';
   name: Scalars['String'];
@@ -2372,6 +2376,11 @@ export type MutationUpdateZoneArgs = {
   input: UpdateZoneInput;
 };
 
+export type NativeAuthInput = {
+  username: Scalars['String'];
+  password: Scalars['String'];
+};
+
 export type NetworkStatus = {
    __typename?: 'NetworkStatus';
   inFlightRequests: Scalars['Int'];

+ 9 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -170,6 +170,10 @@ export type AssignProductsToChannelInput = {
     priceFactor?: Maybe<Scalars['Float']>;
 };
 
+export type AuthenticationInput = {
+    native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -2255,6 +2259,11 @@ export type MutationRemoveMembersFromZoneArgs = {
     memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthInput = {
+    username: Scalars['String'];
+    password: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };

+ 11 - 2
packages/common/src/generated-shop-types.ts

@@ -95,6 +95,10 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export type AuthenticationInput = {
+    native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -1340,6 +1344,7 @@ export type Mutation = {
     setOrderShippingMethod?: Maybe<Order>;
     addPaymentToOrder?: Maybe<Order>;
     setCustomerForOrder?: Maybe<Order>;
+    /** @deprecated Use `authenticate` mutation with the 'native' strategy instead. */
     login: LoginResult;
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
@@ -1432,8 +1437,7 @@ export type MutationLoginArgs = {
 };
 
 export type MutationAuthenticateArgs = {
-    method: Scalars['String'];
-    data: Scalars['JSON'];
+    input: AuthenticationInput;
     rememberMe?: Maybe<Scalars['Boolean']>;
 };
 
@@ -1489,6 +1493,11 @@ export type MutationResetPasswordArgs = {
     password: Scalars['String'];
 };
 
+export type NativeAuthInput = {
+    username: Scalars['String'];
+    password: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };

+ 9 - 0
packages/common/src/generated-types.ts

@@ -170,6 +170,10 @@ export type AssignProductsToChannelInput = {
   priceFactor?: Maybe<Scalars['Float']>;
 };
 
+export type AuthenticationInput = {
+  native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
    __typename?: 'BooleanCustomFieldConfig';
   name: Scalars['String'];
@@ -2337,6 +2341,11 @@ export type MutationRemoveMembersFromZoneArgs = {
   memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthInput = {
+  username: Scalars['String'];
+  password: Scalars['String'];
+};
+
 export type Node = {
   id: Scalars['ID'];
 };

+ 9 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -170,6 +170,10 @@ export type AssignProductsToChannelInput = {
     priceFactor?: Maybe<Scalars['Float']>;
 };
 
+export type AuthenticationInput = {
+    native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -2255,6 +2259,11 @@ export type MutationRemoveMembersFromZoneArgs = {
     memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthInput = {
+    username: Scalars['String'];
+    password: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };

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

@@ -95,6 +95,10 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export type AuthenticationInput = {
+    native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -1340,6 +1344,7 @@ export type Mutation = {
     setOrderShippingMethod?: Maybe<Order>;
     addPaymentToOrder?: Maybe<Order>;
     setCustomerForOrder?: Maybe<Order>;
+    /** @deprecated Use `authenticate` mutation with the 'native' strategy instead. */
     login: LoginResult;
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
@@ -1432,8 +1437,7 @@ export type MutationLoginArgs = {
 };
 
 export type MutationAuthenticateArgs = {
-    method: Scalars['String'];
-    data: Scalars['JSON'];
+    input: AuthenticationInput;
     rememberMe?: Maybe<Scalars['Boolean']>;
 };
 
@@ -1489,6 +1493,11 @@ export type MutationResetPasswordArgs = {
     password: Scalars['String'];
 };
 
+export type NativeAuthInput = {
+    username: Scalars['String'];
+    password: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };

+ 6 - 0
packages/core/src/api/config/configure-graphql-module.ts

@@ -20,6 +20,7 @@ import { AssetInterceptorPlugin } from '../middleware/asset-interceptor-plugin';
 import { IdCodecPlugin } from '../middleware/id-codec-plugin';
 import { TranslateErrorsPlugin } from '../middleware/translate-errors-plugin';
 
+import { generateAuthenticationTypes } from './generate-auth-types';
 import { generateListOptions } from './generate-list-options';
 import {
     addGraphQLCustomFields,
@@ -160,6 +161,10 @@ async function createGraphQLOptions(
         // See https://github.com/nestjs/graphql/issues/336
         const normalizedPaths = options.typePaths.map(p => p.split(path.sep).join('/'));
         const typeDefs = await typesLoader.mergeTypesByPaths(normalizedPaths);
+        const authStrategies =
+            apiType === 'shop'
+                ? configService.authOptions.shopAuthenticationStrategy
+                : configService.authOptions.adminAuthenticationStrategy;
         let schema = buildSchema(typeDefs);
 
         getPluginAPIExtensions(configService.plugins, apiType)
@@ -170,6 +175,7 @@ async function createGraphQLOptions(
         schema = addGraphQLCustomFields(schema, customFields, apiType === 'shop');
         schema = addServerConfigCustomFields(schema, customFields);
         schema = addOrderLineCustomFieldsInput(schema, customFields.OrderLine || []);
+        schema = generateAuthenticationTypes(schema, authStrategies);
 
         return printSchema(schema);
     }

+ 50 - 0
packages/core/src/api/config/generate-auth-types.ts

@@ -0,0 +1,50 @@
+import {
+    buildASTSchema,
+    GraphQLInputFieldConfigMap,
+    GraphQLInputObjectType,
+    GraphQLSchema,
+    isInputObjectType,
+} from 'graphql';
+import { mergeSchemas } from 'graphql-tools';
+
+import { InternalServerError } from '../../common/error/errors';
+import { AuthenticationStrategy } from '../../config/auth/authentication-strategy';
+
+/**
+ * This function is responsible for constructing the `AuthenticationInput` GraphQL input type.
+ * It does so based on the inputs defined by the configured AuthenticationStrategy defineInputType
+ * methods, dynamically building a mapped input type of the format:
+ *
+ *```
+ * {
+ *     [strategy_name]: strategy_input_type
+ * }
+ * ```
+ */
+export function generateAuthenticationTypes(
+    schema: GraphQLSchema,
+    authenticationStrategies: AuthenticationStrategy[],
+): GraphQLSchema {
+    const fields: GraphQLInputFieldConfigMap = {};
+    const strategySchemas: GraphQLSchema[] = [];
+    for (const strategy of authenticationStrategies) {
+        const inputSchema = buildASTSchema(strategy.defineInputType());
+
+        const inputType = Object.values(
+            inputSchema.getTypeMap(),
+        ).find((type): type is GraphQLInputObjectType => isInputObjectType(type));
+        if (!inputType) {
+            throw new InternalServerError(
+                `${strategy.constructor.name}.defineInputType() does not define a GraphQL Input type`,
+            );
+        }
+        fields[strategy.name] = { type: inputType };
+        strategySchemas.push(inputSchema);
+    }
+    const authenticationInput = new GraphQLInputObjectType({
+        name: 'AuthenticationInput',
+        fields,
+    });
+
+    return mergeSchemas({ schemas: [schema, ...strategySchemas, [authenticationInput]] });
+}

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

@@ -42,7 +42,9 @@ export class BaseAuthResolver {
     ): Promise<LoginResult> {
         return await this.createAuthenticatedSession(
             ctx,
-            { method: NATIVE_AUTH_STRATEGY_NAME, data: args },
+            {
+                input: { [NATIVE_AUTH_STRATEGY_NAME]: args },
+            },
             req,
             res,
             apiType,
@@ -88,12 +90,13 @@ export class BaseAuthResolver {
      */
     protected async createAuthenticatedSession(
         ctx: RequestContext,
-        args: MutationAuthenticateArgs, // TODO: generate types
+        args: MutationAuthenticateArgs,
         req: Request,
         res: Response,
         apiType: ApiType,
     ) {
-        const session = await this.authService.authenticate(ctx, apiType, args.method, args.data);
+        const [method, data] = Object.entries(args.input)[0];
+        const session = await this.authService.authenticate(ctx, apiType, method, data);
         if (apiType && apiType === 'admin') {
             const administrator = await this.administratorService.findOneByUserId(session.user.id);
             if (!administrator) {

+ 39 - 17
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -18,11 +18,13 @@ import { Request, Response } from 'express';
 
 import {
     ForbiddenError,
+    InternalServerError,
     PasswordResetTokenError,
     VerificationTokenError,
 } from '../../../common/error/errors';
 import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../../config/config.service';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
@@ -35,6 +37,8 @@ import { BaseAuthResolver } from '../base/base-auth.resolver';
 
 @Resolver()
 export class ShopAuthResolver extends BaseAuthResolver {
+    private nativeAuthStrategyIsConfigured = false;
+
     constructor(
         authService: AuthService,
         userService: UserService,
@@ -44,6 +48,9 @@ export class ShopAuthResolver extends BaseAuthResolver {
         protected historyService: HistoryService,
     ) {
         super(authService, userService, administratorService, configService);
+        this.nativeAuthStrategyIsConfigured = !!this.configService.authOptions.shopAuthenticationStrategy.find(
+            strategy => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
+        );
     }
 
     @Mutation()
@@ -54,6 +61,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ): Promise<LoginResult> {
+        this.requireNativeAuthStrategy();
         return super.login(args, ctx, req, res, 'shop');
     }
 
@@ -65,13 +73,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ): Promise<LoginResult> {
-        return super.login(
-            { username: args.data.username, password: args.data.password, rememberMe: args.rememberMe },
-            ctx,
-            req,
-            res,
-            'shop',
-        );
+        return this.createAuthenticatedSession(ctx, args, req, res, 'shop');
     }
 
     @Mutation()
@@ -96,6 +98,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationRegisterCustomerAccountArgs,
     ) {
+        this.requireNativeAuthStrategy();
         return this.customerService.registerCustomerAccount(ctx, args.input).then(() => true);
     }
 
@@ -107,6 +110,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ) {
+        this.requireNativeAuthStrategy();
         const customer = await this.customerService.verifyCustomerEmailAddress(
             ctx,
             args.token,
@@ -116,12 +120,12 @@ export class ShopAuthResolver extends BaseAuthResolver {
             return super.createAuthenticatedSession(
                 ctx,
                 {
-                    method: NATIVE_AUTH_STRATEGY_NAME,
-                    data: {
-                        username: customer.user.identifier,
-                        password: args.password,
+                    input: {
+                        [NATIVE_AUTH_STRATEGY_NAME]: {
+                            username: customer.user.identifier,
+                            password: args.password,
+                        },
                     },
-                    rememberMe: true,
                 },
                 req,
                 res,
@@ -138,6 +142,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationRefreshCustomerVerificationArgs,
     ) {
+        this.requireNativeAuthStrategy();
         return this.customerService.refreshVerificationToken(ctx, args.emailAddress).then(() => true);
     }
 
@@ -155,18 +160,19 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ) {
+        this.requireNativeAuthStrategy();
         const { token, password } = args;
         const customer = await this.customerService.resetPassword(ctx, token, password);
         if (customer && customer.user) {
             return super.createAuthenticatedSession(
                 ctx,
                 {
-                    method: NATIVE_AUTH_STRATEGY_NAME,
-                    data: {
-                        username: customer.user.identifier,
-                        password: args.password,
+                    input: {
+                        [NATIVE_AUTH_STRATEGY_NAME]: {
+                            username: customer.user.identifier,
+                            password: args.password,
+                        },
                     },
-                    rememberMe: true,
                 },
                 req,
                 res,
@@ -183,6 +189,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdateCustomerPasswordArgs,
     ): Promise<boolean> {
+        this.requireNativeAuthStrategy();
         const result = await super.updatePassword(ctx, args.currentPassword, args.newPassword);
         if (result && ctx.activeUserId) {
             const customer = await this.customerService.findOneByUserId(ctx.activeUserId);
@@ -204,6 +211,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationRequestUpdateCustomerEmailAddressArgs,
     ): Promise<boolean> {
+        this.requireNativeAuthStrategy();
         if (!ctx.activeUserId) {
             throw new ForbiddenError();
         }
@@ -217,6 +225,20 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdateCustomerEmailAddressArgs,
     ): Promise<boolean> {
+        this.requireNativeAuthStrategy();
         return this.customerService.updateEmailAddress(ctx, args.token);
     }
+
+    private requireNativeAuthStrategy() {
+        if (!this.nativeAuthStrategyIsConfigured) {
+            const authStrategyNames = this.configService.authOptions.shopAuthenticationStrategy
+                .map(s => s.name)
+                .join(', ');
+            const errorMessage =
+                'This GraphQL operation requires that the NativeAuthenticationStrategy be configured for the Shop API.\n' +
+                `Currently the following AuthenticationStrategies are enabled: ${authStrategyNames}`;
+            Logger.error(errorMessage);
+            throw new InternalServerError('error.');
+        }
+    }
 }

+ 5 - 2
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -31,8 +31,8 @@ type Mutation {
     setOrderShippingMethod(shippingMethodId: ID!): Order
     addPaymentToOrder(input: PaymentInput!): Order
     setCustomerForOrder(input: CreateCustomerInput!): Order
-    login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
-    authenticate(method: String!, data: JSON!, rememberMe: Boolean): LoginResult!
+    login(username: String!, password: String!, rememberMe: Boolean): LoginResult! @deprecated(reason: "Use `authenticate` mutation with the 'native' strategy instead.")
+    authenticate(input: AuthenticationInput!, rememberMe: Boolean): LoginResult!
     logout: Boolean!
     "Regenerate and send a verification token for a new Customer registration. Only applicable if `authOptions.requireVerification` is set to true."
     refreshCustomerVerification(emailAddress: String!): Boolean!
@@ -68,6 +68,9 @@ type Mutation {
     resetPassword(token: String!, password: String!): LoginResult!
 }
 
+# Populated at run-time
+input AuthenticationInput
+
 input RegisterCustomerInput {
     emailAddress: String!
     title: String

+ 51 - 1
packages/core/src/config/auth/authentication-strategy.ts

@@ -1,9 +1,59 @@
+import { DocumentNode } from 'graphql';
+
 import { RequestContext } from '../../api/common/request-context';
 import { InjectableStrategy } from '../../common/types/injectable-strategy';
 import { User } from '../../entity/user/user.entity';
 
 export interface AuthenticationStrategy<Data = unknown> extends InjectableStrategy {
+    /**
+     * @description
+     * The `name` property is used to create the `AuthenticationMethod` GraphQL enum
+     * used by the `authenticate` mutation.
+     */
     readonly name: string;
+
+    /**
+     * @description
+     * Defines the type of the GraphQL Input object expected by the `authenticate`
+     * mutation. The final input object will be a map, with the key being the name
+     * of the strategy.
+     *
+     * For example, given the following:
+     * ```TypeScript
+     * defineInputType() {
+     *   return gql`
+     *      input MyAuthInput {
+     *        token: String!
+     *      }
+     *   `;
+     * }
+     * ```
+     *
+     * assuming the strategy name is "my_auth", then the resulting call to `authenticate`
+     * would look like:
+     *
+     * ```GraphQL
+     * authenticate(input: {
+     *   my_auth: {
+     *     token: "foo"
+     *   }
+     * }) {
+     *   # ...
+     * }
+     * ```
+     */
+    defineInputType(): DocumentNode;
+
+    /**
+     * @description
+     * Used to authenticate a user with the authentication provider.
+     */
     authenticate(ctx: RequestContext, data: Data): Promise<User | false>;
-    deauthenticate(user: User): Promise<void>;
+
+    /**
+     * @description
+     * Called when a user logs out, and may perform any required tasks
+     * related to the user logging out.
+     */
+    onLogOut?(user: User): Promise<void>;
 }

+ 20 - 4
packages/core/src/config/auth/native-authentication-strategy.ts

@@ -1,5 +1,8 @@
 import { ExecutionContext } from '@nestjs/common';
 import { ID } from '@vendure/common/lib/shared-types';
+import { isObject } from '@vendure/common/lib/shared-utils';
+import { DocumentNode } from 'graphql';
+import gql from 'graphql-tag';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
@@ -30,6 +33,23 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
         this.passwordCipher = injector.get(PasswordCiper);
     }
 
+    /*validateData(data: unknown): data is NativeAuthenticationData {
+        return (
+            isObject(data) &&
+            typeof (data as any).username === 'string' &&
+            typeof (data as any).password === 'string'
+        );
+    }*/
+
+    defineInputType(): DocumentNode {
+        return gql`
+            input NativeAuthInput {
+                username: String!
+                password: String!
+            }
+        `;
+    }
+
     async authenticate(ctx: RequestContext, data: NativeAuthenticationData): Promise<User | false> {
         const user = await this.getUserFromIdentifier(data.username);
         const passwordMatch = await this.verifyUserPassword(user.id, data.password);
@@ -39,10 +59,6 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
         return user;
     }
 
-    deauthenticate(user: User): Promise<void> {
-        return Promise.resolve(undefined);
-    }
-
     private async getUserFromIdentifier(identifier: string): Promise<User> {
         const user = await this.connection.getRepository(User).findOne({
             where: { identifier },

+ 9 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -170,6 +170,10 @@ export type AssignProductsToChannelInput = {
     priceFactor?: Maybe<Scalars['Float']>;
 };
 
+export type AuthenticationInput = {
+    native?: Maybe<NativeAuthInput>;
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -2255,6 +2259,11 @@ export type MutationRemoveMembersFromZoneArgs = {
     memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthInput = {
+    username: Scalars['String'];
+    password: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };

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


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


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