From e82ed0d5dc759f4305637c2f4ae83aa62d5bc2ff Mon Sep 17 00:00:00 2001 From: christophercr Date: Fri, 14 Sep 2018 17:21:30 +0200 Subject: [PATCH] feat(stark-core): implement Stark XSRF module ISSUES CLOSED: #115 --- packages/stark-core/src/modules.ts | 1 + .../src/modules/http/services/http.service.ts | 13 +- .../logging/services/logging.service.spec.ts | 23 +- .../logging/services/logging.service.ts | 40 +- packages/stark-core/src/modules/xsrf.ts | 3 + .../src/modules/xsrf/interceptors.ts | 1 + .../interceptors/http-xsrf.interceptor.ts | 34 ++ .../stark-core/src/modules/xsrf/services.ts | 3 + .../modules/xsrf/services/xsrf-config.intf.ts | 35 ++ .../xsrf/services/xsrf.service.intf.ts | 52 +++ .../xsrf/services/xsrf.service.spec.ts | 374 ++++++++++++++++++ .../src/modules/xsrf/services/xsrf.service.ts | 210 ++++++++++ .../stark-core/src/modules/xsrf/testing.ts | 1 + .../src/modules/xsrf/testing/xsrf.mock.ts | 18 + .../src/modules/xsrf/xsrf.module.ts | 62 +++ packages/stark-core/testing/public_api.ts | 1 + showcase/src/app/app.module.ts | 30 +- 17 files changed, 874 insertions(+), 27 deletions(-) create mode 100644 packages/stark-core/src/modules/xsrf.ts create mode 100644 packages/stark-core/src/modules/xsrf/interceptors.ts create mode 100644 packages/stark-core/src/modules/xsrf/interceptors/http-xsrf.interceptor.ts create mode 100644 packages/stark-core/src/modules/xsrf/services.ts create mode 100644 packages/stark-core/src/modules/xsrf/services/xsrf-config.intf.ts create mode 100644 packages/stark-core/src/modules/xsrf/services/xsrf.service.intf.ts create mode 100644 packages/stark-core/src/modules/xsrf/services/xsrf.service.spec.ts create mode 100644 packages/stark-core/src/modules/xsrf/services/xsrf.service.ts create mode 100644 packages/stark-core/src/modules/xsrf/testing.ts create mode 100644 packages/stark-core/src/modules/xsrf/testing/xsrf.mock.ts create mode 100644 packages/stark-core/src/modules/xsrf/xsrf.module.ts diff --git a/packages/stark-core/src/modules.ts b/packages/stark-core/src/modules.ts index 60fb77d6a7..960aed30c2 100644 --- a/packages/stark-core/src/modules.ts +++ b/packages/stark-core/src/modules.ts @@ -4,3 +4,4 @@ export * from "./modules/routing"; export * from "./modules/session"; export * from "./modules/settings"; export * from "./modules/user"; +export * from "./modules/xsrf"; diff --git a/packages/stark-core/src/modules/http/services/http.service.ts b/packages/stark-core/src/modules/http/services/http.service.ts index f7ade8789c..0d8b2fd4fe 100644 --- a/packages/stark-core/src/modules/http/services/http.service.ts +++ b/packages/stark-core/src/modules/http/services/http.service.ts @@ -1,8 +1,4 @@ /* tslint:disable:completed-docs*/ -/** - * @ignore - */ -const _cloneDeep: Function = require("lodash/cloneDeep"); import { Deserialize, Serialize } from "cerialize"; import { Observable, throwError, timer } from "rxjs"; // FIXME Adapt mergeMap code --> See: https://github.com/ReactiveX/rxjs/blob/master/MIGRATION.md#howto-result-selector-migration @@ -28,6 +24,11 @@ import { import { STARK_LOGGING_SERVICE, StarkLoggingService } from "../../logging/services"; import { STARK_SESSION_SERVICE, StarkSessionService } from "../../session/services"; +/** + * @ignore + */ +const _cloneDeep: Function = require("lodash/cloneDeep"); + /** * @ignore * Service to make HTTP calls in compliance with the guidelines from the NBB REST API Design Guide. @@ -40,7 +41,9 @@ export class StarkHttpServiceImpl

