Add custom XSRF interceptor which works with cross domain requests, using new DSPACE-XSRF-TOKEN header

This commit is contained in:
Tim Donohue
2020-08-06 12:37:43 -05:00
parent 3f95b5009f
commit 5b23ffa2dc
3 changed files with 251 additions and 0 deletions

View File

@@ -42,6 +42,7 @@ import { BrowserModule } from '@angular/platform-browser';
import { ForbiddenComponent } from './forbidden/forbidden.component';
import { AuthInterceptor } from './core/auth/auth.interceptor';
import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
export function getBase() {
return environment.ui.nameSpace;
@@ -108,6 +109,12 @@ const PROVIDERS = [
useClass: LocaleInterceptor,
multi: true
},
// register XsrfInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
useClass: XsrfInterceptor,
multi: true
},
...DYNAMIC_MATCHER_PROVIDERS,
];

View File

@@ -0,0 +1,151 @@
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 { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { RestRequestMethod } from '../data/rest-request-method';
import { CookieService } from '../services/cookie.service';
import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock';
import { XsrfInterceptor } from './xsrf.interceptor';
/**
* A Mock TokenExtractor which just returns whatever token it is initialized with.
* This mock object is injected into our XsrfInterceptor, so that it always finds
* the same fake XSRF token.
*/
class MockTokenExtractor extends HttpXsrfTokenExtractor {
constructor(private token: string | null) { super(); }
getToken(): string | null { return this.token; }
}
describe(`XsrfInterceptor`, () => {
let service: DspaceRestService;
let httpMock: HttpTestingController;
let cookieService: CookieService;
// Create a MockTokenExtractor which always returns "test-token". This will
// be used as the test HttpXsrfTokenExtractor, see below.
const testToken = 'test-token';
const mockTokenExtractor = new MockTokenExtractor(testToken);
// Mock payload/statuses are dummy content as we are not testing the results
// of any below requests. We are only testing for X-XSRF-TOKEN header.
const mockPayload = {
id: 1
};
const mockStatusCode = 200;
const mockStatusText = 'SUCCESS';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DspaceRestService,
{
provide: HTTP_INTERCEPTORS,
useClass: XsrfInterceptor,
multi: true,
},
{ provide: HttpXsrfTokenExtractor, useValue: mockTokenExtractor },
{ provide: CookieService, useValue: new CookieServiceMock() }
],
});
service = TestBed.get(DspaceRestService);
httpMock = TestBed.get(HttpTestingController);
cookieService = TestBed.get(CookieService);
});
it('should change withCredentials to true at all times', (done) => {
service.request(RestRequestMethod.POST, 'server/api/core/items', 'test', { withCredentials: false }).subscribe((response) => {
expect(response).toBeTruthy();
done();
});
const httpRequest = httpMock.expectOne('server/api/core/items');
expect(httpRequest.request.withCredentials).toBeTrue();
httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
});
it('should add an X-XSRF-TOKEN header when we are sending an HTTP POST request', (done) => {
service.request(RestRequestMethod.POST, 'server/api/core/items', 'test').subscribe((response) => {
expect(response).toBeTruthy();
done();
});
const httpRequest = httpMock.expectOne('server/api/core/items');
expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeTrue();
expect(httpRequest.request.withCredentials).toBeTrue();
const token = httpRequest.request.headers.get('X-XSRF-TOKEN');
expect(token).toBeDefined();
expect(token).toBe(testToken.toString());
httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
});
it('should NOT add an X-XSRF-TOKEN header when we are sending an HTTP GET request', (done) => {
service.request(RestRequestMethod.GET, 'server/api/core/items').subscribe((response) => {
expect(response).toBeTruthy();
done();
});
const httpRequest = httpMock.expectOne('server/api/core/items');
expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeFalse();
expect(httpRequest.request.withCredentials).toBeTrue();
httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
});
it('should NOT add an X-XSRF-TOKEN header when we are sending an HTTP POST to an untrusted URL', (done) => {
// POST to a URL which is NOT our REST API
service.request(RestRequestMethod.POST, 'https://untrusted.com', 'test').subscribe((response) => {
expect(response).toBeTruthy();
done();
});
const httpRequest = httpMock.expectOne('https://untrusted.com');
expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeFalse();
expect(httpRequest.request.withCredentials).toBeTrue();
httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
});
it('should update XSRF-TOKEN cookie when DSPACE-XSRF-TOKEN header found in response', (done) => {
// Create a mock XSRF token to be returned in response within DSPACE-XSRF-TOKEN header
const mockNewXSRFToken = '123456789abcdefg';
service.request(RestRequestMethod.GET, 'server/api/core/items').subscribe((response) => {
expect(response).toBeTruthy();
// ensure mock data (added in below flush() call) is returned.
expect(response.statusCode).toBe(mockStatusCode);
expect(response.statusText).toBe(mockStatusText);
// ensure mock XSRF token is in response
expect(response.headers.has('DSPACE-XSRF-TOKEN')).toBeTrue();
const token = response.headers.get('DSPACE-XSRF-TOKEN');
expect(token).toBeDefined();
expect(token).toBe(mockNewXSRFToken.toString());
// 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 response (including sending back a new XSRF token in header)
httpRequest.flush(mockPayload, {
headers: new HttpHeaders().set('DSPACE-XSRF-TOKEN', mockNewXSRFToken),
status: mockStatusCode,
statusText: mockStatusText
});
});
});

