Skip to content

Commit

Permalink
feat(core): Implement configurable session caching
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
michaelbromley committed Jul 2, 2020
1 parent 59dfaad commit 09a432d
Show file tree
Hide file tree
Showing 24 changed files with 758 additions and 285 deletions.
2 changes: 1 addition & 1 deletion packages/core/e2e/order.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
165 changes: 165 additions & 0 deletions packages/core/e2e/session-management.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 9 additions & 10 deletions packages/core/src/api/common/request-context.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,15 +28,15 @@ 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);
const apiType = getApiType(info);

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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down
39 changes: 7 additions & 32 deletions packages/core/src/api/common/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -43,7 +38,7 @@ export class RequestContext {
constructor(options: {
apiType: ApiType;
channel: Channel;
session?: Session;
session?: CachedSession;
languageCode?: LanguageCode;
isAuthorized: boolean;
authorizedAsOwnerOnly: boolean;
Expand All @@ -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,
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading

0 comments on commit 09a432d

Please sign in to comment.