diff --git a/packages/core/e2e/order.e2e-spec.ts b/packages/core/e2e/order.e2e-spec.ts index 8e118dd8d5..3237dc6398 100644 --- a/packages/core/e2e/order.e2e-spec.ts +++ b/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({ diff --git a/packages/core/e2e/session-management.e2e-spec.ts b/packages/core/e2e/session-management.e2e-spec.ts new file mode 100644 index 0000000000..bc954e59c8 --- /dev/null +++ b/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(); +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(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); + + expect(getSpy.mock.calls.length).toBe(1); + }); + + it('sets fresh data after TTL expires', async () => { + setSpy.mockClear(); + + await adminClient.query(ME); + expect(setSpy.mock.calls.length).toBe(0); + + await adminClient.query(ME); + expect(setSpy.mock.calls.length).toBe(0); + + await pause(2000); + + await adminClient.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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/api/common/extract-auth-token.ts b/packages/core/src/api/common/extract-session-token.ts similarity index 83% rename from packages/core/src/api/common/extract-auth-token.ts rename to packages/core/src/api/common/extract-session-token.ts index cabc48c5f5..8bcc953bc3 100644 --- a/packages/core/src/api/common/extract-auth-token.ts +++ b/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; diff --git a/packages/core/src/api/common/request-context.service.ts b/packages/core/src/api/common/request-context.service.ts index 27ec28cd6b..caff440d7e 100644 --- a/packages/core/src/api/common/request-context.service.ts +++ b/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 { 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; } /** diff --git a/packages/core/src/api/common/request-context.ts b/packages/core/src/api/common/request-context.ts index 69c4d29627..b3641794fd 100644 --- a/packages/core/src/api/common/request-context.ts +++ b/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 & { - user: JsonCompatible; - }; + _session: JsonCompatible>; _apiType: ApiType; _channel: JsonCompatible; _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'); - } } diff --git a/packages/core/src/api/common/set-auth-token.ts b/packages/core/src/api/common/set-session-token.ts similarity index 68% rename from packages/core/src/api/common/set-auth-token.ts rename to packages/core/src/api/common/set-session-token.ts index a0f263b1ba..cba4e82207 100644 --- a/packages/core/src/api/common/set-auth-token.ts +++ b/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; 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); } } diff --git a/packages/core/src/api/middleware/auth-guard.ts b/packages/core/src/api/middleware/auth-guard.ts index ae3b6eefab..77c7241d01 100644 --- a/packages/core/src/api/middleware/auth-guard.ts +++ b/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 { @@ -54,35 +54,35 @@ export class AuthGuard implements CanActivate { req: Request, res: Response, hasOwnerPermission: boolean, - ): Promise { - 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 { + 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; } } diff --git a/packages/core/src/api/resolvers/base/base-auth.resolver.ts b/packages/core/src/api/resolvers/base/base-auth.resolver.ts index adffad02c3..818066b8b0 100644 --- a/packages/core/src/api/resolvers/base/base-auth.resolver.ts +++ b/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 { - 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), diff --git a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts b/packages/core/src/api/resolvers/shop/shop-order.resolver.ts index 71f3b02404..28423787b7 100644 --- a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts +++ b/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; diff --git a/packages/core/src/api/schema/admin-api/auth.api.graphql b/packages/core/src/api/schema/admin-api/auth.api.graphql index 62e4288ef6..31a62c5b5f 100644 --- a/packages/core/src/api/schema/admin-api/auth.api.graphql +++ b/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! } diff --git a/packages/core/src/api/schema/shop-api/shop.api.graphql b/packages/core/src/api/schema/shop-api/shop.api.graphql index f688bab275..d68886ad96 100644 --- a/packages/core/src/api/schema/shop-api/shop.api.graphql +++ b/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." diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 6235ecccab..d324e518fc 100644 --- a/packages/core/src/config/default-config.ts +++ b/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: { diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index d88900b59b..3bf9b8f37e 100644 --- a/packages/core/src/config/index.ts +++ b/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'; diff --git a/packages/core/src/config/session-cache/in-memory-session-cache-strategy.ts b/packages/core/src/config/session-cache/in-memory-session-cache-strategy.ts new file mode 100644 index 0000000000..75bfce8700 --- /dev/null +++ b/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(); + 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; + } +} diff --git a/packages/core/src/config/session-cache/noop-session-cache-strategy.ts b/packages/core/src/config/session-cache/noop-session-cache-strategy.ts new file mode 100644 index 0000000000..706475fe73 --- /dev/null +++ b/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; + } +} diff --git a/packages/core/src/config/session-cache/session-cache-strategy.ts b/packages/core/src/config/session-cache/session-cache-strategy.ts new file mode 100644 index 0000000000..e18615dcde --- /dev/null +++ b/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; + + /** + * @description + * Retrieve the session from the cache + */ + get(sessionToken: string): CachedSession | undefined | Promise; + + /** + * @description + * Delete a session from the cache + */ + delete(sessionToken: string): void | Promise; + + /** + * @description + * Clear the entire cache + */ + clear(): void | Promise; +} diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index ab4238d813..68b5d8c5a8 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/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. diff --git a/packages/core/src/entity/product-variant/product-variant.entity.ts b/packages/core/src/entity/product-variant/product-variant.entity.ts index a8fc2247e9..c6125904b6 100644 --- a/packages/core/src/entity/product-variant/product-variant.entity.ts +++ b/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>; - @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[]; } diff --git a/packages/core/src/entity/session/session.entity.ts b/packages/core/src/entity/session/session.entity.ts index 38d0c59e01..471eff909c 100644 --- a/packages/core/src/entity/session/session.entity.ts +++ b/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; } diff --git a/packages/core/src/service/service.module.ts b/packages/core/src/service/service.module.ts index 4b3bb0a54c..724df64227 100644 --- a/packages/core/src/service/service.module.ts +++ b/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, diff --git a/packages/core/src/service/services/auth.service.ts b/packages/core/src/service/services/auth.service.ts index 15182cf9fd..0b76b9209e 100644 --- a/packages/core/src/service/services/auth.service.ts +++ b/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 { - 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 { - 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(session: T, order: Order): Promise { - session.activeOrder = order; - return this.connection.getRepository(Session).save(session); - } - - async unsetActiveOrder(session: T): Promise { - 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 { - await this.connection.getRepository(AuthenticatedSession).delete({ user }); - } - - /** - * Deletes all existing sessions with the given activeOrder. - */ - async deleteSessionsByActiveOrder(activeOrder: Order): Promise { - 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 { + async destroyAuthenticatedSession(ctx: RequestContext, sessionToken: string): Promise { 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 { - 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 { - 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 }); } diff --git a/packages/core/src/service/services/collection.service.ts b/packages/core/src/service/services/collection.service.ts index e056ef2cae..365d7f42c3 100644 --- a/packages/core/src/service/services/collection.service.ts +++ b/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; + private applyFiltersQueue: JobQueue; 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, + job: Job, ): Promise { - 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 { 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 { - 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 }); } diff --git a/packages/core/src/service/services/session.service.ts b/packages/core/src/service/services/session.service.ts new file mode 100644 index 0000000000..3ae2e3f31f --- /dev/null +++ b/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): Promise | void { + this.clearSessionCacheOnDataChange(event); + } + + afterRemove(event: RemoveEvent): Promise | void { + this.clearSessionCacheOnDataChange(event); + } + + afterUpdate(event: UpdateEvent): Promise | void { + this.clearSessionCacheOnDataChange(event); + } + + private async clearSessionCacheOnDataChange( + event: InsertEvent | RemoveEvent | UpdateEvent, + ) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/packages/core/src/service/types/collection-messages.ts b/packages/core/src/service/types/collection-messages.ts index 164f218169..c7c1fcb721 100644 --- a/packages/core/src/service/types/collection-messages.ts +++ b/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[] };