Browse Source

feat(core): Implement configurable session caching

Relates to #394.

BREAKING CHANGE: The `RequestContext.session` object is no longer a `Session` entity. Instead it is a new type, `SerializedSession` which contains a subset of data pertaining to the current session. For example, if you have custom code which references `ctx.session.activeOrder` you will now get an error, since `activeOrder` does not exist on `SerializedSession`. Instead you would use `SerializedSession.activeOrderId` and then lookup the order in a separate query.

The reason for this change is to enable efficient session caching.
Michael Bromley 5 years ago
parent
commit
09a432d48c
24 changed files with 758 additions and 285 deletions
  1. 1 1
      packages/core/e2e/order.e2e-spec.ts
  2. 165 0
      packages/core/e2e/session-management.e2e-spec.ts
  3. 4 1
      packages/core/src/api/common/extract-session-token.ts
  4. 9 10
      packages/core/src/api/common/request-context.service.ts
  5. 7 32
      packages/core/src/api/common/request-context.ts
  6. 5 5
      packages/core/src/api/common/set-session-token.ts
  7. 20 20
      packages/core/src/api/middleware/auth-guard.ts
  8. 8 9
      packages/core/src/api/resolvers/base/base-auth.resolver.ts
  9. 11 14
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  10. 3 1
      packages/core/src/api/schema/admin-api/auth.api.graphql
  11. 3 1
      packages/core/src/api/schema/shop-api/shop.api.graphql
  12. 4 1
      packages/core/src/config/default-config.ts
  13. 3 0
      packages/core/src/config/index.ts
  14. 63 0
      packages/core/src/config/session-cache/in-memory-session-cache-strategy.ts
  15. 26 0
      packages/core/src/config/session-cache/noop-session-cache-strategy.ts
  16. 81 0
      packages/core/src/config/session-cache/session-cache-strategy.ts
  17. 22 3
      packages/core/src/config/vendure-config.ts
  18. 15 14
      packages/core/src/entity/product-variant/product-variant.entity.ts
  19. 6 2
      packages/core/src/entity/session/session.entity.ts
  20. 2 0
      packages/core/src/service/service.module.ts
  21. 17 147
      packages/core/src/service/services/auth.service.ts
  22. 23 23
      packages/core/src/service/services/collection.service.ts
  23. 259 0
      packages/core/src/service/services/session.service.ts
  24. 1 1
      packages/core/src/service/types/collection-messages.ts

+ 1 - 1
packages/core/e2e/order.e2e-spec.ts

@@ -45,7 +45,7 @@ import {
 } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
