mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-08 10:34:15 +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 { ForbiddenComponent } from './forbidden/forbidden.component';
|
||||||
import { AuthInterceptor } from './core/auth/auth.interceptor';
|
import { AuthInterceptor } from './core/auth/auth.interceptor';
|
||||||
import { LocaleInterceptor } from './core/locale/locale.interceptor';
|
import { LocaleInterceptor } from './core/locale/locale.interceptor';
|
||||||
|
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
|
||||||
|
|
||||||
export function getBase() {
|
export function getBase() {
|
||||||
return environment.ui.nameSpace;
|
return environment.ui.nameSpace;
|
||||||
@@ -108,6 +109,12 @@ const PROVIDERS = [
|
|||||||
useClass: LocaleInterceptor,
|
useClass: LocaleInterceptor,
|
||||||
multi: true
|
multi: true
|
||||||
},
|
},
|
||||||
|
// register XsrfInterceptor as HttpInterceptor
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: XsrfInterceptor,
|
||||||
|
multi: true
|
||||||
|
},
|
||||||
...DYNAMIC_MATCHER_PROVIDERS,
|
...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