Return 401 if authentication is not provided#1488
Conversation
3f2926d to
82217e7
Compare
| service.login('bob', (asBob) => | ||
| Promise.all([ | ||
| service.get('/v1/roles/manager').expect(200).then(({ body }) => body.id), | ||
| asBob.get('/v1/roles/manager').expect(200).then(({ body }) => body.id), |
There was a problem hiding this comment.
This endpoint does not currently require authentication. How does that expectation changing relate to 401 vs 403 errors?
There was a problem hiding this comment.
Those two changes (more 401 errors + requiring auth for /v1/roles) both touch on the theme of anonymity. However, I agree that they're also conceptually distinct. If it's easy to pull the two changes apart, that'd make this PR a little smaller, which sounds nice. I'm also OK with reviewing both changes as part of this PR. I like the idea of having the /v1/roles endpoints check auth.
| // if POST run authentication as usual but we'll have to check CSRF afterwards. | ||
| return maybeSession.then((cxt) => { // we have to use cxt rather than context for the linter | ||
| // if authentication failed anyway, just do nothing. | ||
| if ((cxt == null) || cxt.auth.session.isEmpty()) return; |
There was a problem hiding this comment.
Where is an "empty" session now handled?
There was a problem hiding this comment.
I think the callback passed to authBySessionToken() handles that case.
There was a problem hiding this comment.
Then shouldn't authBySessionToken() be returning the session itself, rather than an Option? And L111 would change from:
-const maybeSession = authBySessionToken(token, () => reject(Problem.user.authenticationFailed()));
+const session = authBySessionToken(token, () => reject(Problem.user.authenticationFailed()));
lib/resources/submissions.js
Outdated
| service.get(path, endpointByTestPath.openRosa(({ Projects, Forms }, { params, auth }) => | ||
| Projects.getById(params.projectId) | ||
| .then(getOrNotFound) | ||
| .then(() => (params.xmlFormId ? getForm(auth, params, params.xmlFormId, Forms) : resolve())) |
There was a problem hiding this comment.
It's not obvious how this change is related to 401 responses; any pointers?
There was a problem hiding this comment.
Besides checking for other errors, calling getForm here performs authorization for this endpoint. Currently if an user calls HEAD/GET /v1/projects/1/forms/restricted-form and project 1 exists then they will always get 200 response, even if they don't have any authorization on the Form or even if Form doesn't exist.
I am open to revert this change as well and create a separate PR
There was a problem hiding this comment.
I do think it'd be nice to separate it out if that's not too difficult. I was trying to think through the implications of the change. I don't even remember how this endpoint is used. Is there maybe a reason why it needs to be ultra-performant and shouldn't be doing as many checks? Though I guess if it's checking the project, it should probably check the form as well. 🤔 Anyway, I feel like discussing in a separate PR could be nice!
This comment was marked as resolved.
This comment was marked as resolved.
Checking one endpoint at random, it looks like some API docs might need updates: Lines 1965 to 1970 in 889d208 |
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
For the GET /users endpoint, could you remove the auth.isAuthenticated check? Since the endpoint uses endpoint, we can be sure that it's authenticated.
There was a problem hiding this comment.
Or maybe it's a nice safeguard? It sort of feels redundant to me now.
| .then((token) => mail(savedUser.email, 'accountCreated', { token }))) | ||
| .then(always(savedUser))))); | ||
|
|
||
| const endpointByInvalidateQuery = (cb) => (req, ...rest) => |
There was a problem hiding this comment.
endpointByInvalidateQuery and endpointByTestPath above make me think that if/when we update endpoint to accept an options object, we should make the anonymous option take a callback.
Maybe it'd be reasonable to do something like that now? What if anonymousEndpoint accepted a function returning true/false, and then anonymousEndpoint either called authHandler or not based on the result? We could even make the function required, since Ramda F would be easy to pass in.
Just a thought, I'm also happy with the approach here. 👍
test/integration/api/assignments.js
Outdated
| service.login('alice', (asAlice) => | ||
| service.login('bob', (asBob) => |
There was a problem hiding this comment.
Doesn't need to change, but just FYI, you can pass an array to service.login():
service.login(['alice', 'bob'], (asAlice, asBob) =>
test/integration/api/sessions.js
Outdated
| it('should return a 403 if the token does not exist', testService((service) => | ||
| service.delete('/v1/sessions/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') | ||
| .expect(403))); | ||
| .expect(401))); |
There was a problem hiding this comment.
I feel like this test is looking at nonexistence, not lack of authentication. I think the test should have Alice send the request, then assert that it's a 403. This endpoint is unusual in that it returns a 403 in the nonexistence case instead of a 404.
test/integration/api/sessions.js
Outdated
| @@ -162,7 +162,7 @@ describe('api: /sessions', () => { | |||
| service.get('/v1/sessions/restore') | |||
| .set('X-Forwarded-Proto', 'https') | |||
| .set('Cookie', 'session: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') | |||
There was a problem hiding this comment.
This cookie looks unusual, shouldn't it be = instead of :?
| .set('X-Forwarded-Proto', 'https') | ||
| .set('Cookie', 'session: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') | ||
| .expect(404))); | ||
| .expect(401))); |
There was a problem hiding this comment.
This is probably on your radar already, but the change from 404 to 401 will require a change to restoreSession() in Frontend.
test/unit/http/preprocessors.js
Outdated
| )).should.be.rejectedWith(Problem, { problemCode: 401.2 })); | ||
|
|
||
| it('should do nothing on cookie auth with incorrect session token for non-GET requests', () => | ||
| it('should reject cookie auth with incorrect session token for non-GET requests', () => |
There was a problem hiding this comment.
Maybe we can remove the part of the test description about "for non-GET requests". We will now fail even GET requests.
matthew-white
left a comment
There was a problem hiding this comment.
The approach that's being taken here seems like a good one to me. I left some questions/thoughts above, but I think this will be a nice change for Central.
82217e7 to
f1d3be0
Compare
f1d3be0 to
9523d9e
Compare
|
Even though I know getodk/central#1001 is a large part of the motivation for this PR, I think it'd be easiest to track this change on its own. I'll go ahead and add it to the project. |
matthew-white
left a comment
There was a problem hiding this comment.
Notes from reviewing changes to lib/, now onward to test/.
| // fail the request unless we are under HTTPS. | ||
| if ((context.protocol !== 'https') && (context.headers['x-forwarded-proto'] !== 'https')) | ||
| return; | ||
| return reject(Problem.user.httpsOnly()); |
There was a problem hiding this comment.
How about updating the comment above:
Cookie Auth, which is more relaxed about not doing anything on failures.
I guess cookie auth is now not as relaxed.
There was a problem hiding this comment.
I think we can probably get rid of this line now. I don't think there are any conditions where the preprocessor would pass, but the endpoint would reject here:
central-backend/lib/resources/forms.js
Line 74 in 4ea6121
Today, most endpoints return a 403 if no auth is provided. However, IIRC we specifically needed this endpoint to return a 401. I think a 401 causes Collect and/or Enketo to do something special like prompt for credentials or show a special error.
But now we will return a 401 from the preprocessor, so we don't need to do anything special here.
There was a problem hiding this comment.
Now that the preprocessor ensures that auth is present, what do you think of the following pattern in /v1/users/current? I feel like the .orElse() is impossible now, but I'm not totally sure how we'd do things differently.
// Returns the currently authed actor.
service.get('/users/current', endpoint(({ Auth, Users, UserPreferences }, { auth, queryOptions }) =>
auth.actor.map((actor) =>
{ /* some code */ })
.orElse(Problem.user.notFound())));There was a problem hiding this comment.
I have refactored this function completely on async/await pattern.
There was a problem hiding this comment.
Also related to /v1/users/current, I was remembering that pyODK uses that endpoint to check whether the session token is still active. If the session token isn't active, the endpoint would return a 404 before, but now it will return a 401. I took a look at the pyODK code to make sure that it wasn't looking specifically for a 404 (it's not): https://github.com/getodk/pyodk/blob/07a6fb568bd60a5c7657f05be3f9ab016fdfbf3e/pyodk/_endpoints/auth.py#L25-L34
sadiqkhoja
left a comment
There was a problem hiding this comment.
I have removed checkAuth from user-preferences.js as well.
And added couple of additional tests for /users/current
There was a problem hiding this comment.
I have refactored this function completely on async/await pattern.
| ]; | ||
|
|
||
| describe('anonymous endpoints', () => { | ||
| anonymousEndpoints.filter(e => !e.skipForOidc).forEach(({ method, url }) => { |
There was a problem hiding this comment.
I may be missing something, but I don't see how the tests for which skipForOidc is true would ever be run. They're filtered out here, and I don't see anywhere else where they are used. I feel like process.env.TEST_AUTH needs to be used somehow, something like:
const endpointsToTest = process.env.TEST_AUTH === 'oidc'
? anonymousEndpoints.filter(e => !e.skipForOidc)
: anonymousEndpoints;| it('should fail the request if invalidation is requested but not allowed', testService((service) => | ||
| service.post('/v1/users/reset/initiate?invalidate=true') | ||
| .send({ email: 'alice@getodk.org' }) | ||
| .expect(403))); | ||
| .expect(401))); |
There was a problem hiding this comment.
How about adding a test that Chelsa gets a 403 if she specifies invalidate=true to try to invalidate Alice's password? I took a quick look, but I didn't see an existing test that was exactly like that.
| { method: 'PUT', url: '/v1/users/1/password', skipForOidc: true }, | ||
| { method: 'GET', url: '/v1/users/current' }, | ||
| { method: 'POST', url: '/v1/users/reset/verify', skipForOidc: true } | ||
| ]; |
There was a problem hiding this comment.
How about adding url: '/v1/users/reset/initiate?invalidate=true to this list? Unless it's already covered sufficiently elsewhere?
| it('should return a 404 if no token was specified', testService(service => | ||
| it('should return a 401 if no token was specified', testService(service => | ||
| service.delete('/v1/sessions/current') | ||
| .expect(404))); | ||
| .expect(401))); |
There was a problem hiding this comment.
Looking at the endpoint, I feel like it's still possible for a 404 to happen. If the user authenticates using Basic auth, then auth.actor will exist, but auth.session will not. In that case, it won't be a 401 (because there is auth), but it will be a 404 (because there's no current session).
30e59d0 to
dcc9bb4
Compare
dcc9bb4 to
e8c16d0
Compare
|
I feel like there are a few more places where central-backend/lib/resources/comments.js Line 35 in 20d7e72 |
|
I guess I'm just proposing |
matthew-white
left a comment
There was a problem hiding this comment.
LGTM 🚀
Thank you for making this change @sadiqkhoja, it's definitely a great improvement to the codebase.
There was a problem hiding this comment.
Have any new endpoints been committed to master since this PR was created? If so, they could be added to the list below.
There was a problem hiding this comment.
no new endpoint has been added since then
| service.get('/projects/:projectId/formList', endpoint.openRosa(({ Forms, Projects, env }, { auth, params, originalUrl, queryOptions }) => | ||
| Projects.getById(params.projectId) | ||
| .then(getOrNotFound) | ||
| .then(rejectIf((() => auth.actor.isEmpty()), noargs(Problem.user.openRosaAuthenticationRequired))) |
There was a problem hiding this comment.
How about removing this Problem from lib/util/problem.js? I guess there's no harm in leaving it, but it doesn't look like it's used anywhere else.
lib/resources/users.js
Outdated
| .then(always(savedUser))))); | ||
|
|
||
| const endpointByInvalidateQuery = (cb) => (req, ...rest) => | ||
| (req.query.invalidate ? endpoint(cb) : anonymousEndpoint(cb))(req, ...rest); |
There was a problem hiding this comment.
| (req.query.invalidate ? endpoint(cb) : anonymousEndpoint(cb))(req, ...rest); | |
| (isTrue(req.query.invalidate) ? endpoint(cb) : anonymousEndpoint(cb))(req, ...rest); |
I think we should use isTrue() here to exactly match how query.invalidate is used elsewhere in the endpoint. To avoid subtle differences between truthiness vs. isTrue().
| await service.get(`/v1/form-links/${enketoId}/form`) | ||
| .expect(404); | ||
| .expect(401); |
There was a problem hiding this comment.
I don't 100% understand why it was 404 before and not 403. I think that enketoId must be null here, because we create the simple form without an enketoId (fixtures/02-forms.js). Maybe the test would be better if it created a new form that does have an enketoId. It's an existing test though, so I don't think we need to change that in this PR. And most importantly, 401 is the correct error code going forward.
test/integration/api/users.js
Outdated
| const should = require('should'); | ||
| const { getOrNotFound } = require(appRoot + '/lib/util/promise'); | ||
| const { testService } = require('../setup'); | ||
| const { describe } = require('mocha'); |
There was a problem hiding this comment.
Since these tests existed before and use describe(), I feel like this addition probably isn't needed?
|
|
||
| describe('authentication', () => { | ||
| const anonymousEndpoints = [ | ||
| { method: 'POST', url: '/v1/sessions', skipForOidc: true }, |
There was a problem hiding this comment.
I don't feel like it's especially helpful to include /v1/sessions in this list. The test is asserting that a request to the endpoint doesn't return a 401. But the endpoint is capable of returning a 401 if the email or password is incorrect.
There was a problem hiding this comment.
the anonymousEndpoints array lists endpoints that don't look for any authentication
| return; | ||
| // actually try to authenticate with it. no Problem on failure. | ||
| const maybeSession = authBySessionToken(token); | ||
| return reject(Problem.user.authenticationFailed()); |
There was a problem hiding this comment.
I don't think I see a test of this change, where:
- There is a Cookie header (
context.headers.cookie != null) - But there is no session cookie (
context.cookies[SESSION_COOKIE].token == null) - In that case, it should be a 401.
I see similar tests:
- The Cookie header is invalid
- The token is invalid
But I'm not sure I see a test that gets at the sequence I described. I guess I want tests that get at the difference between these two:
- There's no cookie of any kind at all
- There is some Cookie header, just not a session cookie
Could you check that tests verify that? If they don't, could you add a test along those lines?
lib/resources/submissions.js
Outdated
| // SUBMISSIONS (OPENROSA) | ||
|
|
||
| const openRosaSubmission = (path, draft, getForm) => { | ||
| const openRosaSubmission = (path, endpointToUse, draft, getForm) => { |
There was a problem hiding this comment.
nits: arg order, arg name
| const openRosaSubmission = (path, endpointToUse, draft, getForm) => { | |
| const openRosaSubmission = (_endpoint, path, draft, getForm) => { |
There was a problem hiding this comment.
I have renamed endpointToUse to _endpoint but I would like to keep path as the first arg, it's easy to scan.
This comment was marked as resolved.
This comment was marked as resolved.
* Return 401 if authentication is not provided * Make /oidc/login anonymous * Incorporated PR Feedback * Added tests for authentication in one place * More PR Feedback * PR feedback related to tests * Remove check for Form existence from GET /submissions openrosa endpoint * Incorporated PR Feedback
Prerequisite for getodk/central#1001
What has been done to verify that this works as intended?
All tests are passing.
Some tests are updated to reflect the correct requirement.
Why is this the best possible solution? Were any other approaches considered?
It is necessary to distinguish between 401 and 403, previously we were relying on resources to perform authorization even when authentication failed.
How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?
This PR shouldn't be merged until corresponding frontend changes are ready. There are some places where frontend code expects 403, that needs to be updated.
Does this change require updates to the API documentation? If so, please update docs/api.yaml as part of this PR.
No
Before submitting this PR, please make sure you have:
make testand confirmed all checks still pass OR confirm CircleCI build passes