diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts new file mode 100644 index 0000000000..707daf9e30 --- /dev/null +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -0,0 +1,74 @@ +import { AuthRequestService } from './auth-request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { PostRequest } from '../data/request.models'; +import { TestScheduler } from 'rxjs/testing'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { ShortLivedToken } from './models/short-lived-token.model'; +import { RemoteData } from '../data/remote-data'; + +describe(`AuthRequestService`, () => { + let halService: HALEndpointService; + let endpointURL: string; + let shortLivedToken: ShortLivedToken; + let shortLivedTokenRD: RemoteData; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let service: AuthRequestService; + let testScheduler; + + class TestAuthRequestService extends AuthRequestService { + constructor( + hes: HALEndpointService, + rs: RequestService, + rdbs: RemoteDataBuildService + ) { + super(hes, rs, rdbs); + } + + protected createShortLivedTokenRequest(href: string): PostRequest { + return new PostRequest(this.requestService.generateRequestId(), href); + } + } + + const init = (cold: typeof TestScheduler.prototype.createColdObservable) => { + endpointURL = 'https://rest.api/auth'; + shortLivedToken = Object.assign(new ShortLivedToken(), { + value: 'some-token' + }); + shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); + + halService = jasmine.createSpyObj('halService', { + 'getEndpoint': cold('a', { a: endpointURL }) + }); + requestService = jasmine.createSpyObj('requestService', { + 'send': null + }); + rdbService = jasmine.createSpyObj('rdbService', { + 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) + }); + + service = new TestAuthRequestService(halService, requestService, rdbService); + }; + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe(`getShortlivedToken`, () => { + it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + init(cold); + spyOn(service as any, 'createShortLivedTokenRequest'); + // expectObservable is needed to let testScheduler know to take it in to account, but since + // we're not testing the outcome in this test, a .toBe(…) isn't necessary + expectObservable(service.getShortlivedToken()); + flush(); + expect((service as any).createShortLivedTokenRequest).toHaveBeenCalledWith(`${endpointURL}/shortlivedtokens`); + }); + }); + }); +}); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 4315ddfea8..00a94822d3 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,14 +1,9 @@ import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { isNotEmpty } from '../../shared/empty.util'; -import { - GetRequest, - PostRequest, - RestRequest, -} from '../data/request.models'; +import { GetRequest, PostRequest, RestRequest, } from '../data/request.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -17,8 +12,10 @@ import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; import { URLCombiner } from '../url-combiner/url-combiner'; -@Injectable() -export class AuthRequestService { +/** + * Abstract service to send authentication requests + */ +export abstract class AuthRequestService { protected linkName = 'authn'; protected browseEndpoint = ''; protected shortlivedtokensEndpoint = 'shortlivedtokens'; @@ -62,16 +59,26 @@ export class AuthRequestService { } /** - * Send a POST request to retrieve a short-lived token which provides download access of restricted files + * 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. + * + * @param href The href to send the request to + * @protected + */ + protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest; + + /** + * Send a request to retrieve a short-lived token which provides download access of restricted files */ public getShortlivedToken(): Observable { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()), - map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL)), - tap((request: PostRequest) => this.requestService.send(request)), - switchMap((request: PostRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), + map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), + tap((request: RestRequest) => this.requestService.send(request)), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), getFirstCompletedRemoteData(), map((response: RemoteData) => { if (response.hasSucceeded) { diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts new file mode 100644 index 0000000000..18d27340af --- /dev/null +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -0,0 +1,29 @@ +import { AuthRequestService } from './auth-request.service'; +import { RequestService } from '../data/request.service'; +import { BrowserAuthRequestService } from './browser-auth-request.service'; + +describe(`BrowserAuthRequestService`, () => { + let href: string; + let requestService: RequestService; + let service: AuthRequestService; + + beforeEach(() => { + href = 'https://rest.api/auth/shortlivedtokens'; + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + }); + service = new BrowserAuthRequestService(null, requestService, null); + }); + + describe(`createShortLivedTokenRequest`, () => { + it(`should return a PostRequest`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.constructor.name).toBe('PostRequest'); + }); + + it(`should return a request with the given href`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.href).toBe(href) ; + }); + }); +}); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts new file mode 100644 index 0000000000..85d5f54340 --- /dev/null +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { AuthRequestService } from './auth-request.service'; +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'; + +/** + * Client side version of the service to send authentication requests + */ +@Injectable() +export class BrowserAuthRequestService extends AuthRequestService { + + constructor( + halService: HALEndpointService, + requestService: RequestService, + rdbService: RemoteDataBuildService + ) { + 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. + * + * @param href The href to send the request to + * @protected + */ + protected createShortLivedTokenRequest(href: string): PostRequest { + return 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 new file mode 100644 index 0000000000..69053fbb3a --- /dev/null +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -0,0 +1,34 @@ +import { AuthRequestService } from './auth-request.service'; +import { RequestService } from '../data/request.service'; +import { ServerAuthRequestService } from './server-auth-request.service'; + +describe(`ServerAuthRequestService`, () => { + let href: string; + let requestService: RequestService; + let service: AuthRequestService; + + beforeEach(() => { + href = 'https://rest.api/auth/shortlivedtokens'; + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + }); + service = new ServerAuthRequestService(null, requestService, null); + }); + + describe(`createShortLivedTokenRequest`, () => { + it(`should return a GetRequest`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.constructor.name).toBe('GetRequest'); + }); + + it(`should return a request with the given href`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.href).toBe(href) ; + }); + + it(`should have a responseMsToLive of 2 seconds`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.responseMsToLive).toBe(2 * 1000) ; + }); + }); +}); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts new file mode 100644 index 0000000000..751389f71d --- /dev/null +++ b/src/app/core/auth/server-auth-request.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { AuthRequestService } from './auth-request.service'; +import { GetRequest } 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'; + +/** + * Server side version of the service to send authentication requests + */ +@Injectable() +export class ServerAuthRequestService extends AuthRequestService { + + constructor( + halService: HALEndpointService, + requestService: RequestService, + rdbService: RemoteDataBuildService + ) { + 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. + * + * @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. + }); + } + +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index f73bfd0bdf..619a7dbadc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -31,7 +31,6 @@ import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { UploaderService } from '../shared/uploader/uploader.service'; import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; -import { AuthRequestService } from './auth/auth-request.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthStatus } from './auth/models/auth-status.model'; import { BrowseService } from './browse/browse.service'; @@ -188,7 +187,6 @@ const EXPORTS = []; const PROVIDERS = [ ApiService, AuthenticatedGuard, - AuthRequestService, CommunityDataService, CollectionDataService, SiteDataService, diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 4b6c5c813e..070d530dfe 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -31,6 +31,8 @@ import { import { LocaleService } from '../../app/core/locale/locale.service'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; import { RouterModule, NoPreloading } from '@angular/router'; +import { AuthRequestService } from '../../app/core/auth/auth-request.service'; +import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; export const REQ_KEY = makeStateKey('req'); @@ -104,6 +106,10 @@ export function getRequest(transferState: TransferState): any { provide: GoogleAnalyticsService, useClass: GoogleAnalyticsService, }, + { + provide: AuthRequestService, + useClass: BrowserAuthRequestService, + }, { provide: LocationToken, useFactory: locationProvider, diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 906d4c5f35..dad3a60d5c 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -31,6 +31,8 @@ import { ServerHardRedirectService } from '../../app/core/services/server-hard-r import { Angulartics2 } from 'angulartics2'; import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { RouterModule } from '@angular/router'; +import { AuthRequestService } from '../../app/core/auth/auth-request.service'; +import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); @@ -82,6 +84,10 @@ export function createTranslateLoader() { provide: SubmissionService, useClass: ServerSubmissionService }, + { + provide: AuthRequestService, + useClass: ServerAuthRequestService, + }, { provide: LocaleService, useClass: ServerLocaleService