Skip to content

Commit 617c7d8

Browse files
artloweltdonohue
authored andcommitted
make a call to ensure a correct XSRF token before performing any non-GET requests
1 parent b3b3ef8 commit 617c7d8

11 files changed

Lines changed: 212 additions & 3 deletions

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
@@ -53,6 +53,7 @@ import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
5353
import { NoContent } from '../../../core/shared/NoContent.model';
5454
import { PageInfo } from '../../../core/shared/page-info.model';
5555
import { UUIDService } from '../../../core/shared/uuid.service';
56+
import { XSRFService } from '../../../core/xsrf/xsrf.service';
5657
import { AlertComponent } from '../../../shared/alert/alert.component';
5758
import { ContextHelpDirective } from '../../../shared/context-help.directive';
5859
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
@@ -244,6 +245,7 @@ describe('GroupFormComponent', () => {
244245
{ provide: HttpClient, useValue: {} },
245246
{ provide: ObjectCacheService, useValue: {} },
246247
{ provide: UUIDService, useValue: {} },
248+
{ provide: XSRFService, useValue: {} },
247249
{ provide: Store, useValue: {} },
248250
{ provide: RemoteDataBuildService, useValue: {} },
249251
{ provide: HALEndpointService, useValue: {} },

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
getTestScheduler,
1717
} from 'jasmine-marbles';
1818
import {
19+
BehaviorSubject,
1920
EMPTY,
2021
Observable,
2122
of as observableOf,
@@ -32,6 +33,7 @@ import { ObjectCacheService } from '../cache/object-cache.service';
3233
import { coreReducers } from '../core.reducers';
3334
import { CoreState } from '../core-state.model';
3435
import { UUIDService } from '../shared/uuid.service';
36+
import { XSRFService } from '../xsrf/xsrf.service';
3537
import {
3638
RequestConfigureAction,
3739
RequestExecuteAction,
@@ -59,6 +61,7 @@ describe('RequestService', () => {
5961
let uuidService: UUIDService;
6062
let store: Store<CoreState>;
6163
let mockStore: MockStore<CoreState>;
64+
let xsrfService: XSRFService;
6265

6366
const testUUID = '5f2a0d2a-effa-4d54-bd54-5663b960f9eb';
6467
const testHref = 'https://rest.api/endpoint/selfLink';
@@ -104,10 +107,15 @@ describe('RequestService', () => {
104107
store = TestBed.inject(Store);
105108
mockStore = store as MockStore<CoreState>;
106109
mockStore.setState(initialState);
110+
xsrfService = {
111+
tokenInitialized$: new BehaviorSubject(false),
112+
} as XSRFService;
113+
107114
service = new RequestService(
108115
objectCache,
109116
uuidService,
110117
store,
118+
xsrfService,
111119
undefined,
112120
);
113121
serviceAsAny = service as any;

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
requestIndexSelector,
4343
} from '../index/index.selectors';
4444
import { UUIDService } from '../shared/uuid.service';
45+
import { XSRFService } from '../xsrf/xsrf.service';
4546
import {
4647
RequestConfigureAction,
4748
RequestExecuteAction,
@@ -168,6 +169,7 @@ export class RequestService {
168169
constructor(private objectCache: ObjectCacheService,
169170
private uuidService: UUIDService,
170171
private store: Store<CoreState>,
172+
protected xsrfService: XSRFService,
171173
private indexStore: Store<MetaIndexState>) {
172174
}
173175

@@ -450,7 +452,17 @@ export class RequestService {
450452
*/
451453
private dispatchRequest(request: RestRequest) {
452454
this.store.dispatch(new RequestConfigureAction(request));
453-
this.store.dispatch(new RequestExecuteAction(request.uuid));
455+
// If it's a GET request, or we have an XSRF token, dispatch it immediately
456+
if (request.method === RestRequestMethod.GET || this.xsrfService.tokenInitialized$.getValue() === true) {
457+
this.store.dispatch(new RequestExecuteAction(request.uuid));
458+
} else {
459+
// Otherwise wait for the XSRF token first
460+
this.xsrfService.tokenInitialized$.pipe(
461+
find((hasInitialized: boolean) => hasInitialized === true),
462+
).subscribe(() => {
463+
this.store.dispatch(new RequestExecuteAction(request.uuid));
464+
});
465+
}
454466
}
455467

456468
/**
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { BrowserXSRFService } from './browser-xsrf.service';
2+
import { HttpClient } from '@angular/common/http';
3+
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
4+
import { TestBed } from '@angular/core/testing';
5+
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
6+
7+
describe(`BrowserXSRFService`, () => {
8+
let service: BrowserXSRFService;
9+
let httpClient: HttpClient;
10+
let httpTestingController: HttpTestingController;
11+
12+
const endpointURL = new RESTURLCombiner('/security/csrf').toString();
13+
14+
beforeEach(() => {
15+
TestBed.configureTestingModule({
16+
imports: [ HttpClientTestingModule ],
17+
providers: [ BrowserXSRFService ]
18+
});
19+
httpClient = TestBed.inject(HttpClient);
20+
httpTestingController = TestBed.inject(HttpTestingController);
21+
service = TestBed.inject(BrowserXSRFService);
22+
});
23+
24+
describe(`initXSRFToken`, () => {
25+
it(`should perform a POST to the csrf endpoint`, () => {
26+
service.initXSRFToken(httpClient)();
27+
28+
const req = httpTestingController.expectOne({
29+
url: endpointURL,
30+
method: 'POST'
31+
});
32+
33+
req.flush({});
34+
httpTestingController.verify();
35+
});
36+
37+
describe(`when the POST succeeds`, () => {
38+
it(`should set tokenInitialized$ to true`, () => {
39+
service.initXSRFToken(httpClient)();
40+
41+
const req = httpTestingController.expectOne(endpointURL);
42+
43+
req.flush({});
44+
httpTestingController.verify();
45+
46+
expect(service.tokenInitialized$.getValue()).toBeTrue();
47+
});
48+
});
49+
50+
describe(`when the POST fails`, () => {
51+
it(`should set tokenInitialized$ to true`, () => {
52+
service.initXSRFToken(httpClient)();
53+
54+
const req = httpTestingController.expectOne(endpointURL);
55+
56+
req.error(new ErrorEvent('415'));
57+
httpTestingController.verify();
58+
59+
expect(service.tokenInitialized$.getValue()).toBeTrue();
60+
});
61+
});
62+
63+
});
64+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
4+
import { take, catchError } from 'rxjs/operators';
5+
import { of as observableOf } from 'rxjs';
6+
import { XSRFService } from './xsrf.service';
7+
8+
@Injectable()
9+
export class BrowserXSRFService extends XSRFService {
10+
initXSRFToken(httpClient: HttpClient): () => Promise<any> {
11+
return () => new Promise((resolve) => {
12+
httpClient.post(new RESTURLCombiner('/security/csrf').toString(), undefined).pipe(
13+
// errors are to be expected if the token and the cookie don't match, that's what we're
14+
// trying to fix for future requests, so just emit any observable to end up in the
15+
// subscribe
16+
catchError(() => observableOf(null)),
17+
take(1),
18+
).subscribe(() => {
19+
this.tokenInitialized$.next(true);
20+
});
21+
22+
// return immediately, the rest of the app doesn't need to wait for this to finish
23+
resolve();
24+
});
25+
}
26+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ServerXSRFService } from './server-xsrf.service';
2+
import { HttpClient } from '@angular/common/http';
3+
4+
describe(`ServerXSRFService`, () => {
5+
let service: ServerXSRFService;
6+
let httpClient: HttpClient;
7+
8+
beforeEach(() => {
9+
httpClient = jasmine.createSpyObj(['post', 'get', 'request']);
10+
service = new ServerXSRFService();
11+
});
12+
13+
describe(`initXSRFToken`, () => {
14+
it(`shouldn't perform any requests`, (done: DoneFn) => {
15+
service.initXSRFToken(httpClient)().then(() => {
16+
for (const prop in httpClient) {
17+
if (httpClient.hasOwnProperty(prop)) {
18+
expect(httpClient[prop]).not.toHaveBeenCalled();
19+
}
20+
}
21+
done();
22+
});
23+
});
24+
25+
it(`should leave tokenInitialized$ on false`, (done: DoneFn) => {
26+
service.initXSRFToken(httpClient)().then(() => {
27+
expect(service.tokenInitialized$.getValue()).toBeFalse();
28+
done();
29+
});
30+
});
31+
});
32+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
import { XSRFService } from './xsrf.service';
4+
5+
@Injectable()
6+
export class ServerXSRFService extends XSRFService {
7+
initXSRFToken(httpClient: HttpClient): () => Promise<any> {
8+
return () => new Promise((resolve) => {
9+
// return immediately, and keep tokenInitialized$ false. The server side can make only GET
10+
// requests, since it can never get a valid XSRF cookie
11+
resolve();
12+
});
13+
}
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { XSRFService } from './xsrf.service';
2+
import { HttpClient } from '@angular/common/http';
3+
4+
class XSRFServiceImpl extends XSRFService {
5+
initXSRFToken(httpClient: HttpClient): () => Promise<any> {
6+
return () => null;
7+
}
8+
}
9+
10+
describe(`XSRFService`, () => {
11+
let service: XSRFService;
12+
13+
beforeEach(() => {
14+
service = new XSRFServiceImpl();
15+
});
16+
17+
it(`should start with tokenInitialized$.hasValue() === false`, () => {
18+
expect(service.tokenInitialized$.getValue()).toBeFalse();
19+
});
20+
});

src/app/core/xsrf/xsrf.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
import { BehaviorSubject } from 'rxjs';
4+
5+
@Injectable()
6+
export abstract class XSRFService {
7+
public tokenInitialized$: BehaviorSubject<boolean> = new BehaviorSubject(false);
8+
9+
abstract initXSRFToken(httpClient: HttpClient): () => Promise<any>;
10+
}

src/modules/app/browser-app.module.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import {
22
HttpClient,
33
HttpClientModule,
44
} from '@angular/common/http';
5-
import { NgModule } from '@angular/core';
5+
import {
6+
APP_INITIALIZER,
7+
NgModule,
8+
} from '@angular/core';
69
import {
710
BrowserModule,
811
BrowserTransferStateModule,
@@ -48,13 +51,15 @@ import { ClientCookieService } from '../../app/core/services/client-cookie.servi
4851
import { CookieService } from '../../app/core/services/cookie.service';
4952
import { HardRedirectService } from '../../app/core/services/hard-redirect.service';
5053
import { ReferrerService } from '../../app/core/services/referrer.service';
54+
import { BrowserXSRFService } from '../../app/core/xsrf/browser-xsrf.service';
55+
import { XSRFService } from '../../app/core/xsrf/xsrf.service';
5156
import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service';
5257
import { KlaroService } from '../../app/shared/cookies/klaro.service';
5358
import { MissingTranslationHelper } from '../../app/shared/translate/missing-translation.helper';
5459
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
5560
import { SubmissionService } from '../../app/submission/submission.service';
5661
import { TranslateBrowserLoader } from '../../ngx-translate-loaders/translate-browser.loader';
57-
import { BrowserInitService } from './browser-init.service';
62+
import { BrowserInitService } from './browser-init.service'
5863

5964
export const REQ_KEY = makeStateKey<string>('req');
6065

@@ -98,6 +103,16 @@ export function getRequest(transferState: TransferState): any {
98103
useFactory: getRequest,
99104
deps: [TransferState],
100105
},
106+
{
107+
provide: APP_INITIALIZER,
108+
useFactory: (xsrfService: XSRFService, httpClient: HttpClient) => xsrfService.initXSRFToken(httpClient),
109+
deps: [ XSRFService, HttpClient ],
110+
multi: true,
111+
},
112+
{
113+
provide: XSRFService,
114+
useClass: BrowserXSRFService,
115+
},
101116
{
102117
provide: AuthService,
103118
useClass: AuthService,

0 commit comments

Comments
 (0)