implements StarkHttpS @Inject(STARK_LOGGING_SERVICE) private logger: StarkLoggingService, @Inject(STARK_SESSION_SERVICE) private sessionService: StarkSessionService, private httpClient: HttpClient - ) {} + ) { + this.logger.debug(starkHttpServiceName + " loaded"); + } public executeSingleItemRequest(request: StarkHttpRequest

): Observable> { // remove the etag before executing the request diff --git a/packages/stark-core/src/modules/logging/services/logging.service.spec.ts b/packages/stark-core/src/modules/logging/services/logging.service.spec.ts index 04ebfee48c..e7472d33db 100644 --- a/packages/stark-core/src/modules/logging/services/logging.service.spec.ts +++ b/packages/stark-core/src/modules/logging/services/logging.service.spec.ts @@ -1,6 +1,7 @@ /*tslint:disable:completed-docs*/ import Spy = jasmine.Spy; import SpyObj = jasmine.SpyObj; +import { Injector } from "@angular/core"; import { Store } from "@ngrx/store"; import { Observable, of, throwError } from "rxjs"; import { Serialize } from "cerialize"; @@ -10,15 +11,17 @@ import { StarkLoggingServiceImpl } from "./logging.service"; import { StarkApplicationConfig, StarkApplicationConfigImpl } from "../../../configuration/entities/application"; import { StarkLogging, StarkLoggingImpl, StarkLogMessage, StarkLogMessageImpl, StarkLogMessageType } from "../../logging/entities"; import { StarkBackend } from "../../http/entities/backend"; +import { StarkXSRFService } from "../../xsrf/services"; import { StarkCoreApplicationState } from "../../../common/store"; import { StarkError, StarkErrorImpl } from "../../../common/error"; +import { MockStarkXsrfService } from "../../xsrf/testing/xsrf.mock"; // tslint:disable-next-line:no-big-function describe("Service: StarkLoggingService", () => { let appConfig: StarkApplicationConfig; let mockStore: SpyObj>; - // FIXME: uncomment when XSRF service is implemented - // let mockXSRFService: StarkXSRFService; + let mockInjectorService: SpyObj; + let mockXSRFService: StarkXSRFService; let loggingService: LoggingServiceHelper; const loggingBackend: StarkBackend = { name: "logging", @@ -39,6 +42,7 @@ describe("Service: StarkLoggingService", () => { beforeEach(() => { mockStore = jasmine.createSpyObj>("store", ["dispatch", "pipe"]); + mockInjectorService = jasmine.createSpyObj("injector,", ["get"]); appConfig = new StarkApplicationConfigImpl(); appConfig.debugLoggingEnabled = true; appConfig.loggingFlushDisabled = false; @@ -46,15 +50,16 @@ describe("Service: StarkLoggingService", () => { appConfig.loggingFlushPersistSize = loggingFlushPersistSize; appConfig.addBackend(loggingBackend); - // FIXME: uncomment when XSRF service is implemented - // mockXSRFService = UnitTestingUtils.getMockedXSRFService(); + mockXSRFService = new MockStarkXsrfService(); mockStarkLogging = { uuid: "dummy uuid", applicationId: "dummy app id", messages: [] }; mockStore.pipe.and.returnValue(of(mockStarkLogging)); - loggingService = new LoggingServiceHelper(mockStore, appConfig /*, mockXSRFService*/); + /* tslint:disable-next-line:deprecation */ + (mockInjectorService.get).and.returnValue(mockXSRFService); + loggingService = new LoggingServiceHelper(mockStore, appConfig, mockInjectorService); // reset the calls counter because there is a log in the constructor mockStore.dispatch.calls.reset(); }); @@ -66,7 +71,7 @@ describe("Service: StarkLoggingService", () => { for (const invalidValue of invalidValues) { appConfig.loggingFlushPersistSize = invalidValue; - expect(() => new LoggingServiceHelper(mockStore, appConfig /*, mockXSRFService*/)).toThrowError(/loggingFlushPersistSize/); + expect(() => new LoggingServiceHelper(mockStore, appConfig, mockInjectorService)).toThrowError(/loggingFlushPersistSize/); } }); @@ -74,7 +79,7 @@ describe("Service: StarkLoggingService", () => { appConfig.loggingFlushDisabled = false; appConfig.backends.delete("logging"); - expect(() => new LoggingServiceHelper(mockStore, appConfig /*, mockXSRFService*/)).toThrowError(/backend/); + expect(() => new LoggingServiceHelper(mockStore, appConfig, mockInjectorService)).toThrowError(/backend/); }); it("should generate a new correlation id", () => { @@ -308,8 +313,8 @@ describe("Service: StarkLoggingService", () => { }); class LoggingServiceHelper extends StarkLoggingServiceImpl { - public constructor(store: Store, appConfig: StarkApplicationConfig /*, xsrfService: StarkXSRFService*/) { - super(store, appConfig /*, xsrfService*/); + public constructor(store: Store, appConfig: StarkApplicationConfig, injector: Injector) { + super(store, appConfig, injector); } public constructLogMessageHelper(messageType: StarkLogMessageType, ...args: any[]): StarkLogMessage { diff --git a/packages/stark-core/src/modules/logging/services/logging.service.ts b/packages/stark-core/src/modules/logging/services/logging.service.ts index 2eed1f9589..efd33c86b8 100644 --- a/packages/stark-core/src/modules/logging/services/logging.service.ts +++ b/packages/stark-core/src/modules/logging/services/logging.service.ts @@ -1,19 +1,17 @@ /* tslint:disable:completed-docs*/ import uuid from "uuid"; - import { Serialize } from "cerialize"; - import { select, Store } from "@ngrx/store"; +import { Inject, Injectable, Injector } from "@angular/core"; import { Observable, Subject } from "rxjs"; -import { Inject, Injectable } from "@angular/core"; - import { StarkLoggingService, starkLoggingServiceName } from "./logging.service.intf"; import { STARK_APP_CONFIG, StarkApplicationConfig } from "../../../configuration/entities/application"; import { StarkBackend } from "../../http/entities/backend"; import { StarkCoreApplicationState } from "../../../common/store"; import { StarkHttpStatusCodes } from "../../http/enumerators"; import { StarkHttpHeaders } from "../../http/constants"; +import { STARK_XSRF_SERVICE, StarkXSRFService } from "../../xsrf/services/xsrf.service.intf"; import { StarkLogging, StarkLoggingImpl, StarkLogMessage, StarkLogMessageImpl, StarkLogMessageType } from "../entities"; import { StarkFlushLogMessages, StarkLogMessageAction } from "../actions"; import { selectStarkLogging } from "../reducers"; @@ -24,6 +22,8 @@ import { StarkConfigurationUtil } from "../../../util/configuration.util"; */ const _noop: Function = require("lodash/noop"); +const xsrfServiceNotFound: "not provided" = "not provided"; + /** * @ignore * @ngdoc service @@ -43,14 +43,14 @@ export class StarkLoggingServiceImpl implements StarkLoggingService { private consoleError: Function; private starkLogging: StarkLogging; /** @internal */ + private _xsrfService?: StarkXSRFService | typeof xsrfServiceNotFound; + /** @internal */ private _correlationId: string; - // FIXME: uncomment these lines once XSRF Service is implemented public constructor( private store: Store, - @Inject(STARK_APP_CONFIG) - private appConfig: StarkApplicationConfig /*, - @Inject(starkXSRFServiceName) private xsrfService: StarkXSRFService*/ + @Inject(STARK_APP_CONFIG) private appConfig: StarkApplicationConfig, + private injector: Injector ) { // ensuring that the app config is valid before doing anything StarkConfigurationUtil.validateConfig(this.appConfig, ["logging", "http"], starkLoggingServiceName); @@ -214,8 +214,9 @@ export class StarkLoggingServiceImpl implements StarkLoggingService { // IE "Access is denied" error: https://stackoverflow.com/questions/22098259/access-denied-in-ie-10-and-11-when-ajax-target-is-localhost try { xhr.open("POST", url, async); - // FIXME: uncomment when XSRF service is implemented - // this.xsrfService.configureXHR(xhr); + if (this.xsrfService) { + this.xsrfService.configureXHR(xhr); + } xhr.setRequestHeader(StarkHttpHeaders.CONTENT_TYPE, "application/json"); xhr.send(serializedData); } catch (e) { @@ -259,4 +260,23 @@ export class StarkLoggingServiceImpl implements StarkLoggingService { return logFn.apply(console, consoleArgs); }; } + + /** + * Gets the StarkXSRFService from the Injector (this is tried only once). + * It returns 'undefined' if the service is not found (the XSRF module is not imported in the app). + */ + private get xsrfService(): StarkXSRFService | undefined { + if (typeof this._xsrfService === "undefined") { + // The StarkXSRFService should be resolved at runtime to prevent the Angular DI circular dependency errors + try { + this._xsrfService = this.injector.get(STARK_XSRF_SERVICE); + return this._xsrfService; + } catch (exception) { + this._xsrfService = xsrfServiceNotFound; + return undefined; + } + } + + return this._xsrfService !== xsrfServiceNotFound ? this._xsrfService : undefined; + } } diff --git a/packages/stark-core/src/modules/xsrf.ts b/packages/stark-core/src/modules/xsrf.ts new file mode 100644 index 0000000000..7d74634060 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf.ts @@ -0,0 +1,3 @@ +export * from "./xsrf/interceptors"; +export * from "./xsrf/services"; +export * from "./xsrf/xsrf.module"; diff --git a/packages/stark-core/src/modules/xsrf/interceptors.ts b/packages/stark-core/src/modules/xsrf/interceptors.ts new file mode 100644 index 0000000000..9d0e959e16 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/interceptors.ts @@ -0,0 +1 @@ +export * from "./interceptors/http-xsrf.interceptor"; diff --git a/packages/stark-core/src/modules/xsrf/interceptors/http-xsrf.interceptor.ts b/packages/stark-core/src/modules/xsrf/interceptors/http-xsrf.interceptor.ts new file mode 100644 index 0000000000..1aaf8f82ea --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/interceptors/http-xsrf.interceptor.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from "@angular/core"; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http"; +import { Observable } from "rxjs"; +import { tap } from "rxjs/operators"; +import { StarkXSRFService, STARK_XSRF_SERVICE } from "../services/xsrf.service.intf"; + +/** + * Angular Http interceptor that adds the XSRF configuration to every state-changing request (POST,PUT,PATCH and DELETE) + * and stores the XSRF token from every response. + * + * Defined in the HttpClientXsrfModule set in packages/stark-core/src/modules/http/http.module.ts + */ +@Injectable() +export class StarkXSRFHttpInterceptor implements HttpInterceptor { + public constructor(@Inject(STARK_XSRF_SERVICE) public xsrfService: StarkXSRFService) {} + + /** + * @param request - The intercepted outgoing `HttpRequest` + * @param next - The next request handler where the `HttpRequest` will be forwarded to + * @returns The modified `HttpRequest` with the XSRF configuration enabled. + */ + public intercept(request: HttpRequest, next: HttpHandler): Observable> { + const xsrfProtectedRequest: HttpRequest = this.xsrfService.configureHttpRequest(request); + + return next + .handle(xsrfProtectedRequest) // pass request through to the next request handler + .pipe( + // the Http response is intercepted in order to extract and store the XSRF token via the XSRF service + tap((_httpResponse: HttpEvent) => { + this.xsrfService.storeXSRFToken(); + }) + ); + } +} diff --git a/packages/stark-core/src/modules/xsrf/services.ts b/packages/stark-core/src/modules/xsrf/services.ts new file mode 100644 index 0000000000..a7736f4f05 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/services.ts @@ -0,0 +1,3 @@ +export * from "./services/xsrf.service"; +export * from "./services/xsrf-config.intf"; +export { STARK_XSRF_SERVICE, StarkXSRFService } from "./services/xsrf.service.intf"; diff --git a/packages/stark-core/src/modules/xsrf/services/xsrf-config.intf.ts b/packages/stark-core/src/modules/xsrf/services/xsrf-config.intf.ts new file mode 100644 index 0000000000..c64d358ea9 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/services/xsrf-config.intf.ts @@ -0,0 +1,35 @@ +import { InjectionToken } from "@angular/core"; +import { Observable } from "rxjs"; + +/** + * The InjectionToken version of the config name + */ +export const STARK_XSRF_CONFIG: InjectionToken = new InjectionToken("StarkXSRFConfig"); + +/** + * Alternative literal object to define the waitBeforePinging function and its DI dependencies + */ +export interface StarkXSRFWaitBeforePingingLiteral { + /** + * Array of Dependency Injection tokens for the dependencies of the waitBeforePingingFn. + */ + deps: any[]; + + /** + * Function that will be called by the XSRF service passing the necessary dependencies to get the corresponding Promise/Observable + * that the service should wait for before pinging all the backends. + */ + waitBeforePingingFn: (...deps: any[]) => Promise | PromiseLike | Observable; +} + +/** + * Definition of the configuration object for the Stark XSRF service + */ +export interface StarkXSRFConfig { + /** + * Function that will be called by the XSRF service to get the corresponding Promise/Observable + * that the service should wait for before pinging all the backends. + * Alternatively, this can be defined as a {@link StarkXSRFWaitBeforePingingLiteral|literal} + */ + waitBeforePinging?: (() => Promise | PromiseLike | Observable) | StarkXSRFWaitBeforePingingLiteral; +} diff --git a/packages/stark-core/src/modules/xsrf/services/xsrf.service.intf.ts b/packages/stark-core/src/modules/xsrf/services/xsrf.service.intf.ts new file mode 100644 index 0000000000..459e4d578f --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/services/xsrf.service.intf.ts @@ -0,0 +1,52 @@ +import { InjectionToken } from "@angular/core"; +import { HttpRequest } from "@angular/common/http"; + +/** + * The name of the service in case an injection is needed + */ +export const starkXSRFServiceName: string = "StarkXSRFService"; +/** + * The InjectionToken version of the service name + */ +export const STARK_XSRF_SERVICE: InjectionToken = new InjectionToken(starkXSRFServiceName); + +/** + * Stark XSRF Service. + * Service to get/store the XSRF token to be used with the different backends. + */ +export interface StarkXSRFService { + /** + * Add the necessary options to the XHR config in order to enable XSRF protection. + * Since the service will add the XSRF header to the XHR object, this method must be called after calling the XHR open() method because + * headers cannot be set before open(). See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader + * This method should be used for those HTTP state-changing requests (POST, PUT, PATCH or DELETE) which are not performed + * using StarkHttpService or Angular raw $http + * @param xhr - The XHR object to be configured + */ + configureXHR(xhr: XMLHttpRequest): void; + + /** + * Return a new `HttpRequest` including the necessary options for state-changing requests (POST, PUT, PATCH or DELETE) + * in order to enable XSRF protection. + * Logs a warning whenever there is no XSRF token to be sent in such requests + * @param request - The Angular `HttpRequest` to be modified + * @returns The modified Angular `HttpRequest` + */ + configureHttpRequest(request: HttpRequest): HttpRequest; + + /** + * Get the current XSRF token (in case there is one already stored) + */ + getXSRFToken(): string | undefined; + + /** + * Store the token from the current XSRF cookie + */ + storeXSRFToken(): void; + + /** + * Trigger a GET Http request to all the backends in order to get their XSRF tokens. + * Then the response is intercepted by the XSRF Http Interceptor to store the token from the current XSRF cookie + */ + pingBackends(): void; +} diff --git a/packages/stark-core/src/modules/xsrf/services/xsrf.service.spec.ts b/packages/stark-core/src/modules/xsrf/services/xsrf.service.spec.ts new file mode 100644 index 0000000000..ff71134397 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/services/xsrf.service.spec.ts @@ -0,0 +1,374 @@ +/*tslint:disable:completed-docs*/ +import { HttpClient, HttpErrorResponse, HttpRequest, HttpResponse } from "@angular/common/http"; +import { fakeAsync, tick } from "@angular/core/testing"; +import { Injector } from "@angular/core"; +import { Observable, of, Subject, throwError } from "rxjs"; +import { StarkHttpHeaders } from "../../http/constants"; +import { StarkLoggingService } from "../../logging/services"; +import { StarkXSRFServiceImpl } from "./xsrf.service"; +import { StarkXSRFConfig } from "./xsrf-config.intf"; +import { StarkApplicationConfig, StarkApplicationConfigImpl } from "../../../configuration/entities"; +import { StarkBackend, StarkBackendAuthenticationTypes } from "../../http/entities"; +import { MockStarkLoggingService } from "../../logging/testing/logging.mock"; +import Spy = jasmine.Spy; +import SpyObj = jasmine.SpyObj; +import createSpyObj = jasmine.createSpyObj; +import CallInfo = jasmine.CallInfo; + +/* tslint:disable-next-line:no-big-function */ +describe("Service: StarkXSRFService", () => { + let xsrfService: StarkXSRFServiceHelper; + let appConfig: StarkApplicationConfig; + let mockDocument: Pick; + let mockInjectorService: SpyObj; + let mockXsrfConfig: StarkXSRFConfig; + + const mockLogger: StarkLoggingService = new MockStarkLoggingService(); + const httpMock: SpyObj = createSpyObj("HttpClient", ["get"]); + const mockXSRFToken: string = "dummy xsrf token"; + const dummyHeader: string = "X-DUMMY-HEADER"; + + const mockBackend1: StarkBackend = { + name: "dummy backend 1", + url: "dummy/url", + authenticationType: StarkBackendAuthenticationTypes.PUBLIC, + devAuthenticationEnabled: false, + devAuthenticationRolePrefix: "" + }; + const mockBackend2: StarkBackend = { ...mockBackend1, name: "dummy backend 2", url: "other/url" }; + const mockBackend3: StarkBackend = { ...mockBackend1, name: "dummy backend 3", url: "another/url" }; + + // FIXME: this tslint disable flag is due to a bug in 'no-element-overwrite' rule. Remove it once it is solved + /* tslint:disable:no-element-overwrite */ + beforeEach(() => { + appConfig = new StarkApplicationConfigImpl(); + appConfig.backends = new Map(); + appConfig.backends.set(mockBackend1.name, mockBackend1); + appConfig.backends.set(mockBackend2.name, mockBackend2); + appConfig.backends.set(mockBackend3.name, mockBackend3); + mockDocument = { cookie: "" }; + mockInjectorService = jasmine.createSpyObj("injector,", ["get"]); + mockXsrfConfig = {}; + + (mockLogger.error).calls.reset(); + (mockLogger.warn).calls.reset(); + httpMock.get.calls.reset(); + + xsrfService = new StarkXSRFServiceHelper(appConfig, mockLogger, httpMock, mockDocument, mockInjectorService, mockXsrfConfig); + }); + /* tslint:enable:no-element-overwrite */ + + describe("configureXHR", () => { + it("should add the necessary options to the XHR object in order to enable XSRF protection", () => { + spyOn(xsrfService, "getXSRFToken").and.returnValue(mockXSRFToken); + + const mockXHR: XMLHttpRequest = new XMLHttpRequest(); + mockXHR.open("GET", "some/url"); + + spyOn(mockXHR, "setRequestHeader"); + + xsrfService.configureXHR(mockXHR); + + expect(xsrfService.getXSRFToken).toHaveBeenCalledTimes(1); + expect(mockXHR.setRequestHeader).toHaveBeenCalledTimes(1); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith(StarkHttpHeaders.XSRF_TOKEN, mockXSRFToken); + expect(mockXHR.withCredentials).toBe(true); + }); + + it("should NOT add any options to the XHR object if the XSRF token is not yet stored", () => { + spyOn(xsrfService, "getXSRFToken").and.returnValue(undefined); + + const mockXHR: XMLHttpRequest = new XMLHttpRequest(); + mockXHR.open("GET", "some/url"); + + spyOn(mockXHR, "setRequestHeader"); + + xsrfService.configureXHR(mockXHR); + + expect(xsrfService.getXSRFToken).toHaveBeenCalledTimes(1); + expect(mockXHR.setRequestHeader).not.toHaveBeenCalled(); + expect(mockXHR.withCredentials).toBe(false); + }); + + it("should THROW an error when it was called without calling the XHE open() method before", () => { + const mockXHR: XMLHttpRequest = new XMLHttpRequest(); + + expect(() => xsrfService.configureXHR(mockXHR)).toThrowError(/open\(\) method has not been invoked/); + }); + }); + + describe("configureHttpRequest", () => { + it("should create a new Angular HttpRequest with the XSRF protection enabled if the HTTP method is POST, PUT, PATCH or DELETE", () => { + spyOn(xsrfService, "getXSRFToken").and.returnValue(mockXSRFToken); + + const stateChangingMethods: string[] = ["POST", "PUT", "PATCH", "DELETE"]; + + function headersShouldBeInitialized(httpMethod: string): boolean { + return httpMethod === "PUT" || httpMethod === "PATCH"; + } + + for (const stateChangingMethod of stateChangingMethods) { + (xsrfService.getXSRFToken).calls.reset(); + + let mockHttpRequest: HttpRequest = new HttpRequest(stateChangingMethod, "dummy/url"); + + if (headersShouldBeInitialized(stateChangingMethod)) { + mockHttpRequest = mockHttpRequest.clone({ headers: mockHttpRequest.headers.set(dummyHeader, "dummy value") }); + } + + const protectedConfig: HttpRequest = xsrfService.configureHttpRequest(mockHttpRequest); + + expect(xsrfService.getXSRFToken).toHaveBeenCalledTimes(1); + expect(protectedConfig).not.toBe(mockHttpRequest); + expect(protectedConfig).not.toEqual(mockHttpRequest); + expect(protectedConfig.withCredentials).toBe(true); + expect(protectedConfig.headers).toBeDefined(); + expect(protectedConfig.headers.get(StarkHttpHeaders.XSRF_TOKEN)).toBe(mockXSRFToken); + + if (headersShouldBeInitialized(stateChangingMethod)) { + expect(protectedConfig.headers.get(dummyHeader)).toBe("dummy value"); + } + } + }); + + it("should leave the HttpRequest 'as is' if the XSRF token is not yet stored", () => { + spyOn(xsrfService, "getXSRFToken").and.returnValue(undefined); + + const stateChangingMethods: string[] = ["POST", "PUT", "PATCH", "DELETE"]; + + for (const stateChangingMethod of stateChangingMethods) { + (xsrfService.getXSRFToken).calls.reset(); + + const mockHttpRequest: HttpRequest = new HttpRequest(stateChangingMethod, "dummy/url"); + + const protectedRequest: HttpRequest = xsrfService.configureHttpRequest(mockHttpRequest); + + expect(xsrfService.getXSRFToken).toHaveBeenCalledTimes(1); + expect(protectedRequest).toEqual(mockHttpRequest.clone({ withCredentials: true })); + expect(protectedRequest.withCredentials).toBe(true); + expect(protectedRequest.headers.keys().length).toBe(0); + } + }); + + it("should only add 'withCredentials: true' if the HTTP method is not POST, PUT, PATCH nor DELETE", () => { + spyOn(xsrfService, "getXSRFToken"); + + const nonStateChangingMethods: string[] = ["GET", "HEAD", "CONNECT", "OPTIONS", "TRACE"]; + + function headersShouldBeInitialized(httpMethod: string): boolean { + return httpMethod === "GET" || httpMethod === "OPTIONS"; + } + + for (const nonStateChangingMethod of nonStateChangingMethods) { + let mockHttpRequest: HttpRequest = new HttpRequest(nonStateChangingMethod, "dummy/url"); + + if (headersShouldBeInitialized(nonStateChangingMethod)) { + mockHttpRequest = mockHttpRequest.clone({ headers: mockHttpRequest.headers.set(dummyHeader, "some value") }); + } + + const protectedRequest: HttpRequest = xsrfService.configureHttpRequest(mockHttpRequest); + + expect(xsrfService.getXSRFToken).not.toHaveBeenCalled(); + expect(protectedRequest).toEqual(mockHttpRequest.clone({ withCredentials: true })); + expect(protectedRequest.withCredentials).toBe(true); + + if (headersShouldBeInitialized(nonStateChangingMethod)) { + expect(protectedRequest.headers.keys().length).toBeGreaterThan(0); + expect(protectedRequest.headers.get(dummyHeader)).toBe("some value"); + expect(protectedRequest.headers.get(StarkHttpHeaders.XSRF_TOKEN)).toBeNull(); + } else { + expect(protectedRequest.headers.keys().length).toBe(0); + } + } + }); + }); + + describe("getXSRFToken", () => { + it("should return the XSRF token in case there is one already stored", () => { + xsrfService.setCurrentToken(mockXSRFToken); + + const xsrfToken: string = xsrfService.getXSRFToken(); + + expect(xsrfToken).toBe(mockXSRFToken); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it("should overwrite the XSRF cookie with the XSRF token that is already stored", () => { + xsrfService.setCurrentToken(mockXSRFToken); + spyOn(xsrfService, "setXSRFCookie").and.callThrough(); + + const xsrfToken: string = xsrfService.getXSRFToken(); + + expect(xsrfToken).toBe(mockXSRFToken); + expect(xsrfService.setXSRFCookie).toHaveBeenCalledTimes(1); + expect(xsrfService.setXSRFCookie).toHaveBeenCalledWith(xsrfToken); + expect(mockDocument.cookie.length).toBeGreaterThan(0); + const cookieOptions: any[] = mockDocument.cookie.split(";"); + expect(cookieOptions.length).toBe(3); + expect(cookieOptions[0]).toBe(xsrfService.getXsrfCookieName() + "=" + mockXSRFToken); + expect(cookieOptions[1]).toBe("path='/'"); + expect(cookieOptions[2]).toMatch(new RegExp("expires=.*(" + new Date().getFullYear() + ")")); + }); + + it("should return undefined and log a warning in case there is no XSRF token yet", () => { + xsrfService.setCurrentToken(undefined); + + const xsrfToken: undefined = xsrfService.getXSRFToken(); + + expect(xsrfToken).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + const warningMessage: string = (mockLogger.warn).calls.argsFor(0)[0]; + expect(warningMessage).toContain("no XSRF token found"); + }); + }); + + describe("storeXSRFToken", () => { + it("should store the XSRF token coming in the XSRF cookie if it has not been stored yet", () => { + xsrfService.setCurrentToken(undefined); + spyOn(xsrfService, "getXSRFCookie").and.returnValue(mockXSRFToken); + + xsrfService.storeXSRFToken(); + + expect(xsrfService.getXSRFCookie).toHaveBeenCalledTimes(1); + expect(xsrfService.getCurrentToken()).toBe(mockXSRFToken); + }); + + it("should store an undefined value if it has not been stored yet and the XSRF cookie does not exist or it is empty", () => { + xsrfService.setCurrentToken(undefined); + spyOn(xsrfService, "getXSRFCookie").and.returnValues(undefined, ""); + + xsrfService.storeXSRFToken(); + + expect(xsrfService.getXSRFCookie).toHaveBeenCalledTimes(1); + expect(xsrfService.getCurrentToken()).toBeUndefined(); + + (xsrfService.getXSRFCookie).calls.reset(); + xsrfService.storeXSRFToken(); + + expect(xsrfService.getXSRFCookie).toHaveBeenCalledTimes(1); + expect(xsrfService.getCurrentToken()).toBeUndefined(); + }); + + it("should just overwrite the XSRF cookie with the XSRF token that is already stored", () => { + xsrfService.setCurrentToken(mockXSRFToken); + spyOn(xsrfService, "setXSRFCookie").and.callThrough(); + + xsrfService.storeXSRFToken(); + + expect(xsrfService.setXSRFCookie).toHaveBeenCalledTimes(1); + expect(xsrfService.setXSRFCookie).toHaveBeenCalledWith(xsrfService.getCurrentToken()); + expect(mockDocument.cookie.length).toBeGreaterThan(0); + const cookieOptions: any[] = mockDocument.cookie.split(";"); + expect(cookieOptions.length).toBe(3); + expect(cookieOptions[0]).toBe(xsrfService.getXsrfCookieName() + "=" + mockXSRFToken); + expect(cookieOptions[1]).toBe("path='/'"); + expect(cookieOptions[2]).toMatch(new RegExp("expires=.*(" + new Date().getFullYear() + ")")); + }); + }); + + describe("pingBackends", () => { + it("should trigger an HTTP call to every backend defined in the application configuration", fakeAsync(() => { + httpMock.get.and.returnValue(of(new HttpResponse({ body: "ping OK" }))); + + xsrfService.pingBackends(); + tick(); + + expect(httpMock.get).toHaveBeenCalledTimes(appConfig.backends.size); + const httpCalls: CallInfo[] = httpMock.get.calls.all(); + let callIndex: number = 0; + + appConfig.backends.forEach((backendConfig: StarkBackend) => { + expect(httpCalls[callIndex].args[0]).toBe(backendConfig.url); + expect(httpCalls[callIndex].args[1]).toEqual({ observe: "response", responseType: "text" }); + callIndex++; + }); + + expect(mockLogger.error).not.toHaveBeenCalled(); + })); + + it("should log an error when the HTTP call to a backend failed", fakeAsync(() => { + const failingBackends: StarkBackend[] = [mockBackend1, mockBackend3]; + + httpMock.get.and.callFake((url: string) => { + if (failingBackends.map((failingBackend: StarkBackend) => failingBackend.url).indexOf(url) !== -1) { + return throwError(new HttpErrorResponse({ error: "ping failed" })); + } else { + return of(new HttpResponse({ body: "ping OK" })); + } + }); + + xsrfService.pingBackends(); + tick(); + + expect(httpMock.get).toHaveBeenCalledTimes(appConfig.backends.size); + const httpCalls: CallInfo[] = httpMock.get.calls.all(); + let httpCallIdx: number = 0; + + appConfig.backends.forEach((backendConfig: StarkBackend) => { + expect(httpCalls[httpCallIdx].args[0]).toBe(backendConfig.url); + expect(httpCalls[httpCallIdx].args[1]).toEqual({ observe: "response", responseType: "text" }); + httpCallIdx++; + }); + + expect(mockLogger.error).toHaveBeenCalledTimes(failingBackends.length); + const logErrorCalls: CallInfo[] = (mockLogger.error).calls.all(); + let logErrorCallIdx: number = 0; + + for (const failingBackend of failingBackends) { + expect(logErrorCalls[logErrorCallIdx].args[0]).toContain(failingBackend.name); + logErrorCallIdx++; + } + })); + + it("should NOT trigger any HTTP call until the waitBeforePinging observable emits", () => { + const mockWaitBeforePinging$: Subject = new Subject(); + spyOn(xsrfService, "getWaitBeforePingingObs").and.returnValue(mockWaitBeforePinging$); + + xsrfService.pingBackends(); + + expect(httpMock.get).not.toHaveBeenCalled(); + + mockWaitBeforePinging$.next("stop waiting"); + mockWaitBeforePinging$.complete(); + + expect(httpMock.get).toHaveBeenCalledTimes(appConfig.backends.size); + }); + }); + + class StarkXSRFServiceHelper extends StarkXSRFServiceImpl { + public constructor( + applicationConfig: StarkApplicationConfig, + logger: StarkLoggingService, + httpClient: HttpClient, + document: Document, + injector: Injector, + config: StarkXSRFConfig + ) { + super(applicationConfig, logger, httpClient, document, injector, config); + } + + public getXSRFCookie(): string | undefined { + return super.getXSRFCookie(); + } + + public setXSRFCookie(xsrfToken: string): void { + super.setXSRFCookie(xsrfToken); + } + + public getWaitBeforePingingObs(): Observable { + return super.getWaitBeforePingingObs(); + } + + public getXsrfCookieName(): string { + return this.xsrfCookieName; + } + + public getCurrentToken(): string | undefined { + return this.currentToken; + } + + public setCurrentToken(token: string | undefined): void { + this.currentToken = token; + } + } +}); diff --git a/packages/stark-core/src/modules/xsrf/services/xsrf.service.ts b/packages/stark-core/src/modules/xsrf/services/xsrf.service.ts new file mode 100644 index 0000000000..229a41e387 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/services/xsrf.service.ts @@ -0,0 +1,210 @@ +/* tslint:disable:completed-docs*/ +import { Inject, Injectable, Injector } from "@angular/core"; +import { DOCUMENT } from "@angular/common"; +import { HttpClient, HttpErrorResponse, HttpHeaders, HttpRequest } from "@angular/common/http"; +import moment from "moment"; +import { Observable, of, from } from "rxjs"; +import { take } from "rxjs/operators"; +import { StarkXSRFService, starkXSRFServiceName } from "./xsrf.service.intf"; +import { STARK_XSRF_CONFIG, StarkXSRFConfig } from "./xsrf-config.intf"; +import { StarkHttpHeaders } from "../../http/constants"; +import { StarkHttpStatusCodes } from "../../http/enumerators"; +import { STARK_APP_CONFIG, StarkApplicationConfig } from "../../../configuration/entities"; +import { StarkBackend, StarkHttpErrorWrapper, StarkHttpErrorWrapperImpl } from "../../http/entities"; +import { StarkLoggingService, STARK_LOGGING_SERVICE } from "../../logging/services/logging.service.intf"; + +/** + * Service to get/store the XSRF token to be used with the different backends. + * It also adds the XSRF configuration to XHR objects for those HTTP requests not performed using StarkHttpService or Angular's HttpClient. + */ +@Injectable() +export class StarkXSRFServiceImpl implements StarkXSRFService { + protected xsrfCookieName: string = "XSRF-TOKEN"; + protected currentToken: string | undefined; + + public constructor( + @Inject(STARK_APP_CONFIG) public appConfig: StarkApplicationConfig, + @Inject(STARK_LOGGING_SERVICE) public logger: StarkLoggingService, + private httpClient: HttpClient, + @Inject(DOCUMENT) public document: Document, + private injector: Injector, + @Inject(STARK_XSRF_CONFIG) public configOptions?: StarkXSRFConfig + ) { + this.logger.debug(starkXSRFServiceName + " loaded"); + } + + public configureXHR(xhr: XMLHttpRequest): void { + // in order to be able to configure the XHR object, we should call the setRequestHeader to add the XSRF header + // however the open() method should be called first, so we throw an error if that is not case + // see: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader + if (xhr.readyState === XMLHttpRequest.OPENED) { + const xsrfToken: string | undefined = this.getXSRFToken(); + + if (typeof xsrfToken !== "undefined") { + // Enforce the 'withCredentials' property flag on every XHR object. + // We leverage "credentialed" requests that are aware of HTTP cookies (necessary for XSRF to work with multiple backends) + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Requests_with_credentials + xhr.withCredentials = true; + + xhr.setRequestHeader(StarkHttpHeaders.XSRF_TOKEN, xsrfToken); + } + } else { + throw new Error( + starkXSRFServiceName + + ": cannot set headers to XHR object because its open() method has not been invoked.\n" + + "Make sure that the XHR open() method is called first before calling " + + starkXSRFServiceName + + " configureXHR()" + ); + } + } + + public configureHttpRequest(request: HttpRequest): HttpRequest { + if (request.method.match(/POST|PUT|PATCH|DELETE/)) { + const xsrfToken: string | undefined = this.getXSRFToken(); + + if (typeof xsrfToken !== "undefined") { + const newHeaders: HttpHeaders = request.headers.set(StarkHttpHeaders.XSRF_TOKEN, xsrfToken); + + return request.clone({ + headers: newHeaders, + // Enforce the 'withCredentials' property flag on every XHR object created by Angular $http. + // We leverage "credentialed" requests that are aware of HTTP cookies (necessary for XSRF to work with multiple backends) + // https://angular.io/api/common/http/HttpRequest#withCredentials + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Requests_with_credentials + withCredentials: true + }); + } + } + + // in any case the "withCredentials: true" should be added to ALL requests, otherwise the browser won't accept the XSRF cookie from the backend! + // see: https://angular.io/api/common/http/HttpRequest#withCredentials + // see: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Requests_with_credentials + return request.clone({ withCredentials: true }); + } + + public getXSRFToken(): string | undefined { + const xsrfToken: string | undefined = this.currentToken; + + if (typeof xsrfToken === "undefined") { + const errorMsg: string = + starkXSRFServiceName + + ": no XSRF token found. This could be due to:\n" + + "- the backend has not sent the XSRF token properly, either the cookie was not sent or it has a different name\n" + + "- the application did not store the XSRF token correctly, either it has a different name or it comes from a different origin"; + + this.logger.warn(errorMsg); + // could throw an error: throw new Error(errorMsg); + } else { + // overwrite the cookie with the current token to ensure that we always send the same token + // regardless of the new tokens sent by the backend(s) in every response + this.setXSRFCookie(xsrfToken); + } + + return xsrfToken; + } + + public storeXSRFToken(): void { + if (this.currentToken) { + // overwrite the cookie with the token we stored (we don't care about the rest of tokens but just the one we stored) + this.setXSRFCookie(this.currentToken); + } else { + // store the token only if it is not stored yet + const xsrfCookie: string | undefined = this.getXSRFCookie(); + this.currentToken = xsrfCookie && xsrfCookie !== "" ? xsrfCookie : undefined; + } + } + + public pingBackends(): void { + const backendsMap: Map = this.appConfig.getBackends(); + + const waitFor$: Observable = this.getWaitBeforePingingObs(); + + waitFor$.pipe(take(1)).subscribe(() => { + backendsMap.forEach((backendConfig: StarkBackend) => { + // here the Angular HttpClient is used instead of the StarkHttpService because the response can be anything + // and the StarkHttpService expects only JSON responses causing it to throw an exception + this.httpClient + .get(backendConfig.url, { + observe: "response", // full response, not only the body + responseType: "text" // body as text to allow any kind of response and avoid having weird exceptions as with the StarkHttpService + }) + .subscribe({ + // error: (errorWrapper: StarkHttpErrorWrapper) => { + error: (errorResponse: HttpErrorResponse) => { + // the backend might return 404 Not Found, but it will still send the cookie + if (errorResponse.status !== StarkHttpStatusCodes.HTTP_404_NOT_FOUND) { + const httpResponseHeaders: Map = new Map(); + for (const headerName of errorResponse.headers.keys()) { + httpResponseHeaders.set(headerName, errorResponse.headers.get(headerName)); + } + const errorWrapper: StarkHttpErrorWrapper = new StarkHttpErrorWrapperImpl( + errorResponse, + httpResponseHeaders, + errorResponse.error + ); + + const errorMsg: string = + starkXSRFServiceName + ": ping sent to backend '" + backendConfig.name + "' failed."; + this.logger.error(errorMsg, errorWrapper); + } + } + }); + }); + }); + } + + /** + * Extracts the Promise/Observable that the service should wait for before pinging all the backends. + * Such Promise/Observable is extracted from the configuration object (if any) passed to the StarkXSRFModule.forRoot() + */ + protected getWaitBeforePingingObs(): Observable { + if (this.configOptions && this.configOptions.waitBeforePinging) { + let waitBeforePingingFn: Function; + let waitBeforePingingDeps: any[] = []; + + if (typeof this.configOptions.waitBeforePinging === "object") { + waitBeforePingingFn = this.configOptions.waitBeforePinging.waitBeforePingingFn; + // for a StarkXSRFWaitBeforePingingLiteral we should get all the DI dependencies via the Angular Injector + waitBeforePingingDeps = this.configOptions.waitBeforePinging.deps.map((diDependency: any) => { + return this.injector.get(diDependency); + }); + } else { + waitBeforePingingFn = this.configOptions.waitBeforePinging; + } + + return from(waitBeforePingingFn(...waitBeforePingingDeps) || ["no wait"]); + } + + return of("no wait"); + } + + protected setXSRFCookie(xsrfToken: string): void { + const cookieExpiration: string = moment() + .add(40, "m") + .toDate() + .toUTCString(); // 40 minutes from now + + const cookieAttributes: string[] = [`${this.xsrfCookieName}=${xsrfToken}`, `path='/'`, `expires=${cookieExpiration}`]; + + this.document.cookie = cookieAttributes.join(";"); + } + + // code taken from ngx-cookie-service library (https://github.com/7leads/ngx-cookie-service/blob/master/lib/cookie-service/cookie.service.ts) + protected getXSRFCookie(): string | undefined { + const cookieRegExp: RegExp = this.getCookieRegExp(encodeURIComponent(this.xsrfCookieName)); + const result: RegExpExecArray | null = cookieRegExp.exec(this.document.cookie); + + if (result) { + return decodeURIComponent(result[1]); + } else { + return undefined; + } + } + + private getCookieRegExp(cookieName: string): RegExp { + const escapedName: string = cookieName.replace(/([\[\]\{\}\(\)\|\=\;\+\?\,\.\*\^\$])/gi, "\\$1"); + + return new RegExp("(?:^" + escapedName + "|;\\s*" + escapedName + ")=(.*?)(?:;|$)", "g"); + } +} diff --git a/packages/stark-core/src/modules/xsrf/testing.ts b/packages/stark-core/src/modules/xsrf/testing.ts new file mode 100644 index 0000000000..1cee51e911 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/testing.ts @@ -0,0 +1 @@ +export * from "./testing/xsrf.mock"; diff --git a/packages/stark-core/src/modules/xsrf/testing/xsrf.mock.ts b/packages/stark-core/src/modules/xsrf/testing/xsrf.mock.ts new file mode 100644 index 0000000000..29a461e3de --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/testing/xsrf.mock.ts @@ -0,0 +1,18 @@ +import { HttpRequest } from "@angular/common/http"; +import { StarkXSRFService } from "@nationalbankbelgium/stark-core"; + +/** + * Mock class of the StarkXSRFService interface. + * @link StarkXSRFService + */ +export class MockStarkXsrfService implements StarkXSRFService { + public configureHttpRequest: (request: HttpRequest) => HttpRequest = jasmine.createSpy("configureHttpRequest"); + + public configureXHR: (xhr: XMLHttpRequest) => void = jasmine.createSpy("configureXHR"); + + public getXSRFToken: () => string | undefined = jasmine.createSpy("getXSRFToken"); + + public pingBackends: () => void = jasmine.createSpy("pingBackends"); + + public storeXSRFToken: () => void = jasmine.createSpy("storeXSRFToken"); +} diff --git a/packages/stark-core/src/modules/xsrf/xsrf.module.ts b/packages/stark-core/src/modules/xsrf/xsrf.module.ts new file mode 100644 index 0000000000..5e5892ab43 --- /dev/null +++ b/packages/stark-core/src/modules/xsrf/xsrf.module.ts @@ -0,0 +1,62 @@ +import { ApplicationInitStatus, Inject, ModuleWithProviders, NgModule, Optional, SkipSelf } from "@angular/core"; +import { HTTP_INTERCEPTORS, HttpClientModule, HttpClientXsrfModule } from "@angular/common/http"; +import { from } from "rxjs"; +import { StarkXSRFServiceImpl, STARK_XSRF_SERVICE, StarkXSRFService, StarkXSRFConfig, STARK_XSRF_CONFIG } from "./services"; +import { StarkHttpHeaders } from "../http/constants"; +import { StarkXSRFHttpInterceptor } from "./interceptors/http-xsrf.interceptor"; + +@NgModule({ + imports: [ + HttpClientModule, + HttpClientXsrfModule.withOptions({ + // Name of cookie containing the XSRF token. Default value in Angular is 'XSRF-TOKEN' + // https://angular.io/guide/http#security-xsrf-protection + cookieName: "XSRF-TOKEN", + // Name of HTTP header to populate with the XSRF token. Default value in Angular is 'X-XSRF-TOKEN'. + // https://angular.io/guide/http#security-xsrf-protection + headerName: StarkHttpHeaders.XSRF_TOKEN + }) + ] +}) +export class StarkXSRFModule { + /** + * Instantiates the services only once since they should be singletons + * so the forRoot() should be called only by the AppModule + * @link https://angular.io/guide/singleton-services#forroot + * @param xsrfConfig - Object containing the configuration (if any) for the XSRF service + * @returns a module with providers + */ + public static forRoot(xsrfConfig?: StarkXSRFConfig): ModuleWithProviders { + return { + ngModule: StarkXSRFModule, + providers: [ + { provide: STARK_XSRF_CONFIG, useValue: xsrfConfig }, + { provide: STARK_XSRF_SERVICE, useClass: StarkXSRFServiceImpl }, + { provide: HTTP_INTERCEPTORS, useClass: StarkXSRFHttpInterceptor, multi: true } // Add the StarkXSRFHttpInterceptor as an Http interceptor to handle missing XSRF token + ] + }; + } + + /** + * Prevents this module from being re-imported + * @link https://angular.io/guide/singleton-services#prevent-reimport-of-the-coremodule + * @param parentModule - the parent module + */ + public constructor( + @Optional() + @SkipSelf() + parentModule: StarkXSRFModule, + @Inject(STARK_XSRF_SERVICE) xsrfService: StarkXSRFService, + appInitStatus: ApplicationInitStatus + ) { + if (parentModule) { + throw new Error("StarkXSRFModule is already loaded. Import it in the AppModule only"); + } + + // this logic cannot be executed in an APP_INITIALIZER factory because the StarkXsrfService uses the StarkLoggingService + // which needs the "logging" state to be already defined in the Store (which NGRX defines internally via APP_INITIALIZER factories :p) + from(appInitStatus.donePromise).subscribe(() => { + xsrfService.pingBackends(); + }); + } +} diff --git a/packages/stark-core/testing/public_api.ts b/packages/stark-core/testing/public_api.ts index 9c98543a29..eeb0bdc78b 100644 --- a/packages/stark-core/testing/public_api.ts +++ b/packages/stark-core/testing/public_api.ts @@ -8,5 +8,6 @@ export * from "../src/modules/logging/testing"; export * from "../src/modules/routing/testing"; export * from "../src/modules/session/testing"; export * from "../src/modules/user/testing"; +export * from "../src/modules/xsrf/testing"; // This file only reexports content of the `src/modules/**/testing` folders. Keep it that way. diff --git a/showcase/src/app/app.module.ts b/showcase/src/app/app.module.ts index 58d7a3b0bf..4eb62eff0f 100644 --- a/showcase/src/app/app.module.ts +++ b/showcase/src/app/app.module.ts @@ -18,9 +18,10 @@ import { MatCheckboxModule } from "@angular/material/checkbox"; import { MatListModule } from "@angular/material/list"; import { MatSidenavModule } from "@angular/material/sidenav"; import { MatTooltipModule } from "@angular/material/tooltip"; -import { SharedModule } from "./shared/shared.module"; import { DateAdapter } from "@angular/material/core"; -import { filter } from "rxjs/operators"; +import { SharedModule } from "./shared/shared.module"; +import { Observable, of } from "rxjs"; +import { filter, map } from "rxjs/operators"; import { STARK_APP_CONFIG, @@ -42,7 +43,8 @@ import { StarkSettingsModule, StarkSettingsService, StarkUser, - StarkUserModule + StarkUserModule, + StarkXSRFModule } from "@nationalbankbelgium/stark-core"; import { @@ -127,6 +129,22 @@ export function initRouterLog(router: UIRouter): Function { return () => logRegisteredStates(router.stateService.get()); } +export function getXsrfWaitBeforePinging(sessionService: StarkSessionService): Observable { + let waitFor$: Observable = of("production"); // no need to wait on production + + if (ENV !== "production") { + // wait for the user to be logged in (useful when targeting a live backend on DEV) + waitFor$ = sessionService.getCurrentUser().pipe( + filter((user?: StarkUser) => typeof user !== "undefined"), + map(() => { + return "dev login"; + }) + ); + } + + return waitFor$; +} + // Application Redux State export interface State { // reducer interfaces @@ -193,6 +211,12 @@ export const metaReducers: MetaReducer[] = ENV !== "production" ? [logger StarkSettingsModule.forRoot(), StarkRoutingModule.forRoot(), StarkUserModule.forRoot(), + StarkXSRFModule.forRoot({ + waitBeforePinging: { + waitBeforePingingFn: getXsrfWaitBeforePinging, + deps: [STARK_SESSION_SERVICE] + } + }), SharedModule, DemoModule, NewsModule,