diff --git a/packages/stark-core/src/modules/http/http.module.ts b/packages/stark-core/src/modules/http/http.module.ts index 9466a0cd8f..2f5d1be92e 100644 --- a/packages/stark-core/src/modules/http/http.module.ts +++ b/packages/stark-core/src/modules/http/http.module.ts @@ -1,9 +1,25 @@ -import { ModuleWithProviders, NgModule, Optional, SkipSelf } from "@angular/core"; -import { HttpClientModule } from "@angular/common/http"; -import { STARK_HTTP_SERVICE, StarkHttpServiceImpl } from "./services"; +import { APP_INITIALIZER, ModuleWithProviders, NgModule, Optional, SkipSelf } from "@angular/core"; +import { HTTP_INTERCEPTORS, HttpClientModule, HttpClientXsrfModule } from "@angular/common/http"; +import { STARK_HTTP_SERVICE, StarkHttpServiceImpl, StarkXSRFServiceImpl, STARK_XSRF_SERVICE, StarkXSRFService } from "./services"; +import { StarkHttpHeaders } from "./constants"; +import { StarkXSRFHttpInterceptor } from "./interceptors/http-xsrf.interceptor"; + +export function xsrfTokenInitialization(xsrfService: StarkXSRFService): Function { + return () => xsrfService.pingBackends(); +} @NgModule({ - imports: [HttpClientModule] + 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 StarkHttpModule { /** @@ -15,7 +31,12 @@ export class StarkHttpModule { public static forRoot(): ModuleWithProviders { return { ngModule: StarkHttpModule, - providers: [{ provide: STARK_HTTP_SERVICE, useClass: StarkHttpServiceImpl }] + providers: [ + { provide: STARK_HTTP_SERVICE, useClass: StarkHttpServiceImpl }, + { provide: STARK_XSRF_SERVICE, useClass: StarkXSRFServiceImpl }, + { provide: HTTP_INTERCEPTORS, useClass: StarkXSRFHttpInterceptor, multi: true }, // Add the StarkXSRFHttpInterceptor as an interceptor to handle missing XSRF token + { provide: APP_INITIALIZER, useFactory: xsrfTokenInitialization, deps: [STARK_XSRF_SERVICE], multi: true } + ] }; } diff --git a/packages/stark-core/src/modules/http/interceptors.ts b/packages/stark-core/src/modules/http/interceptors.ts new file mode 100644 index 0000000000..9d0e959e16 --- /dev/null +++ b/packages/stark-core/src/modules/http/interceptors.ts @@ -0,0 +1 @@ +export * from "./interceptors/http-xsrf.interceptor"; diff --git a/packages/stark-core/src/modules/http/interceptors/http-xsrf.interceptor.ts b/packages/stark-core/src/modules/http/interceptors/http-xsrf.interceptor.ts new file mode 100644 index 0000000000..9c3c4917a5 --- /dev/null +++ b/packages/stark-core/src/modules/http/interceptors/http-xsrf.interceptor.ts @@ -0,0 +1,45 @@ +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( + tap((httpResponse: HttpEvent) => { + console.log("CCR==========> httpResponse", httpResponse); + this.xsrfService.storeXSRFToken(); + }) + ); + } + + // /** + // * Default HTTP response interceptor for Angular $http set in src/core/config/xsrf.config.ts. + // * @param response - The Angular $http response object of the intercepted response + // * @returns The Angular $http response object of the intercepted response 'as is'. The XSRF is extracted and stored by the XSRF service. + // */ + // public response: (response: IHttpResponse) => IHttpResponse = (response: IHttpResponse) => { + // this.xsrfService.storeXSRFToken(); + // + // return response; // IMPORTANT: always return the response! + // }; +} diff --git a/packages/stark-core/src/modules/http/services.ts b/packages/stark-core/src/modules/http/services.ts index 11c5533239..b359667380 100644 --- a/packages/stark-core/src/modules/http/services.ts +++ b/packages/stark-core/src/modules/http/services.ts @@ -1,2 +1,4 @@ export * from "./services/http.service"; export { STARK_HTTP_SERVICE, StarkHttpService } from "./services/http.service.intf"; +export * from "./services/xsrf.service"; +export { STARK_XSRF_SERVICE, StarkXSRFService } from "./services/xsrf.service.intf"; diff --git a/packages/stark-core/src/modules/http/services/xsrf.service.intf.ts b/packages/stark-core/src/modules/http/services/xsrf.service.intf.ts new file mode 100644 index 0000000000..459e4d578f --- /dev/null +++ b/packages/stark-core/src/modules/http/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/http/services/xsrf.service.ts b/packages/stark-core/src/modules/http/services/xsrf.service.ts new file mode 100644 index 0000000000..4a227e7a6f --- /dev/null +++ b/packages/stark-core/src/modules/http/services/xsrf.service.ts @@ -0,0 +1,163 @@ +import { Inject, Injectable } from "@angular/core"; +import { DOCUMENT } from "@angular/common"; +import { HttpHeaders, HttpRequest } from "@angular/common/http"; +import moment from "moment"; +import { StarkXSRFService, starkXSRFServiceName } from "./xsrf.service.intf"; +import { StarkHttpHeaders } from "../constants"; +import { StarkHttpStatusCodes } from "../enumerators"; +import { STARK_APP_CONFIG, StarkApplicationConfig } from "../../../configuration/entities"; +import { StarkBackend, StarkHttpErrorWrapper, StarkHttpRequestType } from "../entities"; +import { StarkHttpSerializerImpl } from "../serializer"; +import { StarkLoggingService, STARK_LOGGING_SERVICE } from "../../logging/services/logging.service.intf"; +import { StarkHttpService, STARK_HTTP_SERVICE } from "../services"; + +/** + * 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 { + private xsrfCookieName: string = "XSRF-TOKEN"; + private currentToken: string | undefined; + + public constructor( + @Inject(STARK_APP_CONFIG) public appConfig: StarkApplicationConfig, + @Inject(STARK_LOGGING_SERVICE) public logger: StarkLoggingService, + @Inject(STARK_HTTP_SERVICE) public httpService: StarkHttpService, + @Inject(DOCUMENT) public document: Document + ) {} + + 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://docs.angularjs.org/api/ng/service/$http#$http-arguments + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Requests_with_credentials + withCredentials: true + }); + } + } + + return request; + } + + 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 = this.getXSRFCookie(); + this.currentToken = xsrfCookie && xsrfCookie !== "" ? xsrfCookie : undefined; + } + } + + public pingBackends(): void { + console.log("CCR==========> pingBackends called!"); + const backendsMap: Map = this.appConfig.getBackends(); + + backendsMap.forEach((backendConfig: StarkBackend) => { + this.httpService + .executeCollectionRequest({ + backend: backendConfig, + requestType: StarkHttpRequestType.GET_COLLECTION, + resourcePath: "", + headers: new Map(), + queryParameters: new Map(), + serializer: new StarkHttpSerializerImpl() + }) + .subscribe({ + error: (errorWrapper: StarkHttpErrorWrapper) => { + // the backend might return 404 Not Found, but it will still send the cookie + if (errorWrapper.starkHttpStatusCode !== StarkHttpStatusCodes.HTTP_404_NOT_FOUND) { + const errorMsg: string = starkXSRFServiceName + ": ping sent to backend '" + backendConfig.name + "' failed."; + this.logger.error(errorMsg, errorWrapper); + } + } + }); + }); + } + + private 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) + private getXSRFCookie(): string { + const cookieRegExp: RegExp = this.getCookieRegExp(this.xsrfCookieName); + const result: RegExpExecArray | null = cookieRegExp.exec(this.document.cookie); + + if (result) { + return decodeURIComponent(result[1]); + } else { + return ""; + } + } + + 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/http/testing.ts b/packages/stark-core/src/modules/http/testing.ts index f99277bbf5..dfb825628c 100644 --- a/packages/stark-core/src/modules/http/testing.ts +++ b/packages/stark-core/src/modules/http/testing.ts @@ -1 +1,2 @@ export * from "./testing/http.mock"; +export * from "./testing/xsrf.mock"; diff --git a/packages/stark-core/src/modules/http/testing/xsrf.mock.ts b/packages/stark-core/src/modules/http/testing/xsrf.mock.ts new file mode 100644 index 0000000000..29a461e3de --- /dev/null +++ b/packages/stark-core/src/modules/http/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/logging/services/logging.service.ts b/packages/stark-core/src/modules/logging/services/logging.service.ts index 2eed1f9589..7d5f901213 100644 --- a/packages/stark-core/src/modules/logging/services/logging.service.ts +++ b/packages/stark-core/src/modules/logging/services/logging.service.ts @@ -1,12 +1,9 @@ /* tslint:disable:completed-docs*/ import uuid from "uuid"; - import { Serialize } from "cerialize"; - import { select, Store } from "@ngrx/store"; -import { Observable, Subject } from "rxjs"; - import { Inject, Injectable } from "@angular/core"; +import { Observable, Subject } from "rxjs"; import { StarkLoggingService, starkLoggingServiceName } from "./logging.service.intf"; import { STARK_APP_CONFIG, StarkApplicationConfig } from "../../../configuration/entities/application"; @@ -14,6 +11,7 @@ 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 "../../http/services"; import { StarkLogging, StarkLoggingImpl, StarkLogMessage, StarkLogMessageImpl, StarkLogMessageType } from "../entities"; import { StarkFlushLogMessages, StarkLogMessageAction } from "../actions"; import { selectStarkLogging } from "../reducers"; @@ -45,12 +43,10 @@ export class StarkLoggingServiceImpl implements StarkLoggingService { /** @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, + @Inject(STARK_XSRF_SERVICE) private xsrfService: StarkXSRFService ) { // ensuring that the app config is valid before doing anything StarkConfigurationUtil.validateConfig(this.appConfig, ["logging", "http"], starkLoggingServiceName); @@ -214,8 +210,7 @@ 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); + this.xsrfService.configureXHR(xhr); xhr.setRequestHeader(StarkHttpHeaders.CONTENT_TYPE, "application/json"); xhr.send(serializedData); } catch (e) {