Skip to content

Commit

Permalink
feat(stark-core): implement XSRF protection in Stark Http module
Browse files Browse the repository at this point in the history
ISSUES CLOSED: #115
  • Loading branch information
christophercr committed Sep 17, 2018
1 parent 360589d commit f2abbc7
Show file tree
Hide file tree
Showing 9 changed files with 313 additions and 15 deletions.
31 changes: 26 additions & 5 deletions packages/stark-core/src/modules/http/http.module.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand All @@ -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 }
]
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/stark-core/src/modules/http/interceptors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./interceptors/http-xsrf.interceptor";
Original file line number Diff line number Diff line change
@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const xsrfProtectedRequest: HttpRequest<any> = this.xsrfService.configureHttpRequest(request);

return next
.handle(xsrfProtectedRequest) // pass request through to the next request handler
.pipe(
tap((httpResponse: HttpEvent<any>) => {
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<any>) => IHttpResponse<any> = (response: IHttpResponse<any>) => {
// this.xsrfService.storeXSRFToken();
//
// return response; // IMPORTANT: always return the response!
// };
}
2 changes: 2 additions & 0 deletions packages/stark-core/src/modules/http/services.ts
Original file line number Diff line number Diff line change
@@ -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";
52 changes: 52 additions & 0 deletions packages/stark-core/src/modules/http/services/xsrf.service.intf.ts
Original file line number Diff line number Diff line change
@@ -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<StarkXSRFService> = new InjectionToken<StarkXSRFService>(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<any>): HttpRequest<any>;

/**
* 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;
}
163 changes: 163 additions & 0 deletions packages/stark-core/src/modules/http/services/xsrf.service.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
@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<any>): HttpRequest<any> {
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<string, StarkBackend> = this.appConfig.getBackends();

backendsMap.forEach((backendConfig: StarkBackend) => {
this.httpService
.executeCollectionRequest({
backend: backendConfig,
requestType: StarkHttpRequestType.GET_COLLECTION,
resourcePath: "",
headers: new Map<string, string>(),
queryParameters: new Map<string, string>(),
serializer: new StarkHttpSerializerImpl<any>()
})
.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, <any>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");
}
}
1 change: 1 addition & 0 deletions packages/stark-core/src/modules/http/testing.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./testing/http.mock";
export * from "./testing/xsrf.mock";
18 changes: 18 additions & 0 deletions packages/stark-core/src/modules/http/testing/xsrf.mock.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => HttpRequest<any> = 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");
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
/* 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";
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";
Expand Down Expand Up @@ -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<StarkCoreApplicationState>,
@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);
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit f2abbc7

Please sign in to comment.