Skip to content

Commit 28bd6a5

Browse files
authored
Merge pull request DSpace#3063 from tdonohue/port_new_csrf_fixes_to_7x
[Port dspace-7_x] Ensures CSRF token is initialized prior to first modifying (non-GET) request
2 parents 735a3af + 2d81a48 commit 28bd6a5

34 files changed

Lines changed: 265 additions & 21 deletions

File tree

.github/workflows/build.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
#CHROME_VERSION: "90.0.4430.212-1"
3434
# Bump Node heap size (OOM in CI after upgrading to Angular 15)
3535
NODE_OPTIONS: '--max-old-space-size=4096'
36-
# Project name to use when running docker-compose prior to e2e tests
36+
# Project name to use when running "docker compose" prior to e2e tests
3737
COMPOSE_PROJECT_NAME: 'ci'
3838
strategy:
3939
# Create a matrix of Node versions to test against (in parallel)
@@ -108,12 +108,12 @@ jobs:
108108
path: 'coverage/dspace-angular/lcov.info'
109109
retention-days: 14
110110

111-
# Using docker-compose start backend using CI configuration
111+
# Using "docker compose" start backend using CI configuration
112112
# and load assetstore from a cached copy
113113
- name: Start DSpace REST Backend via Docker (for e2e tests)
114114
run: |
115-
docker-compose -f ./docker/docker-compose-ci.yml up -d
116-
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
115+
docker compose -f ./docker/docker-compose-ci.yml up -d
116+
docker compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
117117
docker container ls
118118
119119
# Run integration tests via Cypress.io
@@ -182,7 +182,7 @@ jobs:
182182
run: kill -9 $(lsof -t -i:4000)
183183

184184
- name: Shutdown Docker containers
185-
run: docker-compose -f ./docker/docker-compose-ci.yml down
185+
run: docker compose -f ./docker/docker-compose-ci.yml down
186186

187187
# Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test
188188
# job above. This is necessary because Codecov uploads seem to randomly fail at times.

src/app/access-control/group-registry/group-form/group-form.component.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model';
2323
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
2424
import { PageInfo } from '../../../core/shared/page-info.model';
2525
import { UUIDService } from '../../../core/shared/uuid.service';
26+
import { XSRFService } from '../../../core/xsrf/xsrf.service';
2627
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
2728
import { NotificationsService } from '../../../shared/notifications/notifications.service';
2829
import { GroupMock, GroupMock2 } from '../../../shared/testing/group-mock';
@@ -211,6 +212,7 @@ describe('GroupFormComponent', () => {
211212
{ provide: HttpClient, useValue: {} },
212213
{ provide: ObjectCacheService, useValue: {} },
213214
{ provide: UUIDService, useValue: {} },
215+
{ provide: XSRFService, useValue: {} },
214216
{ provide: Store, useValue: {} },
215217
{ provide: RemoteDataBuildService, useValue: {} },
216218
{ provide: HALEndpointService, useValue: {} },

src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NotificationsService } from '../../../shared/notifications/notification
1515
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
1616
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
1717
import { BitstreamFormatSupportLevel } from '../../../core/shared/bitstream-format-support-level';
18+
import { XSRFService } from '../../../core/xsrf/xsrf.service';
1819
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
1920
import { TestScheduler } from 'rxjs/testing';
2021
import {
@@ -108,7 +109,8 @@ describe('BitstreamFormatsComponent', () => {
108109
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
109110
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
110111
{ provide: NotificationsService, useValue: notificationsServiceStub },
111-
{ provide: PaginationService, useValue: paginationService }
112+
{ provide: PaginationService, useValue: paginationService },
113+
{ provide: XSRFService, useValue: {} },
112114
]
113115
}).compileComponents();
114116
};

