mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Add custom XSRF interceptor which works with cross domain requests, using new DSPACE-XSRF-TOKEN header
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
|
151
src/app/core/xsrf/xsrf.interceptor.spec.ts
Normal file
151
src/app/core/xsrf/xsrf.interceptor.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
||||
});
|
93
src/app/core/xsrf/xsrf.interceptor.ts
Normal file
93
src/app/core/xsrf/xsrf.interceptor.ts
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user