Bug fix: Ensure we also look for a change in XSRF token during error responses.

This commit is contained in:
Tim Donohue
2021-01-08 15:59:07 -06:00
parent 43c37c2b75
commit 31b346d932
2 changed files with 75 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpHeaders, HTTP_INTERCEPTORS, HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http'; import { HttpHeaders, HTTP_INTERCEPTORS, HttpResponse, HttpXsrfTokenExtractor, HttpErrorResponse } from '@angular/common/http';
import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
@@ -148,4 +148,38 @@ describe(`XsrfInterceptor`, () => {
}); });
}); });
it('should update XSRF-TOKEN cookie when DSPACE-XSRF-TOKEN header found in error response', (done) => {
// Create a mock XSRF token to be returned in response within DSPACE-XSRF-TOKEN header
// In this situation, we are mocking a CSRF token mismatch, which causes our backend to send a new token
const mockNewXSRFToken = '987654321zyxwut';
const mockErrorCode = 403;
const mockErrorText = 'Forbidden';
const mockErrorMessage = 'CSRF token mismatch';
service.request(RestRequestMethod.GET, 'server/api/core/items').subscribe({
error: (error) => {
expect(error).toBeTruthy();
// ensure mock error (added in below flush() call) is returned.
expect(error.statusCode).toBe(mockErrorCode);
expect(error.statusText).toBe(mockErrorText);
// ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header
expect(cookieService.get('XSRF-TOKEN')).toBeDefined();
expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString());
done();
}
});
const httpRequest = httpMock.expectOne('server/api/core/items');
// Flush & create mock error response (including sending back a new XSRF token in header)
httpRequest.flush(mockErrorMessage, {
headers: new HttpHeaders().set('DSPACE-XSRF-TOKEN', mockNewXSRFToken),
status: mockErrorCode,
statusText: mockErrorText
});
});
}); });

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http'; import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { tap, filter } from 'rxjs/operators'; import { tap, catchError } from 'rxjs/operators';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
import { throwError } from 'rxjs';
/** /**
* Custom Http Interceptor intercepting Http Requests & Responses to * Custom Http Interceptor intercepting Http Requests & Responses to
@@ -42,6 +43,11 @@ export class XsrfInterceptor implements HttpInterceptor {
* @param next * @param next
*/ */
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular)
const requestCsrfHeader = 'X-XSRF-TOKEN';
// Name of XSRF header we may receive in responses from backend
const responseCsrfHeader = 'DSPACE-XSRF-TOKEN';
// Ensure EVERY request from Angular includes "withCredentials: true". // Ensure EVERY request from Angular includes "withCredentials: true".
// This allows Angular to receive & send cookies via a CORS request (to // This allows Angular to receive & send cookies via a CORS request (to
// the backend). ONLY requests with credentials will: // the backend). ONLY requests with credentials will:
@@ -65,29 +71,47 @@ export class XsrfInterceptor implements HttpInterceptor {
const token = this.tokenExtractor.getToken() as string; const token = this.tokenExtractor.getToken() as string;
// send token in request's X-XSRF-TOKEN header (anti-CSRF security) to backend // send token in request's X-XSRF-TOKEN header (anti-CSRF security) to backend
const headerName = 'X-XSRF-TOKEN'; if (token !== null && !req.headers.has(requestCsrfHeader)) {
if (token !== null && !req.headers.has(headerName)) { req = req.clone({ headers: req.headers.set(requestCsrfHeader, token) });
req = req.clone({ headers: req.headers.set(headerName, token) });
} }
} }
// Pass to next interceptor, but intercept EVERY response event as well // Pass to next interceptor, but intercept EVERY response event as well
return next.handle(req).pipe( return next.handle(req).pipe(
// Check event that came back...is it an HttpResponse from backend? // Check event that came back...is it an HttpResponse from backend?
filter((event) => event instanceof HttpResponse), tap((response) => {
tap((response: HttpResponse<any>) => { if (response instanceof HttpResponse) {
// For every response that comes back, check for the custom // For every response that comes back, check for the custom
// DSPACE-XSRF-TOKEN header sent from the backend. // DSPACE-XSRF-TOKEN header sent from the backend.
if (response.headers.has('DSPACE-XSRF-TOKEN')) { if (response.headers.has(responseCsrfHeader)) {
// value of header is a new XSRF token // value of header is a new XSRF token
const newToken = response.headers.get('DSPACE-XSRF-TOKEN'); this.saveXsrfToken(response.headers.get(responseCsrfHeader));
// save token value as a *new* value of our client-side
// XSRF-TOKEN cookie. (This is the same cookie we use to
// send back the X-XSRF-TOKEN header. See request logic above)
this.cookieService.remove('XSRF-TOKEN');
this.cookieService.set('XSRF-TOKEN', newToken);
} }
}) }
}),
catchError((error) => {
if (error instanceof HttpErrorResponse) {
// For every error that comes back, also check for the custom
// DSPACE-XSRF-TOKEN header sent from the backend.
if (error.headers.has(responseCsrfHeader)) {
// value of header is a new XSRF token
this.saveXsrfToken(error.headers.get(responseCsrfHeader));
}
}
// Return error response as is.
return throwError(error);
})
) as any; ) as any;
} }
/**
* Save XSRF token found in response
* @param token token found
*/
private saveXsrfToken(token: string) {
// Save token value as a *new* value of our client-side XSRF-TOKEN cookie.
// This is the cookie that is parsed by Angular's tokenExtractor(),
// which we will send back in the X-XSRF-TOKEN header per Angular best practices.
this.cookieService.remove('XSRF-TOKEN');
this.cookieService.set('XSRF-TOKEN', token);
}
} }