src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CollectionElementLinkType } from '../../../../../shared/object-collecti
99
import { ViewMode } from '../../../../../core/shared/view-mode.model';
1010
import { RouterTestingModule } from '@angular/router/testing';
1111
import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model';
12+
import { XSRFService } from '../../../../../core/xsrf/xsrf.service';
1213
import {
1314
WorkflowItemSearchResultAdminWorkflowListElementComponent
1415
} from './workflow-item-search-result-admin-workflow-list-element.component';
@@ -58,7 +59,8 @@ describe('WorkflowItemSearchResultAdminWorkflowListElementComponent', () => {
5859
{ provide: TruncatableService, useValue: mockTruncatableService },
5960
{ provide: LinkService, useValue: linkService },
6061
{ provide: DSONameService, useClass: DSONameServiceMock },
61-
{ provide: APP_CONFIG, useValue: environment }
62+
{ provide: APP_CONFIG, useValue: environment },
63+
{ provide: XSRFService, useValue: {} },
6264
],
6365
schemas: [NO_ERRORS_SCHEMA]
6466
})

src/app/core/data/request.effects.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Injectable, Injector } from '@angular/core';
22

33
import { Actions, createEffect, ofType } from '@ngrx/effects';
4-
import { catchError, filter, map, mergeMap, take } from 'rxjs/operators';
4+
import { catchError, filter, map, mergeMap, take, withLatestFrom } from 'rxjs/operators';
55

