Browse Source

feat(core): Rework User/auth implementation to enable 3rd party auth

Relates to #215. In this commit we are adding a new AuthenticationMethod entity, which stores authentication information for a given auth provider. The default username/password strategy is known as the "Native" strategy.

This refactor does not add any new functionality, but sets up support for external auth providers whilst making sure all existing e2e tests pass.

BREAKING CHANGE: A new `AuthenticationMethod` entity has been added, with a one-to-many relation to the existing User entities. Several properties that were formerly part of the User entity have now moved to the `AuthenticationMethod` entity. Upgrading with therefore require a careful database migration to ensure that no data is lost. On release, a migration script will be provided for this.
Michael Bromley 5 years ago
parent
commit
f12b96f57e

+ 7 - 0
packages/common/src/generated-shop-types.ts

@@ -1341,6 +1341,7 @@ export type Mutation = {
     addPaymentToOrder?: Maybe<Order>;
     setCustomerForOrder?: Maybe<Order>;
     login: LoginResult;
+    authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /**
      * Regenerate and send a verification token for a new Customer registration. Only
@@ -1430,6 +1431,12 @@ export type MutationLoginArgs = {
     rememberMe?: Maybe<Scalars['Boolean']>;
 };
 
+export type MutationAuthenticateArgs = {
+    method: Scalars['String'];
+    data: Scalars['JSON'];
+    rememberMe?: Maybe<Scalars['Boolean']>;
+};
+
 export type MutationRefreshCustomerVerificationArgs = {
     emailAddress: Scalars['String'];
 };

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

@@ -1341,6 +1341,7 @@ export type Mutation = {
     addPaymentToOrder?: Maybe<Order>;
     setCustomerForOrder?: Maybe<Order>;
     login: LoginResult;
+    authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /**
      * Regenerate and send a verification token for a new Customer registration. Only
@@ -1430,6 +1431,12 @@ export type MutationLoginArgs = {
     rememberMe?: Maybe<Scalars['Boolean']>;
 };
 
+export type MutationAuthenticateArgs = {
+    method: Scalars['String'];
+    data: Scalars['JSON'];
+    rememberMe?: Maybe<Scalars['Boolean']>;
+};
+
 export type MutationRefreshCustomerVerificationArgs = {
     emailAddress: Scalars['String'];
 };

+ 10 - 5
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -150,7 +150,7 @@ describe('Shop auth & accounts', () => {
         it('issues a new token if attempting to register a second time', async () => {
             const sendEmail = new Promise<string>(resolve => {
                 sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
-                    resolve(event.user.verificationToken!);
+                    resolve(event.user.getNativeAuthenticationMethod().verificationToken!);
                 });
             });
             const input: RegisterCustomerInput = {
@@ -174,7 +174,7 @@ describe('Shop auth & accounts', () => {
         it('refreshCustomerVerification issues a new token', async () => {
             const sendEmail = new Promise<string>(resolve => {
                 sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
-                    resolve(event.user.verificationToken!);
+                    resolve(event.user.getNativeAuthenticationMethod().verificationToken!);
                 });
             });
             const result = await shopClient.query<RefreshToken.Mutation, RefreshToken.Variables>(
@@ -834,7 +834,7 @@ describe('Updating email address without email verification', () => {
 function getVerificationTokenPromise(): Promise<string> {
     return new Promise<any>(resolve => {
         sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
-            resolve(event.user.verificationToken);
+            resolve(event.user.getNativeAuthenticationMethod().verificationToken);
         });
     });
 }
@@ -842,7 +842,7 @@ function getVerificationTokenPromise(): Promise<string> {
 function getPasswordResetTokenPromise(): Promise<string> {
     return new Promise<any>(resolve => {
         sendEmailFn.mockImplementation((event: PasswordResetEvent) => {
-            resolve(event.user.passwordResetToken);
+            resolve(event.user.getNativeAuthenticationMethod().passwordResetToken);
         });
     });
 }
@@ -853,7 +853,12 @@ function getEmailUpdateTokenPromise(): Promise<{
 }> {
     return new Promise(resolve => {
         sendEmailFn.mockImplementation((event: IdentifierChangeRequestEvent) => {
-            resolve(pick(event.user, ['identifierChangeToken', 'pendingIdentifier']));
+            resolve(
+                pick(event.user.getNativeAuthenticationMethod(), [
+                    'identifierChangeToken',
+                    'pendingIdentifier',
+                ]),
+            );
         });
     });
 }

+ 1 - 3
packages/core/src/api/middleware/auth-guard.ts

@@ -1,9 +1,7 @@
 import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
-import { GqlExecutionContext } from '@nestjs/graphql';
 import { Permission } from '@vendure/common/lib/generated-types';
 import { Request, Response } from 'express';
-import { GraphQLResolveInfo } from 'graphql';
 
 import { ForbiddenError } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
@@ -11,7 +9,7 @@ import { Session } from '../../entity/session/session.entity';
 import { AuthService } from '../../service/services/auth.service';
 import { extractAuthToken } from '../common/extract-auth-token';
 import { parseContext } from '../common/parse-context';
-import { REQUEST_CONTEXT_KEY, RequestContextService } from '../common/request-context.service';
+import { RequestContextService, REQUEST_CONTEXT_KEY } from '../common/request-context.service';
 import { setAuthToken } from '../common/set-auth-token';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
 

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

@@ -1,3 +1,4 @@
+import { MutationAuthenticateArgs } from '@vendure/common/lib/generated-shop-types';
 import {
     CurrentUser,
     CurrentUserChannel,
@@ -8,6 +9,7 @@ import { unique } from '@vendure/common/lib/unique';
 import { Request, Response } from 'express';
 
 import { ForbiddenError, InternalServerError, UnauthorizedError } from '../../../common/error/errors';
+import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../../config/config.service';
 import { User } from '../../../entity/user/user.entity';
 import { getUserChannelsPermissions } from '../../../service/helpers/utils/get-user-channels-permissions';
@@ -38,7 +40,13 @@ export class BaseAuthResolver {
         res: Response,
         apiType: ApiType,
     ): Promise<LoginResult> {
-        return await this.createAuthenticatedSession(ctx, args, req, res, apiType);
+        return await this.createAuthenticatedSession(
+            ctx,
+            { method: NATIVE_AUTH_STRATEGY_NAME, data: args },
+            req,
+            res,
+            apiType,
+        );
     }
 
     async logout(ctx: RequestContext, req: Request, res: Response): Promise<boolean> {
@@ -80,12 +88,12 @@ export class BaseAuthResolver {
      */
     protected async createAuthenticatedSession(
         ctx: RequestContext,
-        args: MutationLoginArgs,
+        args: MutationAuthenticateArgs, // TODO: generate types
         req: Request,
         res: Response,
-        apiType?: ApiType,
+        apiType: ApiType,
     ) {
-        const session = await this.authService.authenticate(ctx, args.username, args.password);
+        const session = await this.authService.authenticate(ctx, apiType, args.method, args.data);
         if (apiType && apiType === 'admin') {
             const administrator = await this.administratorService.findOneByUserId(session.user.id);
             if (!administrator) {

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

@@ -1,6 +1,7 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     LoginResult,
+    MutationAuthenticateArgs,
     MutationLoginArgs,
     MutationRefreshCustomerVerificationArgs,
     MutationRegisterCustomerAccountArgs,
@@ -20,6 +21,7 @@ import {
     PasswordResetTokenError,
     VerificationTokenError,
 } from '../../../common/error/errors';
+import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../../config/config.service';
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { AuthService } from '../../../service/services/auth.service';
@@ -55,6 +57,23 @@ export class ShopAuthResolver extends BaseAuthResolver {
         return super.login(args, ctx, req, res, 'shop');
     }
 
+    @Mutation()
+    @Allow(Permission.Public)
+    authenticate(
+        @Args() args: MutationAuthenticateArgs,
+        @Ctx() ctx: RequestContext,
+        @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',
+        );
+    }
+
     @Mutation()
     @Allow(Permission.Public)
     logout(
@@ -97,12 +116,16 @@ export class ShopAuthResolver extends BaseAuthResolver {
             return super.createAuthenticatedSession(
                 ctx,
                 {
-                    username: customer.user.identifier,
-                    password: args.password,
+                    method: NATIVE_AUTH_STRATEGY_NAME,
+                    data: {
+                        username: customer.user.identifier,
+                        password: args.password,
+                    },
                     rememberMe: true,
                 },
                 req,
                 res,
+                'shop',
             );
         } else {
             throw new VerificationTokenError();
@@ -138,12 +161,16 @@ export class ShopAuthResolver extends BaseAuthResolver {
             return super.createAuthenticatedSession(
                 ctx,
                 {
-                    username: customer.user.identifier,
-                    password: args.password,
+                    method: NATIVE_AUTH_STRATEGY_NAME,
+                    data: {
+                        username: customer.user.identifier,
+                        password: args.password,
+                    },
                     rememberMe: true,
                 },
                 req,
                 res,
+                'shop',
             );
         } else {
             throw new PasswordResetTokenError();

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

@@ -32,6 +32,7 @@ type Mutation {
     addPaymentToOrder(input: PaymentInput!): Order
     setCustomerForOrder(input: CreateCustomerInput!): Order
     login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
+    authenticate(method: String!, data: JSON!, 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!

+ 3 - 0
packages/core/src/app.module.ts

@@ -118,11 +118,14 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat
             assetPreviewStrategy,
             assetStorageStrategy,
         } = this.configService.assetOptions;
+        const { adminAuthenticationStrategy, shopAuthenticationStrategy } = this.configService.authOptions;
         const { taxCalculationStrategy, taxZoneStrategy } = this.configService.taxOptions;
         const { jobQueueStrategy } = this.configService.jobQueueOptions;
         const { mergeStrategy, priceCalculationStrategy } = this.configService.orderOptions;
         const { entityIdStrategy } = this.configService;
         return [
+            ...adminAuthenticationStrategy,
+            ...shopAuthenticationStrategy,
             assetNamingStrategy,
             assetPreviewStrategy,
             assetStorageStrategy,

+ 9 - 0
packages/core/src/config/auth/authentication-strategy.ts

@@ -0,0 +1,9 @@
+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 {
+    readonly name: string;
+    authenticate(ctx: RequestContext, data: Data): Promise<User | false>;
+    deauthenticate(user: User): Promise<void>;
+}

+ 80 - 0
packages/core/src/config/auth/native-authentication-strategy.ts

@@ -0,0 +1,80 @@
+import { ExecutionContext } from '@nestjs/common';
+import { ID } from '@vendure/common/lib/shared-types';
+import { Connection } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { UnauthorizedError } from '../../common/error/errors';
+import { Injector } from '../../common/injector';
+import { AuthenticationMethod } from '../../entity/authentication-method/authentication-method.entity';
+import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
+import { User } from '../../entity/user/user.entity';
+import { PasswordCiper } from '../../service/helpers/password-cipher/password-ciper';
+
+import { AuthenticationStrategy } from './authentication-strategy';
+
+export interface NativeAuthenticationData {
+    username: string;
+    password: string;
+}
+
+export const NATIVE_AUTH_STRATEGY_NAME = 'native';
+
+export class NativeAuthenticationStrategy implements AuthenticationStrategy<NativeAuthenticationData> {
+    readonly name = NATIVE_AUTH_STRATEGY_NAME;
+
+    private connection: Connection;
+    private passwordCipher: PasswordCiper;
+
+    init(injector: Injector) {
+        this.connection = injector.getConnection();
+        this.passwordCipher = injector.get(PasswordCiper);
+    }
+
+    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);
+        if (!passwordMatch) {
+            return false;
+        }
+        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 },
+            relations: ['roles', 'roles.channels'],
+        });
+        if (!user) {
+            throw new UnauthorizedError();
+        }
+        return user;
+    }
+
+    /**
+     * Verify the provided password against the one we have for the given user.
+     */
+    async verifyUserPassword(userId: ID, password: string): Promise<boolean> {
+        const user = await this.connection.getRepository(User).findOne(userId, {
+            relations: ['authenticationMethods'],
+        });
+        if (!user) {
+            return false;
+        }
+        const nativeAuthMethod = user.getNativeAuthenticationMethod();
+        const pw =
+            (
+                await this.connection.getRepository(NativeAuthenticationMethod).findOne(nativeAuthMethod.id, {
+                    select: ['passwordHash'],
+                })
+            )?.passwordHash ?? '';
+        const passwordMatches = await this.passwordCipher.check(password, pw);
+        if (!passwordMatches) {
+            return false;
+        }
+        return true;
+    }
+}

+ 3 - 0
packages/core/src/config/default-config.ts

@@ -12,6 +12,7 @@ import { InMemoryJobQueueStrategy } from '../job-queue/in-memory-job-queue-strat
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
+import { NativeAuthenticationStrategy } from './auth/native-authentication-strategy';
 import { defaultCollectionFilters } from './collection/default-collection-filters';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { DefaultLogger } from './logger/default-logger';
@@ -66,6 +67,8 @@ export const defaultConfig: RuntimeVendureConfig = {
             identifier: SUPER_ADMIN_USER_IDENTIFIER,
             password: SUPER_ADMIN_USER_PASSWORD,
         },
+        shopAuthenticationStrategy: [new NativeAuthenticationStrategy()],
+        adminAuthenticationStrategy: [new NativeAuthenticationStrategy()],
     },
     catalogOptions: {
         collectionFilters: defaultCollectionFilters,

+ 3 - 0
packages/core/src/config/vendure-config.ts

@@ -15,6 +15,7 @@ import { OrderState } from '../service/helpers/order-state-machine/order-state';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
+import { AuthenticationStrategy } from './auth/authentication-strategy';
 import { CollectionFilter } from './collection/collection-filter';
 import { CustomFields } from './custom-field/custom-field-types';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
@@ -223,6 +224,8 @@ export interface AuthOptions {
      * Configures the credentials to be used to create a superadmin
      */
     superadminCredentials?: SuperadminCredentials;
+    shopAuthenticationStrategy?: AuthenticationStrategy[];
+    adminAuthenticationStrategy?: AuthenticationStrategy[];
 }
 
 /**

+ 14 - 0
packages/core/src/entity/authentication-method/authentication-method.entity.ts

@@ -0,0 +1,14 @@
+import { Entity, ManyToOne, TableInheritance } from 'typeorm';
+
+import { VendureEntity } from '../base/base.entity';
+import { User } from '../user/user.entity';
+
+@Entity()
+@TableInheritance({ column: { type: 'varchar', name: 'type' } })
+export abstract class AuthenticationMethod extends VendureEntity {
+    @ManyToOne(
+        type => User,
+        user => user.authenticationMethods,
+    )
+    user: User;
+}

+ 20 - 0
packages/core/src/entity/authentication-method/external-authentication-method.entity.ts

@@ -0,0 +1,20 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { ChildEntity, Column } from 'typeorm';
+
+import { AuthenticationMethod } from './authentication-method.entity';
+
+@ChildEntity()
+export class ExternalAuthenticationMethod extends AuthenticationMethod {
+    constructor(input: DeepPartial<ExternalAuthenticationMethod>) {
+        super(input);
+    }
+
+    @Column()
+    provider: string;
+
+    @Column()
+    externalIdentifier: string;
+
+    @Column('simple-json')
+    metadata: any;
+}

+ 39 - 0
packages/core/src/entity/authentication-method/native-authentication-method.entity.ts

@@ -0,0 +1,39 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { ChildEntity, Column } from 'typeorm';
+
+import { AuthenticationMethod } from './authentication-method.entity';
+
+@ChildEntity()
+export class NativeAuthenticationMethod extends AuthenticationMethod {
+    constructor(input?: DeepPartial<NativeAuthenticationMethod>) {
+        super(input);
+    }
+
+    @Column()
+    identifier: string;
+
+    @Column({ select: false }) passwordHash: string;
+
+    @Column({ type: 'varchar', nullable: true })
+    verificationToken: string | null;
+
+    @Column({ type: 'varchar', nullable: true })
+    passwordResetToken: string | null;
+
+    /**
+     * @description
+     * A token issued when a User requests to change their identifier (typically
+     * an email address)
+     */
+    @Column({ type: 'varchar', nullable: true })
+    identifierChangeToken: string | null;
+
+    /**
+     * @description
+     * When a request has been made to change the User's identifier, the new identifier
+     * will be stored here until it has been verified, after which it will
+     * replace the current value of the `identifier` field.
+     */
+    @Column({ type: 'varchar', nullable: true })
+    pendingIdentifier: string | null;
+}

+ 6 - 0
packages/core/src/entity/entities.ts

@@ -1,6 +1,9 @@
 import { Address } from './address/address.entity';
 import { Administrator } from './administrator/administrator.entity';
 import { Asset } from './asset/asset.entity';
+import { AuthenticationMethod } from './authentication-method/authentication-method.entity';
+import { ExternalAuthenticationMethod } from './authentication-method/external-authentication-method.entity';
+import { NativeAuthenticationMethod } from './authentication-method/native-authentication-method.entity';
 import { Channel } from './channel/channel.entity';
 import { CollectionAsset } from './collection/collection-asset.entity';
 import { CollectionTranslation } from './collection/collection-translation.entity';
@@ -59,6 +62,7 @@ export const coreEntitiesMap = {
     AnonymousSession,
     Asset,
     AuthenticatedSession,
+    AuthenticationMethod,
     Cancellation,
     Channel,
     Collection,
@@ -69,6 +73,7 @@ export const coreEntitiesMap = {
     Customer,
     CustomerGroup,
     CustomerHistoryEntry,
+    ExternalAuthenticationMethod,
     Facet,
     FacetTranslation,
     FacetValue,
@@ -76,6 +81,7 @@ export const coreEntitiesMap = {
     Fulfillment,
     GlobalSettings,
     HistoryEntry,
+    NativeAuthenticationMethod,
     Order,
     OrderHistoryEntry,
     OrderItem,

+ 22 - 25
packages/core/src/entity/user/user.entity.ts

@@ -1,8 +1,11 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
+import { InternalServerError } from '../../common/error/errors';
 import { SoftDeletable } from '../../common/types/common-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
+import { AuthenticationMethod } from '../authentication-method/authentication-method.entity';
+import { NativeAuthenticationMethod } from '../authentication-method/native-authentication-method.entity';
 import { VendureEntity } from '../base/base.entity';
 import { CustomUserFields } from '../custom-entity-fields';
 import { Role } from '../role/role.entity';
@@ -26,34 +29,15 @@ export class User extends VendureEntity implements HasCustomFields, SoftDeletabl
     @Column()
     identifier: string;
 
-    @Column({ select: false }) passwordHash: string;
+    @OneToMany(
+        type => AuthenticationMethod,
+        method => method.user,
+    )
+    authenticationMethods: AuthenticationMethod[];
 
     @Column({ default: false })
     verified: boolean;
 
-    @Column({ type: 'varchar', nullable: true })
-    verificationToken: string | null;
-
-    @Column({ type: 'varchar', nullable: true })
-    passwordResetToken: string | null;
-
-    /**
-     * @description
-     * A token issued when a User requests to change their identifier (typically
-     * an email address)
-     */
-    @Column({ type: 'varchar', nullable: true })
-    identifierChangeToken: string | null;
-
-    /**
-     * @description
-     * When a request has been made to change the User's identifier, the new identifier
-     * will be stored here until it has been verified, after which it will
-     * replace the current value of the `identifier` field.
-     */
-    @Column({ type: 'varchar', nullable: true })
-    pendingIdentifier: string | null;
-
     @ManyToMany(type => Role)
     @JoinTable()
     roles: Role[];
@@ -63,4 +47,17 @@ export class User extends VendureEntity implements HasCustomFields, SoftDeletabl
 
     @Column(type => CustomUserFields)
     customFields: CustomUserFields;
+
+    getNativeAuthenticationMethod(): NativeAuthenticationMethod {
+        if (!this.authenticationMethods) {
+            throw new InternalServerError('error.user-authentication-methods-not-loaded');
+        }
+        const match = this.authenticationMethods.find(
+            (m): m is NativeAuthenticationMethod => m instanceof NativeAuthenticationMethod,
+        );
+        if (!match) {
+            throw new InternalServerError('error.native-authentication-methods-not-found');
+        }
+        return match;
+    }
 }

+ 7 - 1
packages/core/src/service/services/administrator.service.ts

@@ -8,6 +8,7 @@ import { EntityNotFoundError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { ConfigService } from '../../config';
 import { Administrator } from '../../entity/administrator/administrator.entity';
+import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
 import { User } from '../../entity/user/user.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { PasswordCiper } from '../helpers/password-cipher/password-ciper';
@@ -75,7 +76,12 @@ export class AdministratorService {
         await this.connection.manager.save(administrator, { reload: false });
 
         if (input.password) {
-            administrator.user.passwordHash = await this.passwordCipher.hash(input.password);
+            const user = await this.userService.getUserById(administrator.user.id);
+            if (user) {
+                const nativeAuthMethod = user.getNativeAuthenticationMethod();
+                nativeAuthMethod.passwordHash = await this.passwordCipher.hash(input.password);
+                await this.connection.manager.save(nativeAuthMethod);
+            }
         }
         if (input.roleIds) {
             administrator.user.roles = [];

+ 40 - 22
packages/core/src/service/services/auth.service.ts

@@ -5,8 +5,14 @@ import crypto from 'crypto';
 import ms from 'ms';
 import { Connection } from 'typeorm';
 
+import { ApiType } from '../../api/common/get-api-type';
 import { RequestContext } from '../../api/common/request-context';
-import { NotVerifiedError, UnauthorizedError } from '../../common/error/errors';
+import { InternalServerError, NotVerifiedError, UnauthorizedError } from '../../common/error/errors';
+import { AuthenticationStrategy } from '../../config/auth/authentication-strategy';
+import {
+    NativeAuthenticationStrategy,
+    NATIVE_AUTH_STRATEGY_NAME,
+} from '../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../config/config.service';
 import { Order } from '../../entity/order/order.entity';
 import { AnonymousSession } from '../../entity/session/anonymous-session.entity';
@@ -43,12 +49,17 @@ export class AuthService {
      */
     async authenticate(
         ctx: RequestContext,
-        identifier: string,
-        password: string,
+        apiType: ApiType,
+        authenticationMethod: string,
+        authenticationData: any,
     ): Promise<AuthenticatedSession> {
-        this.eventBus.publish(new AttemptedLoginEvent(ctx, identifier));
-        const user = await this.getUserFromIdentifier(identifier);
-        await this.verifyUserPassword(user.id, password);
+        this.eventBus.publish(new AttemptedLoginEvent(ctx, authenticationMethod));
+        const authenticationStrategy = this.getAuthenticationStrategy(apiType, authenticationMethod);
+        const user = await authenticationStrategy.authenticate(ctx, authenticationData);
+        if (!user) {
+            throw new UnauthorizedError();
+        }
+
         if (this.configService.authOptions.requireVerification && !user.verified) {
             throw new NotVerifiedError();
         }
@@ -66,11 +77,11 @@ export class AuthService {
      * Verify the provided password against the one we have for the given user.
      */
     async verifyUserPassword(userId: ID, password: string): Promise<boolean> {
-        const user = await this.connection.getRepository(User).findOne(userId, { select: ['passwordHash'] });
-        if (!user) {
-            throw new UnauthorizedError();
-        }
-        const passwordMatches = await this.passwordCipher.check(password, user.passwordHash);
+        const nativeAuthenticationStrategy = this.getAuthenticationStrategy(
+            'shop',
+            NATIVE_AUTH_STRATEGY_NAME,
+        );
+        const passwordMatches = await nativeAuthenticationStrategy.verifyUserPassword(userId, password);
         if (!passwordMatches) {
             throw new UnauthorizedError();
         }
@@ -175,17 +186,6 @@ export class AuthService {
         });
     }
 
-    private async getUserFromIdentifier(identifier: string): Promise<User> {
-        const user = await this.connection.getRepository(User).findOne({
-            where: { identifier },
-            relations: ['roles', 'roles.channels'],
-        });
-        if (!user) {
-            throw new UnauthorizedError();
-        }
-        return user;
-    }
-
     /**
      * Generates a random session token.
      */
@@ -221,4 +221,22 @@ export class AuthService {
     private getExpiryDate(timeToExpireInMs: number): Date {
         return new Date(Date.now() + timeToExpireInMs);
     }
+
+    private getAuthenticationStrategy(
+        apiType: ApiType,
+        method: typeof NATIVE_AUTH_STRATEGY_NAME,
+    ): NativeAuthenticationStrategy;
+    private getAuthenticationStrategy(apiType: ApiType, method: string): AuthenticationStrategy;
+    private getAuthenticationStrategy(apiType: ApiType, method: string): AuthenticationStrategy {
+        const { authOptions } = this.configService;
+        const strategies =
+            apiType === 'admin'
+                ? authOptions.adminAuthenticationStrategy
+                : authOptions.shopAuthenticationStrategy;
+        const match = strategies.find(s => s.name === method);
+        if (!match) {
+            throw new InternalServerError('error.unrecognized-authentication-strategy', { name: method });
+        }
+        return match;
+    }
 }

+ 9 - 11
packages/core/src/service/services/customer.service.ts

@@ -85,8 +85,8 @@ export class CustomerService {
             .leftJoinAndSelect('country.translations', 'countryTranslation')
             .where('address.customer = :id', { id: customerId })
             .getMany()
-            .then((addresses) => {
-                addresses.forEach((address) => {
+            .then(addresses => {
+                addresses.forEach(address => {
                     address.country = translateDeep(address.country, ctx.languageCode);
                 });
                 return addresses;
@@ -127,11 +127,9 @@ export class CustomerService {
         customer.user = await this.userService.createCustomerUser(input.emailAddress, password);
 
         if (password && password !== '') {
-            if (customer.user.verificationToken) {
-                customer.user = await this.userService.verifyUserByToken(
-                    customer.user.verificationToken,
-                    password,
-                );
+            const verificationToken = customer.user.getNativeAuthenticationMethod().verificationToken;
+            if (verificationToken) {
+                customer.user = await this.userService.verifyUserByToken(verificationToken, password);
             }
         } else {
             this.eventBus.publish(new AccountRegistrationEvent(ctx, customer.user));
@@ -300,7 +298,7 @@ export class CustomerService {
             },
         });
         if (this.configService.authOptions.requireVerification) {
-            user.pendingIdentifier = newEmailAddress;
+            user.getNativeAuthenticationMethod().pendingIdentifier = newEmailAddress;
             await this.userService.setIdentifierChangeToken(user);
             this.eventBus.publish(new IdentifierChangeRequestEvent(ctx, user));
             return true;
@@ -519,8 +517,8 @@ export class CustomerService {
             .findOne(addressId, { relations: ['customer', 'customer.addresses'] });
         if (result) {
             const customerAddressIds = result.customer.addresses
-                .map((a) => a.id)
-                .filter((id) => !idsAreEqual(id, addressId)) as string[];
+                .map(a => a.id)
+                .filter(id => !idsAreEqual(id, addressId)) as string[];
 
             if (customerAddressIds.length) {
                 if (input.defaultBillingAddress === true) {
@@ -553,7 +551,7 @@ export class CustomerService {
             const customerAddresses = result.customer.addresses;
             if (1 < customerAddresses.length) {
                 const otherAddresses = customerAddresses
-                    .filter((address) => !idsAreEqual(address.id, addressToDelete.id))
+                    .filter(address => !idsAreEqual(address.id, addressToDelete.id))
                     .sort((a, b) => (a.id < b.id ? -1 : 1));
                 if (addressToDelete.defaultShippingAddress) {
                     otherAddresses[0].defaultShippingAddress = true;

+ 71 - 31
packages/core/src/service/services/user.service.ts

@@ -12,6 +12,7 @@ import {
     VerificationTokenExpiredError,
 } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
+import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
 import { User } from '../../entity/user/user.entity';
 import { PasswordCiper } from '../helpers/password-cipher/password-ciper';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -31,7 +32,7 @@ export class UserService {
 
     async getUserById(userId: ID): Promise<User | undefined> {
         return this.connection.getRepository(User).findOne(userId, {
-            relations: ['roles', 'roles.channels'],
+            relations: ['roles', 'roles.channels', 'authenticationMethods'],
         });
     }
 
@@ -40,35 +41,45 @@ export class UserService {
             where: {
                 identifier: emailAddress,
             },
-            relations: ['roles', 'roles.channels'],
+            relations: ['roles', 'roles.channels', 'authenticationMethods'],
         });
     }
 
     async createCustomerUser(identifier: string, password?: string): Promise<User> {
         const user = new User();
+        const authenticationMethod = new NativeAuthenticationMethod();
         if (this.configService.authOptions.requireVerification) {
-            user.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
+            authenticationMethod.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
             user.verified = false;
         } else {
             user.verified = true;
         }
         if (password) {
-            user.passwordHash = await this.passwordCipher.hash(password);
+            authenticationMethod.passwordHash = await this.passwordCipher.hash(password);
         } else {
-            user.passwordHash = '';
+            authenticationMethod.passwordHash = '';
         }
         user.identifier = identifier;
+        authenticationMethod.identifier = identifier;
+        await this.connection.manager.save(authenticationMethod);
         const customerRole = await this.roleService.getCustomerRole();
         user.roles = [customerRole];
+        user.authenticationMethods = [authenticationMethod];
         return this.connection.manager.save(user);
     }
 
     async createAdminUser(identifier: string, password: string): Promise<User> {
         const user = new User({
-            passwordHash: await this.passwordCipher.hash(password),
             identifier,
             verified: true,
         });
+        const authenticationMethod = await this.connection.manager.save(
+            new NativeAuthenticationMethod({
+                identifier,
+                passwordHash: await this.passwordCipher.hash(password),
+            }),
+        );
+        user.authenticationMethods = [authenticationMethod];
         return this.connection.manager.save(user);
     }
 
@@ -78,20 +89,27 @@ export class UserService {
     }
 
     async setVerificationToken(user: User): Promise<User> {
-        user.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
+        const nativeAuthMethod = user.getNativeAuthenticationMethod();
+        nativeAuthMethod.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
         user.verified = false;
+        await this.connection.manager.save(nativeAuthMethod);
         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 },
-        });
+        const user = await this.connection
+            .getRepository(User)
+            .createQueryBuilder('user')
+            .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethod')
+            .where('authenticationMethod.verificationToken = :verificationToken', { verificationToken })
+            .getOne();
         if (user) {
             if (this.verificationTokenGenerator.verifyVerificationToken(verificationToken)) {
-                user.passwordHash = await this.passwordCipher.hash(password);
-                user.verificationToken = null;
+                const nativeAuthMethod = user.getNativeAuthenticationMethod();
+                nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
+                nativeAuthMethod.verificationToken = null;
                 user.verified = true;
+                await this.connection.manager.save(nativeAuthMethod);
                 return this.connection.getRepository(User).save(user);
             } else {
                 throw new VerificationTokenExpiredError();
@@ -104,18 +122,25 @@ export class UserService {
         if (!user) {
             return;
         }
-        user.passwordResetToken = await this.verificationTokenGenerator.generateVerificationToken();
-        return this.connection.getRepository(User).save(user);
+        const nativeAuthMethod = user.getNativeAuthenticationMethod();
+        nativeAuthMethod.passwordResetToken = await this.verificationTokenGenerator.generateVerificationToken();
+        await this.connection.manager.save(nativeAuthMethod);
+        return user;
     }
 
     async resetPasswordByToken(passwordResetToken: string, password: string): Promise<User | undefined> {
-        const user = await this.connection.getRepository(User).findOne({
-            where: { passwordResetToken },
-        });
+        const user = await this.connection
+            .getRepository(User)
+            .createQueryBuilder('user')
+            .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethod')
+            .where('authenticationMethod.passwordResetToken = :passwordResetToken', { passwordResetToken })
+            .getOne();
         if (user) {
             if (this.verificationTokenGenerator.verifyVerificationToken(passwordResetToken)) {
-                user.passwordHash = await this.passwordCipher.hash(password);
-                user.passwordResetToken = null;
+                const nativeAuthMethod = user.getNativeAuthenticationMethod();
+                nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
+                nativeAuthMethod.passwordResetToken = null;
+                await this.connection.manager.save(nativeAuthMethod);
                 return this.connection.getRepository(User).save(user);
             } else {
                 throw new PasswordResetTokenExpiredError();
@@ -124,23 +149,31 @@ export class UserService {
     }
 
     async changeIdentifierByToken(token: string): Promise<{ user: User; oldIdentifier: string }> {
-        const user = await this.connection.getRepository(User).findOne({
-            where: { identifierChangeToken: token },
-        });
+        const user = await this.connection
+            .getRepository(User)
+            .createQueryBuilder('user')
+            .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethod')
+            .where('authenticationMethod.identifierChangeToken = :identifierChangeToken', {
+                identifierChangeToken: token,
+            })
+            .getOne();
         if (!user) {
             throw new IdentifierChangeTokenError();
         }
         if (!this.verificationTokenGenerator.verifyVerificationToken(token)) {
             throw new IdentifierChangeTokenExpiredError();
         }
-        const pendingIdentifier = user.pendingIdentifier;
+        const nativeAuthMethod = user.getNativeAuthenticationMethod();
+        const pendingIdentifier = nativeAuthMethod.pendingIdentifier;
         if (!pendingIdentifier) {
             throw new InternalServerError('error.pending-identifier-missing');
         }
         const oldIdentifier = user.identifier;
         user.identifier = pendingIdentifier;
-        user.identifierChangeToken = null;
-        user.pendingIdentifier = null;
+        nativeAuthMethod.identifier = pendingIdentifier;
+        nativeAuthMethod.identifierChangeToken = null;
+        nativeAuthMethod.pendingIdentifier = null;
+        await this.connection.manager.save(nativeAuthMethod, { reload: false });
         await this.connection.getRepository(User).save(user, { reload: false });
         return { user, oldIdentifier };
     }
@@ -148,21 +181,28 @@ export class UserService {
     async updatePassword(userId: ID, currentPassword: string, newPassword: string): Promise<boolean> {
         const user = await this.connection
             .getRepository(User)
-            .findOne(userId, { select: ['id', 'passwordHash'] });
+            .createQueryBuilder('user')
+            .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethods')
+            .addSelect('authenticationMethods.passwordHash')
+            .where('user.id = :id', { id: userId })
+            .getOne();
         if (!user) {
             throw new InternalServerError(`error.no-active-user-id`);
         }
-        const matches = await this.passwordCipher.check(currentPassword, user.passwordHash);
+        const nativeAuthMethod = user.getNativeAuthenticationMethod();
+        const matches = await this.passwordCipher.check(currentPassword, nativeAuthMethod.passwordHash);
         if (!matches) {
             throw new UnauthorizedError();
         }
-        user.passwordHash = await this.passwordCipher.hash(newPassword);
-        await this.connection.getRepository(User).save(user, { reload: false });
+        nativeAuthMethod.passwordHash = await this.passwordCipher.hash(newPassword);
+        await this.connection.manager.save(nativeAuthMethod, { reload: false });
         return true;
     }
 
     async setIdentifierChangeToken(user: User): Promise<User> {
-        user.identifierChangeToken = this.verificationTokenGenerator.generateVerificationToken();
-        return this.connection.manager.save(user);
+        const nativeAuthMethod = user.getNativeAuthenticationMethod();
+        nativeAuthMethod.identifierChangeToken = this.verificationTokenGenerator.generateVerificationToken();
+        await this.connection.manager.save(nativeAuthMethod);
+        return user;
     }
 }

+ 9 - 9
packages/dev-server/docker-compose.yml

@@ -9,16 +9,16 @@ services:
     ports:
       - '3306:3306'
   phpmyadmin:
-    image: 'bitnami/phpmyadmin:latest'
-    labels:
-      kompose.service.type: nodeport
+    image: 'phpmyadmin/phpmyadmin:latest'
+    container_name: phpmyadmin
+    environment:
+      - PMA_HOST=mariadb
+      - PMA_USER=root
+    restart: always
     ports:
-      - '80:80'
-      - '443:443'
-    depends_on:
-      - mariadb
+      - 8080:80
     volumes:
-      - 'phpmyadmin_data:/bitnami'
+      - /sessions
   postgres:
     image: postgres:12.3
     restart: always
@@ -39,7 +39,7 @@ services:
       PGADMIN_DEFAULT_PASSWORD: secret
       PGADMIN_LISTEN_PORT: 80
     ports:
-      - "8080:80"
+      - "8081:80"
     volumes:
       - pgadmin_data:/var/lib/pgadmin
     links:

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