diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts index 704922c5b5..063aad612f 100644 --- a/src/app/core/auth/auth-request.service.spec.ts +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -11,6 +11,7 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import objectContaining = jasmine.objectContaining; import { AuthStatus } from './models/auth-status.model'; import { RestRequestMethod } from '../data/rest-request-method'; +import { Observable, of as observableOf } from 'rxjs'; describe(`AuthRequestService`, () => { let halService: HALEndpointService; @@ -34,8 +35,8 @@ describe(`AuthRequestService`, () => { super(hes, rs, rdbs); } - protected createShortLivedTokenRequest(href: string): PostRequest { - return new PostRequest(this.requestService.generateRequestId(), href); + protected createShortLivedTokenRequest(href: string): Observable { + return observableOf(new PostRequest(this.requestService.generateRequestId(), href)); } } diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 5c0c3340c7..7c1f17dec2 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -100,14 +100,12 @@ export abstract class AuthRequestService { ); } /** - * Factory function to create the request object to send. This needs to be a POST client side and - * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow - * only the server IP to send a GET to this endpoint. + * Factory function to create the request object to send. * * @param href The href to send the request to * @protected */ - protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest; + protected abstract createShortLivedTokenRequest(href: string): Observable; /** * Send a request to retrieve a short-lived token which provides download access of restricted files @@ -117,7 +115,7 @@ export abstract class AuthRequestService { filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()), - map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), + switchMap((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), tap((request: RestRequest) => this.requestService.send(request)), switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), getFirstCompletedRemoteData(), diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts index 18d27340af..b41d981bcf 100644 --- a/src/app/core/auth/browser-auth-request.service.spec.ts +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -1,6 +1,8 @@ import { AuthRequestService } from './auth-request.service'; import { RequestService } from '../data/request.service'; import { BrowserAuthRequestService } from './browser-auth-request.service'; +import { Observable } from 'rxjs'; +import { PostRequest } from '../data/request.models'; describe(`BrowserAuthRequestService`, () => { let href: string; @@ -16,14 +18,20 @@ describe(`BrowserAuthRequestService`, () => { }); describe(`createShortLivedTokenRequest`, () => { - it(`should return a PostRequest`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.constructor.name).toBe('PostRequest'); + it(`should return a PostRequest`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.constructor.name).toBe('PostRequest'); + done(); + }); }); - it(`should return a request with the given href`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.href).toBe(href) ; + it(`should return a request with the given href`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.href).toBe(href); + done(); + }); }); }); }); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts index 85d5f54340..485e2ef9c4 100644 --- a/src/app/core/auth/browser-auth-request.service.ts +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -4,6 +4,7 @@ import { PostRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Observable, of as observableOf } from 'rxjs'; /** * Client side version of the service to send authentication requests @@ -20,15 +21,13 @@ export class BrowserAuthRequestService extends AuthRequestService { } /** - * Factory function to create the request object to send. This needs to be a POST client side and - * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow - * only the server IP to send a GET to this endpoint. + * Factory function to create the request object to send. * * @param href The href to send the request to * @protected */ - protected createShortLivedTokenRequest(href: string): PostRequest { - return new PostRequest(this.requestService.generateRequestId(), href); + protected createShortLivedTokenRequest(href: string): Observable { + return observableOf(new PostRequest(this.requestService.generateRequestId(), href)); } } diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index 69053fbb3a..df6d78256b 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -1,34 +1,68 @@ import { AuthRequestService } from './auth-request.service'; import { RequestService } from '../data/request.service'; import { ServerAuthRequestService } from './server-auth-request.service'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { Observable, of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { PostRequest } from '../data/request.models'; +import { + XSRF_REQUEST_HEADER, + XSRF_RESPONSE_HEADER +} from '../xsrf/xsrf.interceptor'; describe(`ServerAuthRequestService`, () => { let href: string; let requestService: RequestService; let service: AuthRequestService; + let httpClient: HttpClient; + let httpResponse: HttpResponse; + let halService: HALEndpointService; + const mockToken = 'mock-token'; beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' }); - service = new ServerAuthRequestService(null, requestService, null); + let headers = new HttpHeaders(); + headers = headers.set(XSRF_RESPONSE_HEADER, mockToken); + httpResponse = { + body: { bar: false }, + headers: headers, + statusText: '200' + } as HttpResponse; + httpClient = jasmine.createSpyObj('httpClient', { + get: observableOf(httpResponse), + }); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + service = new ServerAuthRequestService(halService, requestService, null, httpClient); }); describe(`createShortLivedTokenRequest`, () => { - it(`should return a GetRequest`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.constructor.name).toBe('GetRequest'); + it(`should return a PostRequest`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.constructor.name).toBe('PostRequest'); + done(); + }); }); - it(`should return a request with the given href`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.href).toBe(href) ; + it(`should return a request with the given href`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.href).toBe(href); + done(); + }); }); - it(`should have a responseMsToLive of 2 seconds`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.responseMsToLive).toBe(2 * 1000) ; + it(`should return a request with a xsrf header`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.options.headers.get(XSRF_REQUEST_HEADER)).toBe(mockToken); + done(); + }); }); }); }); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index 751389f71d..d6302081bc 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -1,9 +1,21 @@ import { Injectable } from '@angular/core'; import { AuthRequestService } from './auth-request.service'; -import { GetRequest } from '../data/request.models'; +import { PostRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { + HttpHeaders, + HttpClient, + HttpResponse +} from '@angular/common/http'; +import { + XSRF_REQUEST_HEADER, + XSRF_RESPONSE_HEADER, + DSPACE_XSRF_COOKIE +} from '../xsrf/xsrf.interceptor'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; /** * Server side version of the service to send authentication requests @@ -14,23 +26,42 @@ export class ServerAuthRequestService extends AuthRequestService { constructor( halService: HALEndpointService, requestService: RequestService, - rdbService: RemoteDataBuildService + rdbService: RemoteDataBuildService, + protected httpClient: HttpClient, ) { super(halService, requestService, rdbService); } /** - * Factory function to create the request object to send. This needs to be a POST client side and - * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow - * only the server IP to send a GET to this endpoint. + * Factory function to create the request object to send. * * @param href The href to send the request to * @protected */ - protected createShortLivedTokenRequest(href: string): GetRequest { - return Object.assign(new GetRequest(this.requestService.generateRequestId(), href), { - responseMsToLive: 2 * 1000 // A short lived token is only valid for 2 seconds. - }); + protected createShortLivedTokenRequest(href: string): Observable { + // First do a call to the root endpoint in order to get an XSRF token + return this.httpClient.get(this.halService.getRootHref(), { observe: 'response' }).pipe( + // retrieve the XSRF token from the response header + map((response: HttpResponse) => response.headers.get(XSRF_RESPONSE_HEADER)), + // Use that token to create an HttpHeaders object + map((xsrfToken: string) => new HttpHeaders() + .set('Content-Type', 'application/json; charset=utf-8') + // set the token as the XSRF header + .set(XSRF_REQUEST_HEADER, xsrfToken) + // and as the DSPACE-XSRF-COOKIE + .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), + map((headers: HttpHeaders) => + // Create a new PostRequest using those headers and the given href + new PostRequest( + this.requestService.generateRequestId(), + href, + {}, + { + headers: headers, + }, + ) + ) + ); } } diff --git a/src/app/core/services/server-xhr.service.ts b/src/app/core/services/server-xhr.service.ts new file mode 100644 index 0000000000..69ae741402 --- /dev/null +++ b/src/app/core/services/server-xhr.service.ts @@ -0,0 +1,16 @@ +import { XhrFactory } from '@angular/common'; +import { Injectable } from '@angular/core'; +import { prototype, XMLHttpRequest } from 'xhr2'; + +/** + * Overrides the default XhrFactory server side, to allow us to set cookies in requests to the + * backend. This was added to be able to perform a working XSRF request from the node server, as it + * needs to set a cookie for the XSRF token + */ +@Injectable() +export class ServerXhrService implements XhrFactory { + build(): XMLHttpRequest { + prototype._restrictedHeaders.cookie = false; + return new XMLHttpRequest(); + } +} diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index d527924a28..cded432397 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -19,6 +19,8 @@ export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; // Name of cookie where we store the XSRF token export const XSRF_COOKIE = 'XSRF-TOKEN'; +// Name of cookie the backend expects the XSRF token to be in +export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE'; /** * Custom Http Interceptor intercepting Http Requests & Responses to diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 81426e7fcc..7d162c5fd1 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -33,6 +33,8 @@ import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mo import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; import { ServerInitService } from './server-init.service'; +import { XhrFactory } from '@angular/common'; +import { ServerXhrService } from '../../app/core/services/server-xhr.service'; export function createTranslateLoader(transferState: TransferState) { return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json'); @@ -104,6 +106,10 @@ export function createTranslateLoader(transferState: TransferState) { provide: HardRedirectService, useClass: ServerHardRedirectService, }, + { + provide: XhrFactory, + useClass: ServerXhrService, + }, ] }) export class ServerAppModule {