66
import { hasValue, isNotEmpty } from '../../shared/empty.util';
77
import { StoreActionTypes } from '../../store.actions';
88
import { getClassForType } from '../cache/builders/build-decorators';
99
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
1010
import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
1111
import { DSpaceSerializer } from '../dspace-rest/dspace.serializer';
12+
import { XSRFService } from '../xsrf/xsrf.service';
1213
import {
1314
RequestActionTypes,
1415
RequestErrorAction,
@@ -19,6 +20,7 @@ import {
1920
import { RequestService } from './request.service';
2021
import { ParsedResponse } from '../cache/response.models';
2122
import { RequestError } from './request-error.model';
23+
import { RestRequestMethod } from './rest-request-method';
2224
import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model';
2325
import { RequestEntry } from './request-entry.model';
2426

@@ -33,7 +35,11 @@ export class RequestEffects {
3335
);
3436
}),
3537
filter((entry: RequestEntry) => hasValue(entry)),
36-
map((entry: RequestEntry) => entry.request),
38+
withLatestFrom(this.xsrfService.tokenInitialized$),
39+
// If it's a GET request, or we have an XSRF token, dispatch it immediately
40+
// Otherwise wait for the XSRF token first
41+
filter(([entry, tokenInitialized]: [RequestEntry, boolean]) => entry.request.method === RestRequestMethod.GET || tokenInitialized === true),
42+
map(([entry, tokenInitialized]: [RequestEntry, boolean]) => entry.request),
3743
mergeMap((request: RestRequestWithResponseParser) => {
3844
let body = request.body;
3945
if (isNotEmpty(request.body) && !request.isMultipart) {
@@ -73,7 +79,8 @@ export class RequestEffects {
7379
private actions$: Actions,
7480
private restApi: DspaceRestService,
7581
private injector: Injector,
76-
protected requestService: RequestService
82+
protected requestService: RequestService,
83+
protected xsrfService: XSRFService,
7784
) { }
7885

7986
}

src/app/core/data/request.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@ describe('RequestService', () => {
8080
store = TestBed.inject(Store);
8181
mockStore = store as MockStore<CoreState>;
8282
mockStore.setState(initialState);
83+
8384
service = new RequestService(
8485
objectCache,
8586
uuidService,
8687
store,
87-
undefined
8888
);
8989
serviceAsAny = service as any;
9090
});

src/app/core/data/request.service.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import cloneDeep from 'lodash/cloneDeep';
88
import { hasValue, isEmpty, isNotEmpty, hasNoValue } from '../../shared/empty.util';
99
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
1010
import { ObjectCacheService } from '../cache/object-cache.service';
11-
import { IndexState, MetaIndexState } from '../index/index.reducer';
11+
import { IndexState } from '../index/index.reducer';
1212
import { requestIndexSelector, getUrlWithoutEmbedParams } from '../index/index.selectors';
1313
import { UUIDService } from '../shared/uuid.service';
1414
import {
@@ -136,8 +136,7 @@ export class RequestService {
136136

137137
constructor(private objectCache: ObjectCacheService,
138138
private uuidService: UUIDService,
139-
private store: Store<CoreState>,
140-
private indexStore: Store<MetaIndexState>) {
139+
private store: Store<CoreState>) {
141140
}
142141

143142
generateRequestId(): string {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import {
3+
HttpClientTestingModule,
4+
HttpTestingController,
5+
} from '@angular/common/http/testing';
6+
import { TestBed } from '@angular/core/testing';
7+
8+
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
9+
import { BrowserXSRFService } from './browser-xsrf.service';
10+
11+
describe(`BrowserXSRFService`, () => {
12+
let service: BrowserXSRFService;
13+
let httpClient: HttpClient;
14+
let httpTestingController: HttpTestingController;
15+
16+
const endpointURL = new RESTURLCombiner('/security/csrf').toString();
17+
18+
beforeEach(() => {
19+
TestBed.configureTestingModule({
20+
imports: [ HttpClientTestingModule ],
21+
providers: [ BrowserXSRFService ],
22+
});
23+
httpClient = TestBed.inject(HttpClient);
24+
httpTestingController = TestBed.inject(HttpTestingController);
25+
service = TestBed.inject(BrowserXSRFService);
26+
});
27+
28+
describe(`initXSRFToken`, () => {
29+
it(`should perform a GET to the csrf endpoint`, (done: DoneFn) => {
30+
service.initXSRFToken(httpClient)();
31+
32+
const req = httpTestingController.expectOne({
33+
url: endpointURL,
34+
method: 'GET',
35+
});
36+
37+
req.flush({});
38+
httpTestingController.verify();
39+
expect().nothing();
40+
done();
41+
});
42+
43+
describe(`when the GET succeeds`, () => {
44+
it(`should set tokenInitialized$ to true`, (done: DoneFn) => {
45+
service.initXSRFToken(httpClient)();
46+
47+
const req = httpTestingController.expectOne(endpointURL);
48+
49+
req.flush({});
50+
httpTestingController.verify();
51+
52+
expect(service.tokenInitialized$.getValue()).toBeTrue();
53+
done();
54+
});
55+
});
56+
57+
});
58+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
import { take } from 'rxjs/operators';
4+
5+
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
6+
import { XSRFService } from './xsrf.service';
7+
8+
/**
9+
* Browser (CSR) Service to obtain a new CSRF/XSRF token when needed by our RequestService
10+
* to perform a modify request (e.g. POST/PUT/DELETE).
11+
* NOTE: This is primarily necessary before the *first* modifying request, as the CSRF
12+
* token may not yet be initialized.
13+
*/
14+
@Injectable()
15+
export class BrowserXSRFService extends XSRFService {
16+
initXSRFToken(httpClient: HttpClient): () => Promise<any> {
17+
return () => new Promise<void>((resolve) => {
18+
// Force a new token to be created by calling the CSRF endpoint
19+
httpClient.get(new RESTURLCombiner('/security/csrf').toString(), undefined).pipe(
20+
take(1),
21+
).subscribe(() => {
22+
// Once token is returned, set tokenInitialized to true.
23+
this.tokenInitialized$.next(true);
24+
});
25+
26+
// return immediately, the rest of the app doesn't need to wait for this to finish
27+
resolve();
28+
});
29+
}
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { HttpClient } from '@angular/common/http';
2+
3+
import { ServerXSRFService } from './server-xsrf.service';
4+
5+
describe(`ServerXSRFService`, () => {
6+
let service: ServerXSRFService;
7+
let httpClient: HttpClient;
8+
9+
beforeEach(() => {
10+
httpClient = jasmine.createSpyObj(['post', 'get', 'request']);
11+
service = new ServerXSRFService();
12+
});
13+
14+
describe(`initXSRFToken`, () => {
15+
it(`shouldn't perform any requests`, (done: DoneFn) => {
16+
service.initXSRFToken(httpClient)().then(() => {
17+
for (const prop in httpClient) {
18+
if (httpClient.hasOwnProperty(prop)) {
19+
expect(httpClient[prop]).not.toHaveBeenCalled();
20+
}
21+
}
22+
done();
23+
});
24+
});
25+
26+
it(`should leave tokenInitialized$ on false`, (done: DoneFn) => {
27+
service.initXSRFToken(httpClient)().then(() => {
28+
expect(service.tokenInitialized$.getValue()).toBeFalse();
29+
done();
30+
});
31+
});
32+
});
33+
});

0 commit comments

Comments
 (0)