Skip to content

Commit b1ec04c

Browse files
committed
Prevent users being logged out incorrectly (#7171)
Updates API and Webapp to: - Only consider 401s from CouchDB to be valid missing authentication - Throw other _session errors without changes - Malformed _session responses result in a 500 - Don't send 401s for missing userCtx, instead send the actual authentication error or a 500. - Add a custom header to 401 responses that Webapp checks for, and only when matched it would log users out. (prevents man-in-the-middle from logging users out with 401s) #7169 (cherry picked from commit 2486dc5)
1 parent 0f69b0c commit b1ec04c

File tree

12 files changed

+350
-85
lines changed

12 files changed

+350
-85
lines changed

api/src/auth.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,17 @@ module.exports = {
8686
getUserCtx: req => {
8787
return get('/_session', req.headers)
8888
.catch(err => {
89-
throw { code: 401, message: 'Not logged in', err: err };
89+
if (err.statusCode === 401) {
90+
throw { code: 401, message: 'Not logged in', err: err };
91+
}
92+
throw err;
9093
})
9194
.then(auth => {
9295
if (auth && auth.userCtx && auth.userCtx.name) {
9396
req.headers['X-Medic-User'] = auth.userCtx.name;
9497
return auth.userCtx;
9598
}
96-
throw { code: 401, message: 'Not logged in' };
99+
throw { code: 500, message: 'Failed to authenticate' };
97100
});
98101
},
99102

api/src/middleware/authorization.js

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,40 @@ module.exports = {
3131
.then(next);
3232
},
3333

34-
// blocks offline users not-authorized requests
35-
offlineUserFirewall: (req, res, next) => {
34+
handleAuthErrors: (req, res, next) => {
35+
if (req.authErr) {
36+
return serverUtils.error(req.authErr, req, res);
37+
}
38+
3639
if (!req.userCtx) {
37-
return serverUtils.notLoggedIn(req, res);
40+
return serverUtils.error('Authentication error', req, res);
41+
}
42+
43+
next();
44+
},
45+
46+
handleAuthErrorsAllowingAuthorized: (req, res, next) => {
47+
if (req.authorized) {
48+
return next();
3849
}
3950

40-
if (!auth.isOnlineOnly(req.userCtx) && !req.authorized) {
51+
return module.exports.handleAuthErrors(req, res, next);
52+
},
53+
54+
// blocks offline users not-authorized requests
55+
offlineUserFirewall: (req, res, next) => {
56+
if (req.userCtx && !auth.isOnlineOnly(req.userCtx) && !req.authorized) {
4157
res.status(FIREWALL_ERROR.code);
4258
return res.json(FIREWALL_ERROR);
4359
}
4460
next();
4561
},
4662

63+
// proxies unauthenticated requests to CouchDB
4764
// proxies online users requests to CouchDB
4865
// saves offline user-settings doc in the request object
4966
onlineUserProxy: (proxy, req, res, next) => {
50-
if (!req.userCtx) {
51-
return serverUtils.notLoggedIn(req, res);
52-
}
53-
54-
if (auth.isOnlineOnly(req.userCtx)) {
67+
if (!req.userCtx || auth.isOnlineOnly(req.userCtx)) {
5568
return proxy.web(req, res);
5669
}
5770

@@ -62,10 +75,6 @@ module.exports = {
6275
// saves offline user-settings doc in the request object
6376
// used for audited endpoints
6477
onlineUserPassThrough:(req, res, next) => {
65-
if (!req.userCtx) {
66-
return serverUtils.notLoggedIn(req, res);
67-
}
68-
6978
if (auth.isOnlineOnly(req.userCtx)) {
7079
return next('route');
7180
}
@@ -80,10 +89,6 @@ module.exports = {
8089
},
8190

8291
getUserSettings: (req, res, next) => {
83-
if (!req.userCtx) {
84-
return serverUtils.notLoggedIn(req, res);
85-
}
86-
8792
if (auth.isOnlineOnly(req.userCtx)) {
8893
return next();
8994
}

api/src/middleware/connected-user-log.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ module.exports = {
88
.getUserCtx(req)
99
.then(({ name }) => connectedUserLogService.save(name))
1010
.catch(err => {
11+
if (err && err.code === 401) {
12+
// don't spam the logs with authentication errors
13+
return;
14+
}
15+
1116
logger.error('Error recording user connection:', err);
1217
})
1318
.then(() => next());

api/src/routing.js

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ const ONLINE_ONLY_ENDPOINTS = [
265265

266266
// block offline users from accessing some unaudited CouchDB endpoints
267267
ONLINE_ONLY_ENDPOINTS.forEach(url =>
268-
app.all(routePrefix + url, authorization.offlineUserFirewall)
268+
app.all(routePrefix + url, authorization.handleAuthErrors, authorization.offlineUserFirewall)
269269
);
270270

271271
// allow anyone to access their session
@@ -431,12 +431,36 @@ app.postJson('/api/v1/people', function(req, res) {
431431
app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete);
432432

433433
// offline users are not allowed to hydrate documents via the hydrate API
434-
app.get('/api/v1/hydrate', authorization.offlineUserFirewall, jsonQueryParser, hydration.hydrate);
435-
app.post('/api/v1/hydrate', authorization.offlineUserFirewall, jsonParser, jsonQueryParser, hydration.hydrate);
434+
app.get(
435+
'/api/v1/hydrate',
436+
authorization.handleAuthErrors,
437+
authorization.offlineUserFirewall,
438+
jsonQueryParser,
439+
hydration.hydrate
440+
);
441+
app.post(
442+
'/api/v1/hydrate',
443+
authorization.handleAuthErrors,
444+
authorization.offlineUserFirewall,
445+
jsonParser,
446+
jsonQueryParser,
447+
hydration.hydrate
448+
);
436449

437450
// offline users are not allowed to get contacts by phone
438-
app.get('/api/v1/contacts-by-phone', authorization.offlineUserFirewall, contactsByPhone.request);
439-
app.post('/api/v1/contacts-by-phone', authorization.offlineUserFirewall, jsonParser, contactsByPhone.request);
451+
app.get(
452+
'/api/v1/contacts-by-phone',
453+
authorization.handleAuthErrors,
454+
authorization.offlineUserFirewall,
455+
contactsByPhone.request
456+
);
457+
app.post(
458+
'/api/v1/contacts-by-phone',
459+
authorization.handleAuthErrors,
460+
authorization.offlineUserFirewall,
461+
jsonParser,
462+
contactsByPhone.request
463+
);
440464

441465
app.get(`${appPrefix}app_settings/${environment.ddoc}/:path?`, settings.getV0); // deprecated
442466
app.get('/api/v1/settings', settings.get);
@@ -447,9 +471,24 @@ app.putJson('/api/v1/settings', settings.put);
447471

448472
app.get('/api/couch-config-attachments', couchConfigController.getAttachments);
449473

450-
app.get('/purging', authorization.onlineUserPassThrough, purgedDocsController.info);
451-
app.get('/purging/changes', authorization.onlineUserPassThrough, purgedDocsController.getPurgedDocs);
452-
app.get('/purging/checkpoint', authorization.onlineUserPassThrough, purgedDocsController.checkpoint);
474+
app.get(
475+
'/purging',
476+
authorization.handleAuthErrors,
477+
authorization.onlineUserPassThrough,
478+
purgedDocsController.info
479+
);
480+
app.get(
481+
'/purging/changes',
482+
authorization.handleAuthErrors,
483+
authorization.onlineUserPassThrough,
484+
purgedDocsController.getPurgedDocs
485+
);
486+
app.get(
487+
'/purging/checkpoint',
488+
authorization.handleAuthErrors,
489+
authorization.onlineUserPassThrough,
490+
purgedDocsController.checkpoint
491+
);
453492

454493
app.get('/api/v1/users-doc-count', replicationLimitLogController.get);
455494

@@ -467,11 +506,13 @@ const changesPath = routePrefix + '_changes(/*)?';
467506

468507
app.get(
469508
changesPath,
509+
authorization.handleAuthErrors,
470510
onlineUserChangesProxy,
471511
changesHandler
472512
);
473513
app.post(
474514
changesPath,
515+
authorization.handleAuthErrors,
475516
onlineUserChangesProxy,
476517
jsonParser,
477518
changesHandler
@@ -481,9 +522,16 @@ app.post(
481522
const allDocsHandler = require('./controllers/all-docs').request;
482523
const allDocsPath = routePrefix + '_all_docs(/*)?';
483524

484-
app.get(allDocsPath, onlineUserProxy, jsonQueryParser, allDocsHandler);
525+
app.get(
526+
allDocsPath,
527+
authorization.handleAuthErrors,
528+
onlineUserProxy,
529+
jsonQueryParser,
530+
allDocsHandler
531+
);
485532
app.post(
486533
allDocsPath,
534+
authorization.handleAuthErrors,
487535
onlineUserProxy,
488536
jsonParser,
489537
jsonQueryParser,
@@ -494,6 +542,7 @@ app.post(
494542
const bulkGetHandler = require('./controllers/bulk-get').request;
495543
app.post(
496544
routePrefix + '_bulk_get(/*)?',
545+
authorization.handleAuthErrors,
497546
onlineUserProxy,
498547
jsonParser,
499548
jsonQueryParser,
@@ -506,6 +555,7 @@ app.post(
506555
routePrefix + '_bulk_docs(/*)?',
507556
jsonParser,
508557
infodoc.mark,
558+
authorization.handleAuthErrors,
509559
authorization.onlineUserPassThrough, // online user requests pass through to the next route
510560
jsonQueryParser,
511561
bulkDocs.request,
@@ -521,6 +571,7 @@ const ddocPath = routePrefix + '_design/+:ddocId*';
521571

522572
app.get(
523573
ddocPath,
574+
authorization.handleAuthErrors,
524575
onlineUserProxy,
525576
jsonQueryParser,
526577
_.partial(dbDocHandler.requestDdoc, environment.ddoc),
@@ -529,6 +580,7 @@ app.get(
529580

530581
app.get(
531582
docPath,
583+
authorization.handleAuthErrors,
532584
onlineUserProxy, // online user GET requests are proxied directly to CouchDB
533585
jsonQueryParser,
534586
dbDocHandler.request
@@ -537,6 +589,7 @@ app.post(
537589
`/+${environment.db}/?`,
538590
jsonParser,
539591
infodoc.mark,
592+
authorization.handleAuthErrors,
540593
authorization.onlineUserPassThrough, // online user requests pass through to the next route
541594
jsonQueryParser,
542595
dbDocHandler.request,
@@ -546,20 +599,23 @@ app.put(
546599
docPath,
547600
jsonParser,
548601
infodoc.mark,
602+
authorization.handleAuthErrors,
549603
authorization.onlineUserPassThrough, // online user requests pass through to the next route,
550604
jsonQueryParser,
551605
dbDocHandler.request,
552606
authorization.setAuthorized // adds the `authorized` flag to the `req` object, so it passes the firewall
553607
);
554608
app.delete(
555609
docPath,
610+
authorization.handleAuthErrors,
556611
authorization.onlineUserPassThrough, // online user requests pass through to the next route,
557612
jsonQueryParser,
558613
dbDocHandler.request,
559614
authorization.setAuthorized // adds the `authorized` flag to the `req` object, so it passes the firewall
560615
);
561616
app.all(
562617
attachmentPath,
618+
authorization.handleAuthErrors,
563619
authorization.onlineUserPassThrough, // online user requests pass through to the next route
564620
jsonQueryParser,
565621
dbDocHandler.request,
@@ -677,6 +733,10 @@ proxyForChanges.on('proxyReq', (proxyReq, req) => {
677733

678734
// because these are longpolls, we need to manually flush the CouchDB heartbeats through compression
679735
proxyForChanges.on('proxyRes', (proxyRes, req, res) => {
736+
if (proxyRes.statusCode === 401) {
737+
return serverUtils.notLoggedIn(req, res);
738+
}
739+
680740
copyProxyHeaders(proxyRes, res);
681741

682742
proxyRes.pipe(res);
@@ -700,6 +760,7 @@ app.all(appPrefix + '*', authorization.setAuthorized);
700760
// block offline users requests from accessing CouchDB directly, via Proxy
701761
// requests which are authorized (fe: by BulkDocsHandler or DbDocHandler) can pass through
702762
// unauthenticated requests will be redirected to login or given a meaningful error
763+
app.use(authorization.handleAuthErrorsAllowingAuthorized);
703764
app.use(authorization.offlineUserFirewall);
704765

705766
const canEdit = function(req, res) {
@@ -742,8 +803,18 @@ proxyForAuth.on('proxyReq', function(proxyReq, req) {
742803
writeParsedBody(proxyReq, req);
743804
});
744805

806+
proxy.on('proxyRes', (proxyRes, req, res) => {
807+
if (proxyRes.statusCode === 401) {
808+
serverUtils.notLoggedIn(req, res);
809+
}
810+
});
811+
745812
// intercept responses from filtered offline endpoints to fill in with forbidden docs stubs
746813
proxyForAuth.on('proxyRes', (proxyRes, req, res) => {
814+
if (proxyRes.statusCode === 401) {
815+
return serverUtils.notLoggedIn(req, res);
816+
}
817+
747818
copyProxyHeaders(proxyRes, res);
748819

749820
if (res.interceptResponse) {

api/src/server-utils.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ const MEDIC_BASIC_AUTH = 'Basic realm="Medic Mobile Web Services"';
88
const wantsJSON = req => req.get('Accept') === 'application/json';
99

1010
const writeJSON = (res, code, error, details) => {
11-
res.status(code);
12-
res.json({ code, error, details });
11+
if (!res.headersSent && !res.ended) {
12+
res.status(code);
13+
res.json({ code, error, details });
14+
}
1315
};
1416

1517
const respond = (req, res, code, message, details) => {
@@ -72,6 +74,10 @@ module.exports = {
7274
* an authentication error.
7375
*/
7476
notLoggedIn: (req, res, showPrompt) => {
77+
if (!res.headersSent) {
78+
res.setHeader('logout-authorization', 'CHT-Core API');
79+
}
80+
7581
if (showPrompt) {
7682
// api access - basic auth allowed
7783
promptForBasicAuth(res);

api/tests/mocha/auth.spec.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('Auth', () => {
2424

2525
it('returns error when not logged in', () => {
2626
environment.serverUrl = 'http://abc.com';
27-
const get = sinon.stub(request, 'get').resolves();
27+
const get = sinon.stub(request, 'get').rejects({ statusCode: 401 });
2828
return auth.check({ }).catch(err => {
2929
chai.expect(get.callCount).to.equal(1);
3030
chai.expect(get.args[0][0].url).to.equal('http://abc.com/_session');
@@ -33,24 +33,34 @@ describe('Auth', () => {
3333
});
3434
});
3535

36+
it('returns error with incomplete session', () => {
37+
environment.serverUrl = 'http://abc.com';
38+
const get = sinon.stub(request, 'get').resolves();
39+
return auth.check({ }).catch(err => {
40+
chai.expect(get.callCount).to.equal(1);
41+
chai.expect(get.args[0][0].url).to.equal('http://abc.com/_session');
42+
chai.expect(err.message).to.equal('Failed to authenticate');
43+
chai.expect(err.code).to.equal(500);
44+
});
45+
});
46+
3647
it('returns error when no user context', () => {
3748
environment.serverUrl = 'http://abc.com';
3849
const get = sinon.stub(request, 'get').resolves({ roles: [] });
3950
return auth.check({ }).catch(err => {
4051
chai.expect(get.callCount).to.equal(1);
41-
chai.expect(err.message).to.equal('Not logged in');
42-
chai.expect(err.code).to.equal(401);
52+
chai.expect(err.message).to.equal('Failed to authenticate');
53+
chai.expect(err.code).to.equal(500);
4354
});
4455
});
4556

4657
it('returns error when request errors', () => {
4758
environment.serverUrl = 'http://abc.com';
48-
const get = sinon.stub(request, 'get').rejects('boom');
59+
const get = sinon.stub(request, 'get').rejects({ error: 'boom' });
4960
return auth.check({ }).catch(err => {
5061
chai.expect(get.callCount).to.equal(1);
5162
chai.expect(get.args[0][0].url).to.equal('http://abc.com/_session');
52-
chai.expect(err.message).to.equal('Not logged in');
53-
chai.expect(err.code).to.equal(401);
63+
chai.expect(err).to.deep.equal({ error: 'boom' });
5464
});
5565
});
5666

0 commit comments

Comments
 (0)