-import { addPaymentToOrder, proceedToArrangingPayment, sortById } from './utils/test-order-utils';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
 describe('Orders resolver', () => {
     const { server, adminClient, shopClient } = createTestEnvironment({

+ 165 - 0
packages/core/e2e/session-management.e2e-spec.ts

@@ -0,0 +1,165 @@
+/* tslint:disable:no-non-null-assertion */
+import { CachedSession, mergeConfig, SessionCacheStrategy } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '../../common/src/shared-constants';
+
+import { AttemptLogin, Me } from './graphql/generated-e2e-admin-types';
+import { ATTEMPT_LOGIN, ME } from './graphql/shared-definitions';
+
+const testSessionCache = new Map<string, CachedSession>();
+const getSpy = jest.fn();
+const setSpy = jest.fn();
+const clearSpy = jest.fn();
+const deleteSpy = jest.fn();
+
+class TestingSessionCacheStrategy implements SessionCacheStrategy {
+    clear() {
+        clearSpy();
+        testSessionCache.clear();
+    }
+
+    delete(sessionToken: string) {
+        deleteSpy(sessionToken);
+        testSessionCache.delete(sessionToken);
+    }
+
+    get(sessionToken: string) {
+        getSpy(sessionToken);
+        return testSessionCache.get(sessionToken);
+    }
+
+    set(session: CachedSession) {
+        setSpy(session);
+        testSessionCache.set(session.token, session);
+    }
+}
+
+describe('Session caching', () => {
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            authOptions: {
+                sessionCacheStrategy: new TestingSessionCacheStrategy(),
+                sessionCacheTTL: 2,
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('populates the cache on login', async () => {
+        setSpy.mockClear();
+        expect(setSpy.mock.calls.length).toBe(0);
+
+        await adminClient.query<AttemptLogin.Mutation, AttemptLogin.Variables>(ATTEMPT_LOGIN, {
+            username: SUPER_ADMIN_USER_IDENTIFIER,
+            password: SUPER_ADMIN_USER_PASSWORD,
+        });
+
+        expect(testSessionCache.size).toBe(1);
+        expect(setSpy.mock.calls.length).toBe(1);
+    });
+
+    it('takes user data from cache on next request', async () => {
+        getSpy.mockClear();
+        const { me } = await adminClient.query<Me.Query>(ME);
+
+        expect(getSpy.mock.calls.length).toBe(1);
+    });
+
+    it('sets fresh data after TTL expires', async () => {
+        setSpy.mockClear();
+
+        await adminClient.query<Me.Query>(ME);
+        expect(setSpy.mock.calls.length).toBe(0);
+
+        await adminClient.query<Me.Query>(ME);
+        expect(setSpy.mock.calls.length).toBe(0);
+
+        await pause(2000);
+
+        await adminClient.query<Me.Query>(ME);
+        expect(setSpy.mock.calls.length).toBe(1);
+    });
+
+    it('clears cache for that user on logout', async () => {
+        deleteSpy.mockClear();
+
+        await adminClient.query(
+            gql`
+                mutation {
+                    logout
+                }
+            `,
+        );
+
+        expect(testSessionCache.size).toBe(0);
+        expect(deleteSpy.mock.calls.length).toBe(1);
+    });
+});
+
+describe('Session expiry', () => {
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            authOptions: {
+                sessionDuration: '3s',
+                sessionCacheTTL: 1,
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('session does not expire with continued use', async () => {
+        await adminClient.asSuperAdmin();
+        await pause(1000);
+        await adminClient.query(ME);
+        await pause(1000);
+        await adminClient.query(ME);
+        await pause(1000);
+        await adminClient.query(ME);
+        await pause(1000);
+        await adminClient.query(ME);
+    }, 10000);
+
+    it('session expires when not used for longer than sessionDuration', async () => {
+        await adminClient.asSuperAdmin();
+        await pause(3000);
+        try {
+            await adminClient.query(ME);
+            fail('Should have thrown');
+        } catch (e) {
+            expect(e.message).toContain('You are not currently authorized to perform this action');
+        }
+    }, 10000);
+});
+
+function pause(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 4 - 1
packages/core/src/api/common/extract-auth-token.ts → packages/core/src/api/common/extract-session-token.ts

@@ -6,7 +6,10 @@ import { AuthOptions } from '../../config/vendure-config';
  * Get the session token from either the cookie or the Authorization header, depending
  * on the configured tokenMethod.
  */
-export function extractAuthToken(req: Request, tokenMethod: AuthOptions['tokenMethod']): string | undefined {
+export function extractSessionToken(
+    req: Request,
+    tokenMethod: AuthOptions['tokenMethod'],
+): string | undefined {
     if (tokenMethod === 'cookie') {
         if (req.session && req.session.token) {
             return req.session.token;

+ 9 - 10
packages/core/src/api/common/request-context.service.ts

@@ -5,10 +5,8 @@ import { GraphQLResolveInfo } from 'graphql';
 
 import { idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
+import { CachedSession, CachedSessionUser } from '../../config/session-cache/session-cache-strategy';
 import { Channel } from '../../entity/channel/channel.entity';
-import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
-import { Session } from '../../entity/session/session.entity';
-import { User } from '../../entity/user/user.entity';
 import { ChannelService } from '../../service/services/channel.service';
 
 import { getApiType } from './get-api-type';
@@ -30,7 +28,7 @@ export class RequestContextService {
         req: Request,
         info?: GraphQLResolveInfo,
         requiredPermissions?: Permission[],
-        session?: Session,
+        session?: CachedSession,
     ): Promise<RequestContext> {
         const channelToken = this.getChannelToken(req);
         const channel = this.channelService.getChannelFromToken(channelToken);
@@ -38,7 +36,7 @@ export class RequestContextService {
 
         const hasOwnerPermission = !!requiredPermissions && requiredPermissions.includes(Permission.Owner);
         const languageCode = this.getLanguageCode(req, channel);
-        const user = session && (session as AuthenticatedSession).user;
+        const user = session && session.user;
         const isAuthorized = this.userHasRequiredPermissionsOnChannel(requiredPermissions, channel, user);
         const authorizedAsOwnerOnly = !isAuthorized && hasOwnerPermission;
         const translationFn = (req as any).t;
@@ -76,15 +74,16 @@ export class RequestContextService {
     private userHasRequiredPermissionsOnChannel(
         permissions: Permission[] = [],
         channel?: Channel,
-        user?: User,
+        user?: CachedSessionUser,
     ): boolean {
         if (!user || !channel) {
             return false;
         }
-        const permissionsOnChannel = user.roles
-            .filter((role) => role.channels.find((c) => idsAreEqual(c.id, channel.id)))
-            .reduce((output, role) => [...output, ...role.permissions], [] as Permission[]);
-        return this.arraysIntersect(permissions, permissionsOnChannel);
+        const permissionsOnChannel = user.channelPermissions.find((c) => idsAreEqual(c.id, channel.id));
+        if (permissionsOnChannel) {
+            return this.arraysIntersect(permissionsOnChannel.permissions, permissions);
+        }
+        return false;
     }
 
     /**

+ 7 - 32
packages/core/src/api/common/request-context.ts

@@ -2,18 +2,13 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID, JsonCompatible } from '@vendure/common/lib/shared-types';
 import { TFunction } from 'i18next';
 
+import { CachedSession } from '../../config/session-cache/session-cache-strategy';
 import { Channel } from '../../entity/channel/channel.entity';
-import { AnonymousSession } from '../../entity/session/anonymous-session.entity';
-import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
-import { Session } from '../../entity/session/session.entity';
-import { User } from '../../entity/user/user.entity';
 
 import { ApiType } from './get-api-type';
 
 export type SerializedRequestContext = {
-    _session: JsonCompatible<Session> & {
-        user: JsonCompatible<User>;
-    };
+    _session: JsonCompatible<Required<CachedSession>>;
     _apiType: ApiType;
     _channel: JsonCompatible<Channel>;
     _languageCode: LanguageCode;
@@ -31,7 +26,7 @@ export type SerializedRequestContext = {
 export class RequestContext {
     private readonly _languageCode: LanguageCode;
     private readonly _channel: Channel;
-    private readonly _session?: Session;
+    private readonly _session?: CachedSession;
     private readonly _isAuthorized: boolean;
     private readonly _authorizedAsOwnerOnly: boolean;
     private readonly _translationFn: TFunction;
@@ -43,7 +38,7 @@ export class RequestContext {
     constructor(options: {
         apiType: ApiType;
         channel: Channel;
-        session?: Session;
+        session?: CachedSession;
         languageCode?: LanguageCode;
         isAuthorized: boolean;
         authorizedAsOwnerOnly: boolean;
@@ -65,22 +60,10 @@ export class RequestContext {
      * a JSON serialization - deserialization operation.
      */
     static deserialize(ctxObject: SerializedRequestContext): RequestContext {
-        let session: Session | undefined;
-        if (ctxObject._session) {
-            if (ctxObject._session.user) {
-                const user = new User(ctxObject._session.user);
-                session = new AuthenticatedSession({
-                    ...ctxObject._session,
-                    user,
-                });
-            } else {
-                session = new AnonymousSession(ctxObject._session);
-            }
-        }
         return new RequestContext({
             apiType: ctxObject._apiType,
             channel: new Channel(ctxObject._channel),
-            session,
+            session: ctxObject._session,
             languageCode: ctxObject._languageCode,
             isAuthorized: ctxObject._isAuthorized,
             authorizedAsOwnerOnly: ctxObject._authorizedAsOwnerOnly,
@@ -107,16 +90,12 @@ export class RequestContext {
         return this._languageCode;
     }
 
-    get session(): Session | undefined {
+    get session(): CachedSession | undefined {
         return this._session;
     }
 
     get activeUserId(): ID | undefined {
-        if (this.session) {
-            if (this.isAuthenticatedSession(this.session)) {
-                return this.session.user.id;
-            }
-        }
+        return this.session?.user?.id;
     }
 
     /**
@@ -147,8 +126,4 @@ export class RequestContext {
             return `Translation format error: ${e.message}). Original key: ${key}`;
         }
     }
-
-    private isAuthenticatedSession(session: Session): session is AuthenticatedSession {
-        return session.hasOwnProperty('user');
-    }
 }

+ 5 - 5
packages/core/src/api/common/set-auth-token.ts → packages/core/src/api/common/set-session-token.ts

@@ -7,22 +7,22 @@ import { AuthOptions } from '../../config/vendure-config';
  * Sets the authToken either as a cookie or as a response header, depending on the
  * config settings.
  */
-export function setAuthToken(options: {
-    authToken: string;
+export function setSessionToken(options: {
+    sessionToken: string;
     rememberMe: boolean;
     authOptions: Required<AuthOptions>;
     req: Request;
     res: Response;
 }) {
-    const { authToken, rememberMe, authOptions, req, res } = options;
+    const { sessionToken, rememberMe, authOptions, req, res } = options;
     if (authOptions.tokenMethod === 'cookie') {
         if (req.session) {
             if (rememberMe) {
                 req.sessionOptions.maxAge = ms('1y');
             }
-            req.session.token = authToken;
+            req.session.token = sessionToken;
         }
     } else {
-        res.set(authOptions.authTokenHeaderKey, authToken);
+        res.set(authOptions.authTokenHeaderKey, sessionToken);
     }
 }

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

@@ -5,12 +5,12 @@ import { Request, Response } from 'express';
 
 import { ForbiddenError } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
-import { Session } from '../../entity/session/session.entity';
-import { AuthService } from '../../service/services/auth.service';
-import { extractAuthToken } from '../common/extract-auth-token';
+import { CachedSession } from '../../config/session-cache/session-cache-strategy';
+import { SessionService } from '../../service/services/session.service';
+import { extractSessionToken } from '../common/extract-session-token';
 import { parseContext } from '../common/parse-context';
-import { RequestContextService, REQUEST_CONTEXT_KEY } from '../common/request-context.service';
-import { setAuthToken } from '../common/set-auth-token';
+import { REQUEST_CONTEXT_KEY, RequestContextService } from '../common/request-context.service';
+import { setSessionToken } from '../common/set-session-token';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
 
 /**
@@ -24,8 +24,8 @@ export class AuthGuard implements CanActivate {
     constructor(
         private reflector: Reflector,
         private configService: ConfigService,
-        private authService: AuthService,
         private requestContextService: RequestContextService,
+        private sessionService: SessionService,
     ) {}
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -54,35 +54,35 @@ export class AuthGuard implements CanActivate {
         req: Request,
         res: Response,
         hasOwnerPermission: boolean,
-    ): Promise<Session | undefined> {
-        const authToken = extractAuthToken(req, this.configService.authOptions.tokenMethod);
-        let session: Session | undefined;
-        if (authToken) {
-            session = await this.authService.validateSession(authToken);
-            if (session) {
-                return session;
+    ): Promise<CachedSession | undefined> {
+        const sessionToken = extractSessionToken(req, this.configService.authOptions.tokenMethod);
+        let serializedSession: CachedSession | undefined;
+        if (sessionToken) {
+            serializedSession = await this.sessionService.getSessionFromToken(sessionToken);
+            if (serializedSession) {
+                return serializedSession;
             }
             // if there is a token but it cannot be validated to a Session,
             // then the token is no longer valid and should be unset.
-            setAuthToken({
+            setSessionToken({
                 req,
                 res,
                 authOptions: this.configService.authOptions,
                 rememberMe: false,
-                authToken: '',
+                sessionToken: '',
             });
         }
 
-        if (hasOwnerPermission && !session) {
-            session = await this.authService.createAnonymousSession();
-            setAuthToken({
-                authToken: session.token,
+        if (hasOwnerPermission && !serializedSession) {
+            serializedSession = await this.sessionService.createAnonymousSession();
+            setSessionToken({
+                sessionToken: serializedSession.token,
                 rememberMe: true,
                 authOptions: this.configService.authOptions,
                 req,
                 res,
             });
         }
-        return session;
+        return serializedSession;
     }
 }

+ 8 - 9
packages/core/src/api/resolvers/base/base-auth.resolver.ts

@@ -1,11 +1,10 @@
-import { MutationAuthenticateArgs } from '@vendure/common/lib/generated-shop-types';
 import {
     CurrentUser,
     CurrentUserChannel,
     LoginResult,
+    MutationAuthenticateArgs,
     MutationLoginArgs,
 } from '@vendure/common/lib/generated-types';
-import { unique } from '@vendure/common/lib/unique';
 import { Request, Response } from 'express';
 
 import { ForbiddenError, InternalServerError, UnauthorizedError } from '../../../common/error/errors';
@@ -16,10 +15,10 @@ import { getUserChannelsPermissions } from '../../../service/helpers/utils/get-u
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { UserService } from '../../../service/services/user.service';
-import { extractAuthToken } from '../../common/extract-auth-token';
+import { extractSessionToken } from '../../common/extract-session-token';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
-import { setAuthToken } from '../../common/set-auth-token';
+import { setSessionToken } from '../../common/set-session-token';
 
 export class BaseAuthResolver {
     constructor(
@@ -50,17 +49,17 @@ export class BaseAuthResolver {
     }
 
     async logout(ctx: RequestContext, req: Request, res: Response): Promise<boolean> {
-        const token = extractAuthToken(req, this.configService.authOptions.tokenMethod);
+        const token = extractSessionToken(req, this.configService.authOptions.tokenMethod);
         if (!token) {
             return false;
         }
         await this.authService.destroyAuthenticatedSession(ctx, token);
-        setAuthToken({
+        setSessionToken({
             req,
             res,
             authOptions: this.configService.authOptions,
             rememberMe: false,
-            authToken: '',
+            sessionToken: '',
         });
         return true;
     }
@@ -101,12 +100,12 @@ export class BaseAuthResolver {
                 throw new UnauthorizedError();
             }
         }
-        setAuthToken({
+        setSessionToken({
             req,
             res,
             authOptions: this.configService.authOptions,
             rememberMe: args.rememberMe || false,
-            authToken: session.token,
+            sessionToken: session.token,
         });
         return {
             user: this.publiclyAccessibleUser(session.user),

+ 11 - 14
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -25,9 +25,9 @@ import { Country } from '../../../entity';
 import { Order } from '../../../entity/order/order.entity';
 import { CountryService } from '../../../service';
 import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
-import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
 import { OrderService } from '../../../service/services/order.service';
+import { SessionService } from '../../../service/services/session.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
@@ -37,7 +37,7 @@ export class ShopOrderResolver {
     constructor(
         private orderService: OrderService,
         private customerService: CustomerService,
-        private authService: AuthService,
+        private sessionService: SessionService,
         private countryService: CountryService,
     ) {}
 
@@ -291,13 +291,8 @@ export class ShopOrderResolver {
                         }
                     }
                 }
-                if (
-                    order.active === false &&
-                    ctx.session &&
-                    ctx.session.activeOrder &&
-                    ctx.session.activeOrder.id === sessionOrder.id
-                ) {
-                    await this.authService.unsetActiveOrder(ctx.session);
+                if (order.active === false && ctx.session?.activeOrderId === sessionOrder.id) {
+                    await this.sessionService.unsetActiveOrder(ctx.session);
                 }
                 return order;
             }
@@ -325,16 +320,18 @@ export class ShopOrderResolver {
         if (!ctx.session) {
             throw new InternalServerError(`error.no-active-session`);
         }
-        let order = ctx.session.activeOrder;
+        let order = ctx.session.activeOrderId
+            ? await this.orderService.findOne(ctx, ctx.session.activeOrderId)
+            : undefined;
         if (order && order.active === false) {
             // edge case where an inactive order may not have been
             // removed from the session, i.e. the regular process was interrupted
-            await this.authService.unsetActiveOrder(ctx.session);
-            order = null;
+            await this.sessionService.unsetActiveOrder(ctx.session);
+            order = undefined;
         }
         if (!order) {
             if (ctx.activeUserId) {
-                order = (await this.orderService.getActiveOrderForUser(ctx, ctx.activeUserId)) || null;
+                order = await this.orderService.getActiveOrderForUser(ctx, ctx.activeUserId);
             }
 
             if (!order && createIfNotExists) {
@@ -342,7 +339,7 @@ export class ShopOrderResolver {
             }
 
             if (order) {
-                await this.authService.setActiveOrder(ctx.session, order);
+                await this.sessionService.setActiveOrder(ctx.session, order);
             }
         }
         return order || undefined;

+ 3 - 1
packages/core/src/api/schema/admin-api/auth.api.graphql

@@ -3,7 +3,9 @@ type Query {
 }
 
 type Mutation {
-    login(username: String!, password: String!, rememberMe: Boolean): LoginResult! @deprecated(reason: "Use `authenticate` mutation with the 'native' strategy instead.")
+    "Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})`"
+    login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
+    "Authenticates the user using a named authentication strategy"
     authenticate(input: AuthenticationInput!, rememberMe: Boolean): LoginResult!
     logout: Boolean!
 }

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

@@ -35,7 +35,9 @@ type Mutation {
     setOrderShippingMethod(shippingMethodId: ID!): Order
     addPaymentToOrder(input: PaymentInput!): Order
     setCustomerForOrder(input: CreateCustomerInput!): Order
-    login(username: String!, password: String!, rememberMe: Boolean): LoginResult! @deprecated(reason: "Use `authenticate` mutation with the 'native' strategy instead.")
+    "Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})`"
+    login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
+    "Authenticates the user using a named authentication strategy"
     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."

+ 4 - 1
packages/core/src/config/default-config.ts

@@ -22,6 +22,7 @@ import { MergeOrdersStrategy } from './order/merge-orders-strategy';
 import { UseGuestStrategy } from './order/use-guest-strategy';
 import { defaultPromotionActions } from './promotion/default-promotion-actions';
 import { defaultPromotionConditions } from './promotion/default-promotion-conditions';
+import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-cache-strategy';
 import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
 import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
 import { DefaultTaxCalculationStrategy } from './tax/default-tax-calculation-strategy';
@@ -60,7 +61,9 @@ export const defaultConfig: RuntimeVendureConfig = {
         tokenMethod: 'cookie',
         sessionSecret: 'session-secret',
         authTokenHeaderKey: DEFAULT_AUTH_TOKEN_HEADER_KEY,
-        sessionDuration: '7d',
+        sessionDuration: '1y',
+        sessionCacheStrategy: new InMemorySessionCacheStrategy(),
+        sessionCacheTTL: 300,
         requireVerification: true,
         verificationTokenDuration: '7d',
         superadminCredentials: {

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

@@ -25,6 +25,9 @@ export * from './promotion/default-promotion-actions';
 export * from './promotion/default-promotion-conditions';
 export * from './promotion/promotion-action';
 export * from './promotion/promotion-condition';
+export * from './session-cache/session-cache-strategy';
+export * from './session-cache/noop-session-cache-strategy';
+export * from './session-cache/in-memory-session-cache-strategy';
 export * from './shipping-method/default-shipping-calculator';
 export * from './shipping-method/default-shipping-eligibility-checker';
 export * from './shipping-method/shipping-calculator';

+ 63 - 0
packages/core/src/config/session-cache/in-memory-session-cache-strategy.ts

@@ -0,0 +1,63 @@
+import { CachedSession, SessionCacheStrategy } from './session-cache-strategy';
+
+/**
+ * @description
+ * Caches session in memory, using a LRU cache implementation. Not suitable for
+ * multi-server setups since the cache will be local to each instance, reducing
+ * its effectiveness. By default the cache has a size of 1000, meaning that after
+ * 1000 sessions have been cached, any new sessions will cause the least-recently-used
+ * session to be evicted (removed) from the cache.
+ *
+ * The cache size can be configured by passing a different number to the constructor
+ * function.
+ *
+ * @docsCategory auth
+ */
+export class InMemorySessionCacheStrategy implements SessionCacheStrategy {
+    private readonly cache = new Map<string, CachedSession>();
+    private readonly cacheSize: number = 1000;
+
+    constructor(cacheSize?: number) {
+        if (cacheSize != null) {
+            if (cacheSize < 1) {
+                throw new Error(`cacheSize must be a positive integer`);
+            }
+            this.cacheSize = Math.round(cacheSize);
+        }
+    }
+
+    delete(sessionToken: string) {
+        this.cache.delete(sessionToken);
+    }
+
+    get(sessionToken: string) {
+        const item = this.cache.get(sessionToken);
+        if (item) {
+            // refresh key
+            this.cache.delete(sessionToken);
+            this.cache.set(sessionToken, item);
+        }
+        return item;
+    }
+
+    set(session: CachedSession) {
+        this.cache.set(session.token, session);
+
+        if (this.cache.has(session.token)) {
+            // refresh key
+            this.cache.delete(session.token);
+        } else if (this.cache.size === this.cacheSize) {
+            // evict oldest
+            this.cache.delete(this.first());
+        }
+        this.cache.set(session.token, session);
+    }
+
+    clear() {
+        this.cache.clear();
+    }
+
+    private first() {
+        return this.cache.keys().next().value;
+    }
+}

+ 26 - 0
packages/core/src/config/session-cache/noop-session-cache-strategy.ts

@@ -0,0 +1,26 @@
+import { CachedSession, SessionCacheStrategy } from './session-cache-strategy';
+
+/**
+ * @description
+ * A cache that doesn't cache. The cache lookup will miss every time
+ * so the session will always be taken from the database.
+ *
+ * @docsCategory auth
+ */
+export class NoopSessionCacheStrategy implements SessionCacheStrategy {
+    clear() {
+        return undefined;
+    }
+
+    delete(sessionToken: string) {
+        return undefined;
+    }
+
+    get(sessionToken: string) {
+        return undefined;
+    }
+
+    set(session: CachedSession) {
+        return undefined;
+    }
+}

+ 81 - 0
packages/core/src/config/session-cache/session-cache-strategy.ts

@@ -0,0 +1,81 @@
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import { UserChannelPermissions } from '../../service/helpers/utils/get-user-channels-permissions';
+
+/**
+ * @description
+ * A simplified representation of the User associated with the
+ * current Session.
+ *
+ * @docsCategory auth
+ * @docsPage SessionCacheStrategy
+ */
+export type CachedSessionUser = {
+    id: ID;
+    identifier: string;
+    verified: boolean;
+    channelPermissions: UserChannelPermissions[];
+};
+
+/**
+ * @description
+ * A simplified representation of a Session which is easy to
+ * store.
+ *
+ * @docsCategory auth
+ * @docsPage SessionCacheStrategy
+ */
+export type CachedSession = {
+    /**
+     * @description
+     * The timestamp after which this cache entry is considered stale and
+     * a fresh copy of the data will be set. Based on the `sessionCacheTTL`
+     * option.
+     */
+    cacheExpiry: number;
+    id: ID;
+    token: string;
+    expires: Date;
+    activeOrderId?: ID;
+    authenticationStrategy?: string;
+    user?: CachedSessionUser;
+};
+
+/**
+ * @description
+ * This strategy defines how sessions get cached. Since most requests will need the Session
+ * object for permissions data, it can become a bottleneck to go to the database and do a multi-join
+ * SQL query each time. Therefore we cache the session data only perform the SQL query once and upon
+ * invalidation of the cache.
+ *
+ * @docsCategory auth
+ * @docsPage SessionCacheStrategy
+ */
+export interface SessionCacheStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Store the session in the cache. When caching a session, the data
+     * should not be modified apart from performing any transforms needed to
+     * get it into a state to be stored, e.g. JSON.stringify().
+     */
+    set(session: CachedSession): void | Promise<void>;
+
+    /**
+     * @description
+     * Retrieve the session from the cache
+     */
+    get(sessionToken: string): CachedSession | undefined | Promise<CachedSession | undefined>;
+
+    /**
+     * @description
+     * Delete a session from the cache
+     */
+    delete(sessionToken: string): void | Promise<void>;
+
+    /**
+     * @description
+     * Clear the entire cache
+     */
+    clear(): void | Promise<void>;
+}

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

@@ -26,6 +26,7 @@ import { PriceCalculationStrategy } from './order/price-calculation-strategy';
 import { PaymentMethodHandler } from './payment-method/payment-method-handler';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionCondition } from './promotion/promotion-condition';
+import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
 import { TaxCalculationStrategy } from './tax/tax-calculation-strategy';
@@ -137,7 +138,7 @@ export interface ApiOptions {
 
 /**
  * @description
- * The AuthOptions define how authentication is managed.
+ * The AuthOptions define how authentication and authorization is managed.
  *
  * @docsCategory auth
  * */
@@ -189,15 +190,33 @@ export interface AuthOptions {
     authTokenHeaderKey?: string;
     /**
      * @description
-     * Session duration, i.e. the time which must elapse from the last authenticted request
+     * Session duration, i.e. the time which must elapse from the last authenticated request
      * after which the user must re-authenticate.
      *
      * Expressed as a string describing a time span per
      * [zeit/ms](https://github.com/zeit/ms.js).  Eg: `60`, `'2 days'`, `'10h'`, `'7d'`
      *
-     * @default '7d'
+     * @default '1y'
      */
     sessionDuration?: string | number;
+    /**
+     * @description
+     * This strategy defines how sessions will be cached. By default, sessions are cached using a simple
+     * in-memory caching strategy which is suitable for development and low-traffic, single-instance
+     * deployments.
+     *
+     * @default InMemorySessionCacheStrategy
+     */
+    sessionCacheStrategy?: SessionCacheStrategy;
+    /**
+     * @description
+     * The "time to live" of a given item in the session cache. This determines the length of time (in seconds)
+     * that a cache entry is kept before being considered "stale" and being replaced with fresh data
+     * taken from the database.
+     *
+     * @default 300
+     */
+    sessionCacheTTL?: number;
     /**
      * @description
      * Determines whether new User accounts require verification of their email address.

+ 15 - 14
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -1,5 +1,5 @@
 import { CurrencyCode } from '@vendure/common/lib/generated-types';
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { SoftDeletable } from '../../common/types/common-types';
@@ -9,6 +9,7 @@ import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Collection } from '../collection/collection.entity';
 import { CustomProductVariantFields } from '../custom-entity-fields';
+import { EntityId } from '../entity-id.decorator';
 import { FacetValue } from '../facet-value/facet-value.entity';
 import { ProductOption } from '../product-option/product-option.entity';
 import { Product } from '../product/product.entity';
@@ -76,26 +77,26 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
      */
     taxRateApplied: TaxRate;
 
-    @ManyToOne(type => Asset)
+    @ManyToOne((type) => Asset)
     featuredAsset: Asset;
 
-    @OneToMany(type => ProductVariantAsset, productVariantAsset => productVariantAsset.productVariant)
+    @OneToMany((type) => ProductVariantAsset, (productVariantAsset) => productVariantAsset.productVariant)
     assets: ProductVariantAsset[];
 
-    @ManyToOne(type => TaxCategory)
+    @ManyToOne((type) => TaxCategory)
     taxCategory: TaxCategory;
 
-    @OneToMany(type => ProductVariantPrice, price => price.variant, { eager: true })
+    @OneToMany((type) => ProductVariantPrice, (price) => price.variant, { eager: true })
     productVariantPrices: ProductVariantPrice[];
 
-    @OneToMany(type => ProductVariantTranslation, translation => translation.base, { eager: true })
+    @OneToMany((type) => ProductVariantTranslation, (translation) => translation.base, { eager: true })
     translations: Array<Translation<ProductVariant>>;
 
-    @ManyToOne(type => Product, product => product.variants)
+    @ManyToOne((type) => Product, (product) => product.variants)
     product: Product;
 
-    @Column({ nullable: true })
-    productId: number;
+    @EntityId({ nullable: true })
+    productId: ID;
 
     @Column({ default: 0 })
     stockOnHand: number;
@@ -103,20 +104,20 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
     @Column()
     trackInventory: boolean;
 
-    @OneToMany(type => StockMovement, stockMovement => stockMovement.productVariant)
+    @OneToMany((type) => StockMovement, (stockMovement) => stockMovement.productVariant)
     stockMovements: StockMovement[];
 
-    @ManyToMany(type => ProductOption)
+    @ManyToMany((type) => ProductOption)
     @JoinTable()
     options: ProductOption[];
 
-    @ManyToMany(type => FacetValue)
+    @ManyToMany((type) => FacetValue)
     @JoinTable()
     facetValues: FacetValue[];
 
-    @Column(type => CustomProductVariantFields)
+    @Column((type) => CustomProductVariantFields)
     customFields: CustomProductVariantFields;
 
-    @ManyToMany(type => Collection, collection => collection.productVariants)
+    @ManyToMany((type) => Collection, (collection) => collection.productVariants)
     collections: Collection[];
 }

+ 6 - 2
packages/core/src/entity/session/session.entity.ts

@@ -1,8 +1,9 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, ManyToOne, TableInheritance } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
+import { EntityId } from '../entity-id.decorator';
 import { Order } from '../order/order.entity';
 import { User } from '../user/user.entity';
 
@@ -24,6 +25,9 @@ export abstract class Session extends VendureEntity {
 
     @Column() invalidated: boolean;
 
-    @ManyToOne(type => Order)
+    @EntityId({ nullable: true })
+    activeOrderId?: ID;
+
+    @ManyToOne((type) => Order)
     activeOrder: Order | null;
 }

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -47,6 +47,7 @@ import { ProductService } from './services/product.service';
 import { PromotionService } from './services/promotion.service';
 import { RoleService } from './services/role.service';
 import { SearchService } from './services/search.service';
+import { SessionService } from './services/session.service';
 import { ShippingMethodService } from './services/shipping-method.service';
 import { StockMovementService } from './services/stock-movement.service';
 import { TaxCategoryService } from './services/tax-category.service';
@@ -77,6 +78,7 @@ const services = [
     PromotionService,
     RoleService,
     SearchService,
+    SessionService,
     ShippingMethodService,
     StockMovementService,
     TaxCategoryService,

+ 17 - 147
packages/core/src/service/services/auth.service.ts

@@ -1,8 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { ID } from '@vendure/common/lib/shared-types';
-import crypto from 'crypto';
-import ms from 'ms';
 import { Connection } from 'typeorm';
 
 import { ApiType } from '../../api/common/get-api-type';
@@ -10,39 +8,30 @@ import { RequestContext } from '../../api/common/request-context';
 import { InternalServerError, NotVerifiedError, UnauthorizedError } from '../../common/error/errors';
 import { AuthenticationStrategy } from '../../config/auth/authentication-strategy';
 import {
-    NativeAuthenticationStrategy,
     NATIVE_AUTH_STRATEGY_NAME,
+    NativeAuthenticationStrategy,
 } 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';
 import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
-import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { AttemptedLoginEvent } from '../../event-bus/events/attempted-login-event';
 import { LoginEvent } from '../../event-bus/events/login-event';
 import { LogoutEvent } from '../../event-bus/events/logout-event';
-import { PasswordCiper } from '../helpers/password-cipher/password-ciper';
 
-import { OrderService } from './order.service';
+import { SessionService } from './session.service';
 
 /**
  * The AuthService manages both authenticated and anonymous Sessions.
  */
 @Injectable()
 export class AuthService {
-    private readonly sessionDurationInMs: number;
-
     constructor(
         @InjectConnection() private connection: Connection,
-        private passwordCipher: PasswordCiper,
         private configService: ConfigService,
-        private orderService: OrderService,
+        private sessionService: SessionService,
         private eventBus: EventBus,
-    ) {
-        this.sessionDurationInMs = ms(this.configService.authOptions.sessionDuration as string);
-    }
+    ) {}
 
     /**
      * Authenticates a user's credentials and if okay, creates a new session.
@@ -73,16 +62,19 @@ export class AuthService {
         if (this.configService.authOptions.requireVerification && !user.verified) {
             throw new NotVerifiedError();
         }
-
-        if (ctx.session && ctx.session.activeOrder) {
-            await this.deleteSessionsByActiveOrder(ctx.session && ctx.session.activeOrder);
+        await this.sessionService.deleteSessionsByUser(user);
+        if (ctx.session && ctx.session.activeOrderId) {
+            await this.sessionService.deleteSessionsByActiveOrderId(ctx.session.activeOrderId);
         }
         user.lastLogin = new Date();
         await this.connection.manager.save(user, { reload: false });
-        const session = await this.createNewAuthenticatedSession(ctx, user, authenticationStrategy);
-        const newSession = await this.connection.getRepository(AuthenticatedSession).save(session);
+        const session = await this.sessionService.createNewAuthenticatedSession(
+            ctx,
+            user,
+            authenticationStrategy,
+        );
         this.eventBus.publish(new LoginEvent(ctx, user));
-        return newSession;
+        return session;
     }
 
     /**
@@ -100,76 +92,12 @@ export class AuthService {
         return true;
     }
 
-    /**
-     * Create an anonymous session.
-     */
-    async createAnonymousSession(): Promise<AnonymousSession> {
-        const token = await this.generateSessionToken();
-        const anonymousSessionDurationInMs = ms('1y');
-        const session = new AnonymousSession({
-            token,
-            expires: this.getExpiryDate(anonymousSessionDurationInMs),
-            invalidated: false,
-        });
-        // save the new session
-        const newSession = await this.connection.getRepository(AnonymousSession).save(session);
-        return newSession;
-    }
-
-    /**
-     * Looks for a valid session with the given token and returns one if found.
-     */
-    async validateSession(token: string): Promise<Session | undefined> {
-        const session = await this.connection
-            .getRepository(Session)
-            .createQueryBuilder('session')
-            .leftJoinAndSelect('session.activeOrder', 'activeOrder')
-            .leftJoinAndSelect('session.user', 'user')
-            .leftJoinAndSelect('user.roles', 'roles')
-            .leftJoinAndSelect('roles.channels', 'channels')
-            .where('session.token = :token', { token })
-            .andWhere('session.invalidated = false')
-            .getOne();
-
-        if (session && session.expires > new Date()) {
-            await this.updateSessionExpiry(session);
-            return session;
-        }
-    }
-
-    async setActiveOrder<T extends Session>(session: T, order: Order): Promise<T> {
-        session.activeOrder = order;
-        return this.connection.getRepository(Session).save(session);
-    }
-
-    async unsetActiveOrder<T extends Session>(session: T): Promise<T> {
-        if (session.activeOrder) {
-            session.activeOrder = null;
-            return this.connection.getRepository(Session).save(session);
-        }
-        return session;
-    }
-
-    /**
-     * Deletes all existing sessions for the given user.
-     */
-    async deleteSessionsByUser(user: User): Promise<void> {
-        await this.connection.getRepository(AuthenticatedSession).delete({ user });
-    }
-
-    /**
-     * Deletes all existing sessions with the given activeOrder.
-     */
-    async deleteSessionsByActiveOrder(activeOrder: Order): Promise<void> {
-        await this.connection.getRepository(Session).delete({ activeOrder });
-    }
-
     /**
      * Deletes all sessions for the user associated with the given session token.
      */
-    async destroyAuthenticatedSession(ctx: RequestContext, token: string): Promise<void> {
+    async destroyAuthenticatedSession(ctx: RequestContext, sessionToken: string): Promise<void> {
         const session = await this.connection.getRepository(AuthenticatedSession).findOne({
-            where: { token },
+            where: { token: sessionToken },
             relations: ['user', 'user.authenticationMethods'],
         });
 
@@ -182,68 +110,10 @@ export class AuthService {
                 await authenticationStrategy.onLogOut(session.user);
             }
             this.eventBus.publish(new LogoutEvent(ctx));
-            return this.deleteSessionsByUser(session.user);
-        }
-    }
-
-    private async createNewAuthenticatedSession(
-        ctx: RequestContext,
-        user: User,
-        authenticationStrategy: AuthenticationStrategy,
-    ): Promise<AuthenticatedSession> {
-        const token = await this.generateSessionToken();
-        const guestOrder =
-            ctx.session && ctx.session.activeOrder
-                ? await this.orderService.findOne(ctx, ctx.session.activeOrder.id)
-                : undefined;
-        const existingOrder = await this.orderService.getActiveOrderForUser(ctx, user.id);
-        const activeOrder = await this.orderService.mergeOrders(ctx, user, guestOrder, existingOrder);
-        return new AuthenticatedSession({
-            token,
-            user,
-            activeOrder,
-            authenticationStrategy: authenticationStrategy.name,
-            expires: this.getExpiryDate(this.sessionDurationInMs),
-            invalidated: false,
-        });
-    }
-
-    /**
-     * Generates a random session token.
-     */
-    private generateSessionToken(): Promise<string> {
-        return new Promise((resolve, reject) => {
-            crypto.randomBytes(32, (err, buf) => {
-                if (err) {
-                    reject(err);
-                }
-                resolve(buf.toString('hex'));
-            });
-        });
-    }
-
-    /**
-     * If we are over half way to the current session's expiry date, then we update it.
-     *
-     * This ensures that the session will not expire when in active use, but prevents us from
-     * needing to run an update query on *every* request.
-     */
-    private async updateSessionExpiry(session: Session) {
-        const now = new Date().getTime();
-        if (session.expires.getTime() - now < this.sessionDurationInMs / 2) {
-            await this.connection
-                .getRepository(Session)
-                .update({ id: session.id }, { expires: this.getExpiryDate(this.sessionDurationInMs) });
+            return this.sessionService.deleteSessionsByUser(session.user);
         }
     }
 
-    /**
-     * Returns a future expiry date according timeToExpireInMs in the future.
-     */
-    private getExpiryDate(timeToExpireInMs: number): Date {
-        return new Date(Date.now() + timeToExpireInMs);
-    }
-
     private getAuthenticationStrategy(
         apiType: ApiType,
         method: typeof NATIVE_AUTH_STRATEGY_NAME,
@@ -255,7 +125,7 @@ export class AuthService {
             apiType === 'admin'
                 ? authOptions.adminAuthenticationStrategy
                 : authOptions.shopAuthenticationStrategy;
-        const match = strategies.find(s => s.name === method);
+        const match = strategies.find((s) => s.name === method);
         if (!match) {
             throw new InternalServerError('error.unrecognized-authentication-strategy', { name: method });
         }

+ 23 - 23
packages/core/src/service/services/collection.service.ts

@@ -43,7 +43,7 @@ import { findOneInChannel } from '../helpers/utils/channel-aware-orm-utils';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { moveToIndex } from '../helpers/utils/move-to-index';
 import { translateDeep } from '../helpers/utils/translate-entity';
-import { ApplyCollectionFiletersJobData, ApplyCollectionFiltersMessage } from '../types/collection-messages';
+import { ApplyCollectionFiltersJobData, ApplyCollectionFiltersMessage } from '../types/collection-messages';
 
 import { AssetService } from './asset.service';
 import { ChannelService } from './channel.service';
@@ -51,7 +51,7 @@ import { FacetValueService } from './facet-value.service';
 
 export class CollectionService implements OnModuleInit {
     private rootCollection: Collection | undefined;
-    private applyFiltersQueue: JobQueue<ApplyCollectionFiletersJobData>;
+    private applyFiltersQueue: JobQueue<ApplyCollectionFiltersJobData>;
 
     constructor(
         @InjectConnection() private connection: Connection,
@@ -73,18 +73,18 @@ export class CollectionService implements OnModuleInit {
 
         merge(productEvents$, variantEvents$)
             .pipe(debounceTime(50))
-            .subscribe(async event => {
+            .subscribe(async (event) => {
                 const collections = await this.connection.getRepository(Collection).find();
                 this.applyFiltersQueue.add({
                     ctx: event.ctx.serialize(),
-                    collectionIds: collections.map(c => c.id),
+                    collectionIds: collections.map((c) => c.id),
                 });
             });
 
         this.applyFiltersQueue = this.jobQueueService.createQueue({
             name: 'apply-collection-filters',
             concurrency: 1,
-            process: async job => {
+            process: async (job) => {
                 const collections = await this.connection
                     .getRepository(Collection)
                     .findByIds(job.data.collectionIds);
@@ -108,7 +108,7 @@ export class CollectionService implements OnModuleInit {
             })
             .getManyAndCount()
             .then(async ([collections, totalItems]) => {
-                const items = collections.map(collection =>
+                const items = collections.map((collection) =>
                     translateDeep(collection, ctx.languageCode, ['parent']),
                 );
                 return {
@@ -145,7 +145,7 @@ export class CollectionService implements OnModuleInit {
     }
 
     getAvailableFilters(ctx: RequestContext): ConfigurableOperationDefinition[] {
-        return this.configService.catalogOptions.collectionFilters.map(x =>
+        return this.configService.catalogOptions.collectionFilters.map((x) =>
             configurableDefToOperation(ctx, x),
         );
     }
@@ -158,7 +158,7 @@ export class CollectionService implements OnModuleInit {
             .createQueryBuilder('collection')
             .leftJoinAndSelect('collection.translations', 'translation')
             .where(
-                qb =>
+                (qb) =>
                     `collection.id = ${qb
                         .subQuery()
                         .select(parentIdSelect)
@@ -207,7 +207,7 @@ export class CollectionService implements OnModuleInit {
         }
         const result = await qb.getMany();
 
-        return result.map(collection => translateDeep(collection, ctx.languageCode));
+        return result.map((collection) => translateDeep(collection, ctx.languageCode));
     }
 
     /**
@@ -233,7 +233,7 @@ export class CollectionService implements OnModuleInit {
         };
 
         const descendants = await getChildren(rootId);
-        return descendants.map(c => translateDeep(c, ctx.languageCode));
+        return descendants.map((c) => translateDeep(c, ctx.languageCode));
     }
 
     /**
@@ -265,9 +265,9 @@ export class CollectionService implements OnModuleInit {
 
         return this.connection
             .getRepository(Collection)
-            .findByIds(ancestors.map(c => c.id))
-            .then(categories => {
-                return ctx ? categories.map(c => translateDeep(c, ctx.languageCode)) : categories;
+            .findByIds(ancestors.map((c) => c.id))
+            .then((categories) => {
+                return ctx ? categories.map((c) => translateDeep(c, ctx.languageCode)) : categories;
             });
     }
 
@@ -277,7 +277,7 @@ export class CollectionService implements OnModuleInit {
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async coll => {
+            beforeSave: async (coll) => {
                 await this.channelService.assignToCurrentChannel(coll, ctx);
                 const parent = await this.getParentCollection(ctx, input.parentId);
                 if (parent) {
@@ -302,7 +302,7 @@ export class CollectionService implements OnModuleInit {
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async coll => {
+            beforeSave: async (coll) => {
                 if (input.filters) {
                     coll.filters = this.getCollectionFiltersFromInput(input);
                 }
@@ -346,7 +346,7 @@ export class CollectionService implements OnModuleInit {
 
         if (
             idsAreEqual(input.parentId, target.id) ||
-            descendants.some(cat => idsAreEqual(input.parentId, cat.id))
+            descendants.some((cat) => idsAreEqual(input.parentId, cat.id))
         ) {
             throw new IllegalOperationError(`error.cannot-move-collection-into-self`);
         }
@@ -401,15 +401,15 @@ export class CollectionService implements OnModuleInit {
     private async applyCollectionFilters(
         ctx: SerializedRequestContext,
         collections: Collection[],
-        job: Job<ApplyCollectionFiletersJobData>,
+        job: Job<ApplyCollectionFiltersJobData>,
     ): Promise<void> {
-        const collectionIds = collections.map(c => c.id);
+        const collectionIds = collections.map((c) => c.id);
         const requestContext = RequestContext.deserialize(ctx);
 
         this.workerService.send(new ApplyCollectionFiltersMessage({ collectionIds })).subscribe({
             next: ({ total, completed, duration, collectionId, affectedVariantIds }) => {
                 const progress = Math.ceil((completed / total) * 100);
-                const collection = collections.find(c => idsAreEqual(c.id, collectionId));
+                const collection = collections.find((c) => idsAreEqual(c.id, collectionId));
                 if (collection) {
                     this.eventBus.publish(
                         new CollectionModificationEvent(requestContext, collection, affectedVariantIds),
@@ -420,7 +420,7 @@ export class CollectionService implements OnModuleInit {
             complete: () => {
                 job.complete();
             },
-            error: err => {
+            error: (err) => {
                 Logger.error(err);
                 job.fail(err);
             },
@@ -432,14 +432,14 @@ export class CollectionService implements OnModuleInit {
      */
     async getCollectionProductVariantIds(collection: Collection): Promise<ID[]> {
         if (collection.productVariants) {
-            return collection.productVariants.map(v => v.id);
+            return collection.productVariants.map((v) => v.id);
         } else {
             const productVariants = await this.connection
                 .getRepository(ProductVariant)
                 .createQueryBuilder('variant')
                 .innerJoin('variant.collections', 'collection', 'collection.id = :id', { id: collection.id })
                 .getMany();
-            return productVariants.map(v => v.id);
+            return productVariants.map((v) => v.id);
         }
     }
 
@@ -519,7 +519,7 @@ export class CollectionService implements OnModuleInit {
     }
 
     private getFilterByCode(code: string): CollectionFilter<any> {
-        const match = this.configService.catalogOptions.collectionFilters.find(a => a.code === code);
+        const match = this.configService.catalogOptions.collectionFilters.find((a) => a.code === code);
         if (!match) {
             throw new UserInputError(`error.adjustment-operation-with-code-not-found`, { code });
         }

+ 259 - 0
packages/core/src/service/services/session.service.ts

@@ -0,0 +1,259 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { ID } from '@vendure/common/lib/shared-types';
+import crypto from 'crypto';
+import se from 'i18next-icu/locale-data/se';
+import ms from 'ms';
+import { Connection, EntitySubscriberInterface, InsertEvent, RemoveEvent, UpdateEvent } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { AuthenticationStrategy } from '../../config/auth/authentication-strategy';
+import { ConfigService } from '../../config/config.service';
+import { CachedSession, SessionCacheStrategy } from '../../config/session-cache/session-cache-strategy';
+import { Channel } from '../../entity/channel/channel.entity';
+import { Order } from '../../entity/order/order.entity';
+import { Role } from '../../entity/role/role.entity';
+import { AnonymousSession } from '../../entity/session/anonymous-session.entity';
+import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
+import { Session } from '../../entity/session/session.entity';
+import { User } from '../../entity/user/user.entity';
+import { getUserChannelsPermissions } from '../helpers/utils/get-user-channels-permissions';
+
+import { OrderService } from './order.service';
+
+@Injectable()
+export class SessionService implements EntitySubscriberInterface {
+    private sessionCacheStrategy: SessionCacheStrategy;
+    private readonly sessionDurationInMs: number;
+
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private configService: ConfigService,
+        private orderService: OrderService,
+    ) {
+        this.sessionCacheStrategy = this.configService.authOptions.sessionCacheStrategy;
+        this.sessionDurationInMs = ms(this.configService.authOptions.sessionDuration as string);
+        // This allows us to register this class as a TypeORM Subscriber while also allowing
+        // the injection on dependencies. See https://docs.nestjs.com/techniques/database#subscribers
+        this.connection.subscribers.push(this);
+    }
+
+    afterInsert(event: InsertEvent<any>): Promise<any> | void {
+        this.clearSessionCacheOnDataChange(event);
+    }
+
+    afterRemove(event: RemoveEvent<any>): Promise<any> | void {
+        this.clearSessionCacheOnDataChange(event);
+    }
+
+    afterUpdate(event: UpdateEvent<any>): Promise<any> | void {
+        this.clearSessionCacheOnDataChange(event);
+    }
+
+    private async clearSessionCacheOnDataChange(
+        event: InsertEvent<any> | RemoveEvent<any> | UpdateEvent<any>,
+    ) {
+        if (event.entity) {
+            // If a Channel or Role changes, potentially all the cached permissions in the
+            // session cache will be wrong, so we just clear the entire cache. It should however
+            // be a very rare occurrence in normal operation, once initial setup is complete.
+            if (event.entity instanceof Channel || event.entity instanceof Role) {
+                await this.sessionCacheStrategy.clear();
+            }
+        }
+    }
+
+    async createNewAuthenticatedSession(
+        ctx: RequestContext,
+        user: User,
+        authenticationStrategy: AuthenticationStrategy,
+    ): Promise<AuthenticatedSession> {
+        const token = await this.generateSessionToken();
+        const guestOrder =
+            ctx.session && ctx.session.activeOrderId
+                ? await this.orderService.findOne(ctx, ctx.session.activeOrderId)
+                : undefined;
+        const existingOrder = await this.orderService.getActiveOrderForUser(ctx, user.id);
+        const activeOrder = await this.orderService.mergeOrders(ctx, user, guestOrder, existingOrder);
+        const authenticatedSession = await this.connection.getRepository(AuthenticatedSession).save(
+            new AuthenticatedSession({
+                token,
+                user,
+                activeOrder,
+                authenticationStrategy: authenticationStrategy.name,
+                expires: this.getExpiryDate(this.sessionDurationInMs),
+                invalidated: false,
+            }),
+        );
+        await this.sessionCacheStrategy.set(this.serializeSession(authenticatedSession));
+        return authenticatedSession;
+    }
+
+    /**
+     * Create an anonymous session.
+     */
+    async createAnonymousSession(): Promise<CachedSession> {
+        const token = await this.generateSessionToken();
+        const anonymousSessionDurationInMs = ms('1y');
+        const session = new AnonymousSession({
+            token,
+            expires: this.getExpiryDate(anonymousSessionDurationInMs),
+            invalidated: false,
+        });
+        // save the new session
+        const newSession = await this.connection.getRepository(AnonymousSession).save(session);
+        const serializedSession = this.serializeSession(newSession);
+        await this.sessionCacheStrategy.set(serializedSession);
+        return serializedSession;
+    }
+
+    async getSessionFromToken(sessionToken: string): Promise<CachedSession | undefined> {
+        let serializedSession = await this.sessionCacheStrategy.get(sessionToken);
+        const stale = serializedSession && serializedSession.cacheExpiry < new Date().getTime() / 1000;
+        const expired = serializedSession && serializedSession.expires < new Date();
+        if (!serializedSession || stale || expired) {
+            const session = await this.findSessionByToken(sessionToken);
+            if (session) {
+                serializedSession = this.serializeSession(session);
+                await this.sessionCacheStrategy.set(serializedSession);
+                return serializedSession;
+            } else {
+                return;
+            }
+        }
+        return serializedSession;
+    }
+
+    serializeSession(session: AuthenticatedSession | AnonymousSession): CachedSession {
+        const expiry =
+            Math.floor(new Date().getTime() / 1000) + this.configService.authOptions.sessionCacheTTL;
+        const serializedSession: CachedSession = {
+            cacheExpiry: expiry,
+            id: session.id,
+            token: session.token,
+            expires: session.expires,
+            activeOrderId: session.activeOrderId,
+        };
+        if (this.isAuthenticatedSession(session)) {
+            serializedSession.authenticationStrategy = session.authenticationStrategy;
+            const { user } = session;
+            serializedSession.user = {
+                id: user.id,
+                identifier: user.identifier,
+                verified: user.verified,
+                channelPermissions: getUserChannelsPermissions(user),
+            };
+        }
+        return serializedSession;
+    }
+
+    /**
+     * Looks for a valid session with the given token and returns one if found.
+     */
+    private async findSessionByToken(token: string): Promise<Session | undefined> {
+        const session = await this.connection
+            .getRepository(Session)
+            .createQueryBuilder('session')
+            .leftJoinAndSelect('session.user', 'user')
+            .leftJoinAndSelect('user.roles', 'roles')
+            .leftJoinAndSelect('roles.channels', 'channels')
+            .where('session.token = :token', { token })
+            .andWhere('session.invalidated = false')
+            .getOne();
+
+        if (session && session.expires > new Date()) {
+            await this.updateSessionExpiry(session);
+            return session;
+        }
+    }
+
+    async setActiveOrder(serializedSession: CachedSession, order: Order): Promise<CachedSession> {
+        const session = await this.connection.getRepository(Session).findOne(serializedSession.id);
+        if (session) {
+            session.activeOrder = order;
+            await this.connection.getRepository(Session).save(session, { reload: false });
+            const updatedSerializedSession = this.serializeSession(session);
+            await this.sessionCacheStrategy.set(updatedSerializedSession);
+            return updatedSerializedSession;
+        }
+        return serializedSession;
+    }
+
+    async unsetActiveOrder(serializedSession: CachedSession): Promise<CachedSession> {
+        if (serializedSession.activeOrderId) {
+            const session = await this.connection
+                .getRepository(Session)
+                .save({ id: serializedSession.id, activeOrder: null });
+            const updatedSerializedSession = this.serializeSession(session);
+            await this.configService.authOptions.sessionCacheStrategy.set(updatedSerializedSession);
+            return updatedSerializedSession;
+        }
+        return serializedSession;
+    }
+
+    /**
+     * Deletes all existing sessions for the given user.
+     */
+    async deleteSessionsByUser(user: User): Promise<void> {
+        const userSessions = await this.connection
+            .getRepository(AuthenticatedSession)
+            .find({ where: { user } });
+        await this.connection.getRepository(AuthenticatedSession).remove(userSessions);
+        for (const session of userSessions) {
+            await this.sessionCacheStrategy.delete(session.token);
+        }
+    }
+
+    /**
+     * Deletes all existing sessions with the given activeOrder.
+     */
+    async deleteSessionsByActiveOrderId(activeOrderId: ID): Promise<void> {
+        const sessions = await this.connection.getRepository(Session).find({ where: { activeOrderId } });
+        await this.connection.getRepository(Session).remove(sessions);
+        for (const session of sessions) {
+            await this.sessionCacheStrategy.delete(session.token);
+        }
+    }
+
+    /**
+     * If we are over half way to the current session's expiry date, then we update it.
+     *
+     * This ensures that the session will not expire when in active use, but prevents us from
+     * needing to run an update query on *every* request.
+     */
+    private async updateSessionExpiry(session: Session) {
+        const now = new Date().getTime();
+        if (session.expires.getTime() - now < this.sessionDurationInMs / 2) {
+            const newExpiryDate = this.getExpiryDate(this.sessionDurationInMs);
+            session.expires = newExpiryDate;
+            await this.connection
+                .getRepository(Session)
+                .update({ id: session.id }, { expires: newExpiryDate });
+        }
+    }
+
+    /**
+     * Returns a future expiry date according timeToExpireInMs in the future.
+     */
+    private getExpiryDate(timeToExpireInMs: number): Date {
+        return new Date(Date.now() + timeToExpireInMs);
+    }
+
+    /**
+     * Generates a random session token.
+     */
+    private generateSessionToken(): Promise<string> {
+        return new Promise((resolve, reject) => {
+            crypto.randomBytes(32, (err, buf) => {
+                if (err) {
+                    reject(err);
+                }
+                resolve(buf.toString('hex'));
+            });
+        });
+    }
+
+    private isAuthenticatedSession(session: Session): session is AuthenticatedSession {
+        return session.hasOwnProperty('user');
+    }
+}

+ 1 - 1
packages/core/src/service/types/collection-messages.ts

@@ -18,4 +18,4 @@ export class ApplyCollectionFiltersMessage extends WorkerMessage<
     static readonly pattern = 'ApplyCollectionFilters';
 }
 
-export type ApplyCollectionFiletersJobData = { ctx: SerializedRequestContext; collectionIds: ID[] };
+export type ApplyCollectionFiltersJobData = { ctx: SerializedRequestContext; collectionIds: ID[] };