View File

@@ -0,0 +1,93 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http';
import { Observable } from 'rxjs/internal/Observable';
import { tap, filter } from 'rxjs/operators';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
import { CookieService } from '../services/cookie.service';
/**
* Custom Http Interceptor intercepting Http Requests & Responses to
* exchange XSRF/CSRF tokens with the backend.
*
* DSpace has a custom XSRF token process in order to support the UI and backend
* running on entirely separate domains:
*
* 1. Backend generates XSRF token & stores in a *server-side* (only) cookie
* named DSPACE-XSRF-COOKIE. This cookie is not readable to Angular, but is
* returned (by user's browser) on every subsequent request to backend.
* 2. Backend also sends XSRF token in a header named DSPACE-XSRF-TOKEN to
* Angular.
* 3. This interceptor looks for DSPACE-XSRF-TOKEN header in a response. If
* found, its value is saved to a *client-side* (only) cookie named XSRF-TOKEN.
* 4. Whenever Angular is making a mutating request (POST, PUT, DELETE, etc),
* this interceptor checks for that client-side XSRF-TOKEN cookie. If found,
* its value is sent to the backend in the X-XSRF-TOKEN header.
* 5. On backend, the X-XSRF-TOKEN header is received & compared to the current
* value of the *server-side* cookie named DSPACE-XSRF-COOKIE. If tokens
* match, the request is accepted. If tokens don't match a 403 is returned.
*
* In summary, the XSRF token is ALWAYS sent to/from the Angular UI and backend
* via *headers*. Both the Angular UI and backend have cookies which retain the
* last known value of the XSRF token for verification purposes.
*/
@Injectable()
export class XsrfInterceptor implements HttpInterceptor {
constructor(private tokenExtractor: HttpXsrfTokenExtractor, private cookieService: CookieService) {
}
/**
* Intercept http requests and add the XSRF/CSRF token to the X-Forwarded-For header
* @param httpRequest
* @param next
*/
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 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:
// 1. Ensure a user's browser sends the server-side XSRF cookie back to
// the backend
// 2. Ensure a user's browser saves changes to the server-side XSRF
// cookie (to ensure it is kept in sync with client side cookie)
req = req.clone({ withCredentials: true });
// Get request URL
const reqUrl = req.url.toLowerCase();
// Get root URL of configured REST API
const restUrl = new RESTURLCombiner('/').toString().toLowerCase();
// Skip any non-mutating request. This is because our REST API does NOT
// require CSRF verification for read-only requests like GET or HEAD
// Also skip any request which is NOT to our trusted/configured REST API
if (req.method !== 'GET' && req.method !== 'HEAD' && reqUrl.startsWith(restUrl)) {
// parse token from XSRF-TOKEN (client-side) cookie
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) });
}
}
// 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<any>) => {
// 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')) {
// 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);
}
})
) as any;
}
}