diff --git a/src/app/core/xsrf/xsrf.interceptor.spec.ts b/src/app/core/xsrf/xsrf.interceptor.spec.ts index 007286302f..84f10b9e13 100644 --- a/src/app/core/xsrf/xsrf.interceptor.spec.ts +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/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 { RestRequestMethod } from '../data/rest-request-method'; 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 + }); + }); + }); diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index ceb401e4c4..7b5a66f27a 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -1,9 +1,10 @@ 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 { tap, filter } from 'rxjs/operators'; +import { tap, catchError } from 'rxjs/operators'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { CookieService } from '../services/cookie.service'; +import { throwError } from 'rxjs'; /** * Custom Http Interceptor intercepting Http Requests & Responses to @@ -42,6 +43,11 @@ export class XsrfInterceptor implements HttpInterceptor { * @param next */ intercept(req: HttpRequest, next: HttpHandler): Observable> { + // 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". // This allows Angular to receive & send cookies via a CORS request (to // the backend). ONLY requests with credentials will: @@ -65,29 +71,47 @@ export class XsrfInterceptor implements HttpInterceptor { const token = this.tokenExtractor.getToken() as string; // 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(headerName)) { - req = req.clone({ headers: req.headers.set(headerName, token) }); + if (token !== null && !req.headers.has(requestCsrfHeader)) { + req = req.clone({ headers: req.headers.set(requestCsrfHeader, token) }); } } // Pass to next interceptor, but intercept EVERY response event as well return next.handle(req).pipe( // Check event that came back...is it an HttpResponse from backend? - filter((event) => event instanceof HttpResponse), - tap((response: HttpResponse) => { + tap((response) => { + if (response instanceof HttpResponse) { // For every response that comes back, check for the custom // 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 - const newToken = response.headers.get('DSPACE-XSRF-TOKEN'); - - // 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); + this.saveXsrfToken(response.headers.get(responseCsrfHeader)); } - }) + } + }), + 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; } + + /** + * 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); + } }