forked from NationalBankBelgium/stark
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(stark-core): implement XSRF protection in Stark Http module
ISSUES CLOSED: #115
- Loading branch information
1 parent
4678ee2
commit 563cca6
Showing
9 changed files
with
313 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./interceptors/http-xsrf.interceptor"; |
45 changes: 45 additions & 0 deletions
45
packages/stark-core/src/modules/http/interceptors/http-xsrf.interceptor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! | ||
// }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
52
packages/stark-core/src/modules/http/services/xsrf.service.intf.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
163
packages/stark-core/src/modules/http/services/xsrf.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from "./testing/http.mock"; | ||
export * from "./testing/xsrf.mock"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters