diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..2e1fa2d52e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 71df536115..2e513186a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ __BREAKING CHANGES:__ - NEW: Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html). [#7071](https://github.com/parse-community/parse-server/pull/7071). Thanks to [dblythy](https://github.com/dblythy). ___ +- IMPROVE: Improved Auth Adapter Interface [7052](https://github.com/parse-community/parse-server/pull/7052) Thanks to [Moumouls](https://github.com/Moumouls). - IMPROVE: Optimize queries on classes with pointer permissions. [#7061](https://github.com/parse-community/parse-server/pull/7061). Thanks to [Pedro Diaz](https://github.com/pdiaz) - FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c2ccd3bd8..c602426a08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,12 +8,12 @@ If you are not familiar with Pull Requests and want to know more about them, you ### Recommended setup: -* [vscode](https://code.visualstudio.com), the popular IDE. -* [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-jasmine-test-adapter), a very practical test exploration plugin which let you run, debug and see the test results inline. +- [vscode](https://code.visualstudio.com), the popular IDE. +- [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-jasmine-test-adapter), a very practical test exploration plugin which let you run, debug and see the test results inline. ### Setting up you local machine: -* [Fork](https://github.com/parse-community/parse-server) this project and clone the fork on your local machine: +- [Fork](https://github.com/parse-community/parse-server) this project and clone the fork on your local machine: ```sh $ git clone https://github.com/parse-community/parse-server @@ -29,44 +29,44 @@ Once you have babel running in watch mode, you can start making changes to parse ### Good to know: -* The `lib/` folder is not commited, so never make changes in there. -* Always make changes to files in the `src/` folder. -* All the tests should point to sources in the `lib/` folder. +- The `lib/` folder is not commited, so never make changes in there. +- Always make changes to files in the `src/` folder. +- All the tests should point to sources in the `lib/` folder. ### Troubleshooting: -*Question*: I modify the code in the src folder but it doesn't seem to have any effect.
-*Answer*: Check that `npm run watch` is running +_Question_: I modify the code in the src folder but it doesn't seem to have any effect.
+_Answer_: Check that `npm run watch` is running -*Question*: How do I use breakpoints and debug step by step?
-*Answer*: The easiest way is to install [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer), it will let you run selectively tests and debug them. - -*Question*: How do I deploy my forked version on my servers?
-*Answer*: In your `package.json`, update the `parse-server` dependency to `https://github.com/MY_USERNAME/parse-server#MY_FEATURE`. Run `npm install`, commit the changes and deploy to your servers. +_Question_: How do I use breakpoints and debug step by step?
+_Answer_: The easiest way is to install [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer), it will let you run selectively tests and debug them. +_Question_: How do I deploy my forked version on my servers?
+_Answer_: In your `package.json`, update the `parse-server` dependency to `https://github.com/MY_USERNAME/parse-server#MY_FEATURE`. Run `npm install`, commit the changes and deploy to your servers. ### Please Do's -* Begin by reading the [Development Guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) to learn how to get started running the parse-server. -* Take testing seriously! Aim to increase the test coverage with every pull request. To obtain the test coverage of the project, run: `npm run coverage` -* Run the tests for the file you are working on with the following command: `npm test spec/MyFile.spec.js` -* Run the tests for the whole project to make sure the code passes all tests. This can be done by running the test command for a single file but removing the test file argument. The results can be seen at */coverage/lcov-report/index.html*. -* Lint your code by running `npm run lint` to make sure the code is not going to be rejected by the CI. -* **Do not** publish the *lib* folder. +- Begin by reading the [Development Guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) to learn how to get started running the parse-server. +- Take testing seriously! Aim to increase the test coverage with every pull request. To obtain the test coverage of the project, run: `npm run coverage` +- Run the tests for the file you are working on with the following command: `npm test spec/MyFile.spec.js` +- Run the tests for the whole project to make sure the code passes all tests. This can be done by running the test command for a single file but removing the test file argument. The results can be seen at _/coverage/lcov-report/index.html_. +- Lint your code by running `npm run lint` to make sure the code is not going to be rejected by the CI. +- **Do not** publish the _lib_ folder. ### Run your tests against Postgres (optional) If your pull request introduces a change that may affect the storage or retrieval of objects, you may want to make sure it plays nice with Postgres. -* Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/.travis.yml#L43) or use [`Docker`](#run-a-parse-postgres-with-docker). -* The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug` -* If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with: +- Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/.travis.yml#L43) or use [`Docker`](#run-a-parse-postgres-with-docker). +- The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug` +- If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with: - `describe_only_db('mongo')` // will create a `describe` that runs only on mongoDB - `it_only_db('mongo')` // will make a test that only runs on mongo - `it_exclude_dbs(['postgres'])` // will make a test that runs against all DB's but postgres -* Similarly, if your feature is intended to only work with PostgreSQL, you should disable MongoDB-specific tests with: - + +- Similarly, if your feature is intended to only work with PostgreSQL, you should disable MongoDB-specific tests with: + - `describe_only_db('postgres')` // will create a `describe` that runs only on postgres - `it_only_db('postgres')` // will make a test that only runs on postgres - `it_exclude_dbs(['mongo'])` // will make a test that runs against all DB's but mongo @@ -75,9 +75,10 @@ If your pull request introduces a change that may affect the storage or retrieva [PostGIS images (select one with v2.2 or higher) on docker dashboard](https://hub.docker.com/r/postgis/postgis) is based off of the official [postgres](https://registry.hub.docker.com/_/postgres/) image and will work out-of-the-box (as long as you create a user with the necessary extensions for each of your Parse databases; see below). To launch the compatible Postgres instance, copy and paste the following line into your shell: -``` +```sh docker run -d --name parse-postgres -p 5432:5432 -e POSTGRES_PASSWORD=password --rm postgis/postgis:11-3.0-alpine && sleep 20 && docker exec -it parse-postgres psql -U postgres -c 'CREATE DATABASE parse_server_postgres_adapter_test_database;' && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis;' -d parse_server_postgres_adapter_test_database && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis_topology;' -d parse_server_postgres_adapter_test_database ``` + To stop the Postgres instance: ``` diff --git a/package.json b/package.json index 02f91b4e5e..f8c7cf8d26 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@graphql-tools/links": "6.2.5", "@graphql-tools/stitch": "6.2.4", "@graphql-tools/utils": "6.2.4", + "@node-rs/bcrypt": "0.4.1", "@parse/fs-files-adapter": "1.2.0", "@parse/push-adapter": "3.4.0", "@parse/s3-files-adapter": "1.6.0", @@ -105,7 +106,7 @@ "posttest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} mongodb-runner stop", "coverage": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} TESTING=1 nyc jasmine", "start": "node ./bin/parse-server", - "prettier": "prettier --write '{src,spec}/{**/*,*}.js'", + "prettier": "prettier --write '{src,spec}/{**/*,*}.js' && npm run lint-fix", "prepare": "npm run build", "postinstall": "node -p 'require(\"./postinstall.js\")()'" }, diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 87679d02a4..66d169a5f6 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -1748,3 +1748,808 @@ describe('microsoft graph auth adapter', () => { }); }); }); + +describe('Auth Adapter features', () => { + const baseAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const baseAdapter2 = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + options: { anOption: true }, + }; + + const doNotSaveAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve({ doNotSave: true }), + }; + + const additionalAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'additional', + }; + + const soloAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'solo', + }; + + const challengeAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + challenge: () => Promise.resolve({ token: 'test' }), + options: { + anOption: true, + }, + }; + + const modernAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const wrongAdapter = { + validateAppId: () => Promise.resolve(), + }; + + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('should pass authData, options, req, user to validateAuthData', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter } }); + + const user = new Parse.User(); + + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }); + + expect(user.getSessionToken()).toBeDefined(); + + const firstCall = baseAdapter.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter); + expect(firstCall[2].config).toBeDefined(); + expect(firstCall[2].config.headers).toBeDefined(); + expect(firstCall[2].auth).toBeDefined(); + expect(firstCall[3]).toBeUndefined(); + + // Use request sine JS SDK not ready + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }), + }); + const secondCall = baseAdapter.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter); + expect(secondCall[2].config).toBeDefined(); + expect(secondCall[2].auth).toBeDefined(); + expect(secondCall[2].config.headers).toBeDefined(); + expect(secondCall[3] instanceof Parse.User).toBeTruthy(); + expect(secondCall[3].id).toEqual(user.id); + }); + + it('should trigger correctly validateSetUp', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter } }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateSetUp.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].config).toBeDefined(); + expect(call[2].auth).toBeDefined(); + expect(call[2].config.headers).toBeDefined(); + expect(call[3]).toBeUndefined(); + expect(user.getSessionToken()).toBeDefined(); + }); + it('should trigger correctly validateLogin', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter } }); + const user = new Parse.User(); + + // Signup + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + // Login + const user2 = new Parse.User(); + await user2.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateLogin.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].config).toBeDefined(); + expect(call[2].auth).toBeDefined(); + expect(call[2].config.headers).toBeDefined(); + expect(call[3] instanceof Parse.User).toBeTruthy(); + expect(call[3].id).toEqual(user2.id); + expect(call[3].id).toEqual(user.id); + expect(user2.getSessionToken()).toBeDefined(); + }); + it('should trigger correctly validateUpdate', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter } }); + const user = new Parse.User(); + + // Signup + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + + // Save same data + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { sessionToken: user.getSessionToken() } + ); + + // Save same data with master key + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { useMasterKey: true } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + + // Change authData + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + const call = modernAdapter.validateUpdate.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter2' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].config).toBeDefined(); + expect(call[2].auth).toBeDefined(); + expect(call[2].config.headers).toBeDefined(); + expect(call[3] instanceof Parse.User).toBeTruthy(); + expect(call[3].id).toEqual(user.id); + expect(user.getSessionToken()).toBeDefined(); + }); + it('should throw if no triggers found', async () => { + await reconfigureServer({ auth: { wrongAdapter } }); + const user = new Parse.User(); + try { + await user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }); + fail('should throw'); + } catch (e) { + expect(e.message).toContain( + 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + ); + } + }); + it('should not update authData if provider return doNotSave', async () => { + spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: true }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { baseAdapter: { id: 'baseAdapter' }, doNotSaveAdapter: { token: true } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'baseAdapter' } }); + }); + it('should perform authData validation only when its required', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { baseAdapter2, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(2); + }); + it('should require additional provider if configured', async () => { + await reconfigureServer({ + auth: { baseAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + try { + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }); + fail('should require additional authData'); + } catch (e) { + expect(e.message).toContain('Missing additional authData additionalAdapter'); + expect(user2.getSessionToken()).toBeUndefined(); + } + + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + expect(user2.getSessionToken()).toBeDefined(); + }); + it('should skip additional provider if used provider is solo', async () => { + await reconfigureServer({ + auth: { soloAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + }, + }); + expect(user2.getSessionToken()).toBeDefined(); + }); + it('should return authData response and save some info on non username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.save( + { + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(user2.get('authDataResponse')).toEqual({ baseAdapter2: { someData2: true } }); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + // On logIn all authData are revalidated + expect(user3.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const userViaMasterKey = new Parse.User(); + userViaMasterKey.id = user2.id; + await userViaMasterKey.fetch({ useMasterKey: true }); + expect(userViaMasterKey.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + it('should return authData response and save some info on username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }), + }); + const result = JSON.parse(res.text); + expect(result.authDataResponse).toEqual({ + baseAdapter2: { someData2: true }, + baseAdapter: { someData: true }, + }); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + it('should allow update of authData', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + expect(user.id).toBeDefined(); + expect(user.getSessionToken()).toBeDefined(); + // Should not re validate the baseAdapter + // when user is already logged in and authData not changed + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + // Should not re validate the baseAdapter + // when master key used and authData are not changed + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + // Should allow user to change authData + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter2' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + + // Should allow master key to change authData + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter3' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(3); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter3' }, + baseAdapter2: { otherData: true }, + }); + }); + it('should pass user to auth adapter on update by matching session', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter2 } }); + + const user = new Parse.User(); + + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + }); + + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { baseAdapter2: payload } }, + { sessionToken: user.getSessionToken() } + ); + + const firstCall = baseAdapter2.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter2); + expect(firstCall[2].config).toBeDefined(); + expect(firstCall[2].auth).toBeDefined(); + expect(firstCall[2].config.headers).toBeDefined(); + expect(firstCall[3] instanceof Parse.User).toBeTruthy(); + expect(firstCall[3].id).toEqual(user.id); + + await user.save({ authData: { baseAdapter2: payload } }, { useMasterKey: true }); + + const secondCall = baseAdapter2.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter2); + expect(secondCall[2].config).toBeDefined(); + expect(secondCall[2].auth).toBeDefined(); + expect(secondCall[2].config.headers).toBeDefined(); + expect(secondCall[3] instanceof Parse.User).toBeTruthy(); + expect(secondCall[3].id).toEqual(user.id); + }); + it('should return challenge with no logged user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: {}, + }); + fail('should throw Nothing to challenge.'); + } catch (e) { + expect(e.text).toContain('Nothing to challenge.'); + } + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: true }, + }); + fail('should throw challengeData should be an object.'); + } catch (e) { + expect(e.text).toContain('challengeData should be an object.'); + } + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: { data: true }, authData: true }, + }); + fail('should throw authData should be an object.'); + } catch (e) { + expect(e.text).toContain('authData should be an object.'); + } + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(JSON.parse(res.text)).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toBeUndefined(); + expect(challengeCall[2]).toBeUndefined(); + expect(challengeCall[3].config).toBeDefined(); + expect(challengeCall[3].auth).toBeDefined(); + expect(challengeCall[3].config.headers).toBeDefined(); + expect(challengeCall[4]).toEqual({ anOption: true }); + }); + it('should return challenge with username created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + fail('should throw You provided username or email, you need to also provide password.'); + } catch (e) { + expect(e.text).toContain( + 'You provided username or email, you need to also provide password.' + ); + } + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { data: true }, + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + fail( + 'should throw You cant provide username/email and authData, only use one identification method.' + ); + } catch (e) { + expect(e.text).toContain( + 'You cant provide username/email and authData, only use one identification method.' + ); + } + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(JSON.parse(res.text)).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2] instanceof Parse.User).toBeTruthy(); + expect(challengeCall[2].id).toEqual(user.id); + expect(challengeCall[3].config).toBeDefined(); + expect(challengeCall[3].auth).toBeDefined(); + expect(challengeCall[3].config.headers).toBeDefined(); + expect(challengeCall[4]).toEqual({ anOption: true }); + }); + it('should return challenge with authData created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }); + fail('should throw User not found.'); + } catch (e) { + expect(e.text).toContain('User not found.'); + } + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + const user2 = new Parse.User(); + await user2.save({ authData: { soloAdapter: { id: 'soloAdapter' } } }); + + try { + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + soloAdapter: { id: 'soloAdapter' }, + }, + }), + }); + fail('should throw You cant provide more than one authData provider with an id.'); + } catch (e) { + expect(e.text).toContain('You cant provide more than one authData provider with an id.'); + } + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }); + + expect(JSON.parse(res.text)).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual({ id: 'challengeAdapter' }); + expect(challengeCall[2] instanceof Parse.User).toBeTruthy(); + expect(challengeCall[2].id).toEqual(user.id); + expect(challengeCall[3].config).toBeDefined(); + expect(challengeCall[3].auth).toBeDefined(); + expect(challengeCall[3].config.headers).toBeDefined(); + expect(challengeCall[4]).toEqual({ anOption: true }); + }); +}); diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index d15bc2479d..c740696ea1 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -587,16 +587,10 @@ describe('cloud validator', () => { expect(obj.get('foo')).toBe('bar'); const query = new Parse.Query('beforeFind'); - try { - const first = await query.first({ useMasterKey: true }); - expect(first).toBeDefined(); - expect(first.id).toBe(obj.id); - done(); - } catch (e) { - console.log(e); - console.log(e.code); - throw e; - } + const first = await query.first({ useMasterKey: true }); + expect(first).toBeDefined(); + expect(first.id).toBe(obj.id); + done(); }); it('basic beforeDelete skipWithMasterKey', async function (done) { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index dad9bda3df..b40ccd31d7 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -903,8 +903,7 @@ describe('ParseGraphQLServer', () => { ).data['__type'].inputFields .map(field => field.name) .sort(); - - expect(inputFields).toEqual(['clientMutationId', 'password', 'username']); + expect(inputFields).toEqual(['authData', 'clientMutationId', 'password', 'username']); }); it('should have clientMutationId in log in mutation payload', async () => { @@ -6754,7 +6753,62 @@ describe('ParseGraphQLServer', () => { }); describe('Users Mutations', () => { + const challengeAdapter = { + validateAuthData: () => Promise.resolve({ response: { someData: true } }), + validateAppId: () => Promise.resolve(), + challenge: () => Promise.resolve({ someData: true }), + options: { anOption: true }, + }; + + it('should create user and return authData response', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + const clientMutationId = uuidv4(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { + clientMutationId + user { + id + authDataResponse + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, + }, + }, + }, + }); + + expect(result.data.createUser.clientMutationId).toEqual(clientMutationId); + expect(result.data.createUser.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + it('should sign user up', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); const clientMutationId = uuidv4(); const userSchema = new Parse.Schema('_User'); userSchema.addString('someField'); @@ -6771,6 +6825,7 @@ describe('ParseGraphQLServer', () => { sessionToken user { someField + authDataResponse aPointer { id username @@ -6786,6 +6841,11 @@ describe('ParseGraphQLServer', () => { fields: { username: 'user1', password: 'user1', + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, aPointer: { createAndLink: { username: 'user2', @@ -6805,6 +6865,9 @@ describe('ParseGraphQLServer', () => { expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined(); expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2'); expect(typeof result.data.signUp.viewer.sessionToken).toBe('string'); + expect(result.data.signUp.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should login with user', async () => { @@ -6813,6 +6876,7 @@ describe('ParseGraphQLServer', () => { parseServer = await global.reconfigureServer({ publicServerURL: 'http://localhost:13377/parse', auth: { + challengeAdapter, myAuth: { module: global.mockCustomAuthenticator('parse', 'graphql'), }, @@ -6832,6 +6896,7 @@ describe('ParseGraphQLServer', () => { sessionToken user { someField + authDataResponse aPointer { id username @@ -6845,6 +6910,7 @@ describe('ParseGraphQLServer', () => { input: { clientMutationId, authData: { + challengeAdapter: { id: 'challengeAdapter' }, myAuth: { id: 'parse', password: 'graphql', @@ -6870,9 +6936,69 @@ describe('ParseGraphQLServer', () => { expect(typeof result.data.logInWith.viewer.sessionToken).toBe('string'); expect(result.data.logInWith.viewer.user.aPointer.id).toBeDefined(); expect(result.data.logInWith.viewer.user.aPointer.username).toEqual('user2'); + expect(result.data.logInWith.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should handle challenge', async () => { + const clientMutationId = uuidv4(); + + spyOn(challengeAdapter, 'challenge').and.callThrough(); + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someChallengeData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2] instanceof Parse.User).toBeTruthy(); + expect(challengeCall[2].id).toEqual(user.id); + expect(challengeCall[3].config).toBeDefined(); + expect(challengeCall[3].auth).toBeDefined(); + expect(challengeCall[3].config.headers).toBeDefined(); + expect(challengeCall[4]).toEqual({ anOption: true }); + expect(result.data.challenge.clientMutationId).toEqual(clientMutationId); + expect(result.data.challenge.challengeData).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should log the user in', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); const clientMutationId = uuidv4(); const user = new Parse.User(); user.setUsername('user1'); @@ -6889,6 +7015,7 @@ describe('ParseGraphQLServer', () => { viewer { sessionToken user { + authDataResponse someField } } @@ -6900,6 +7027,7 @@ describe('ParseGraphQLServer', () => { clientMutationId, username: 'user1', password: 'user1', + authData: { challengeAdapter: { token: true } }, }, }, }); @@ -6908,6 +7036,9 @@ describe('ParseGraphQLServer', () => { expect(result.data.logIn.viewer.sessionToken).toBeDefined(); expect(result.data.logIn.viewer.user.someField).toEqual('someValue'); expect(typeof result.data.logIn.viewer.sessionToken).toBe('string'); + expect(result.data.logIn.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should log the user out', async () => { diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index a44926caa4..d1984333c4 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1789,7 +1789,7 @@ describe('Parse.User testing', () => { }); }); - it('should allow login with old authData token', done => { + it('should not allow login with old authData token', async () => { const provider = { authData: { id: '12345', @@ -1810,22 +1810,17 @@ describe('Parse.User testing', () => { }; defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith('shortLivedAuth', {}) - .then(() => { - // Simulate a remotely expired token (like a short lived one) - // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out - defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); - return Parse.User._logInWith('shortLivedAuth', {}); - }) - .then( - () => { - done(); - }, - err => { - done.fail(err); - } - ); + await Parse.User._logInWith('shortLivedAuth', {}); + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated one, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + try { + await Parse.User._logInWith('shortLivedAuth', {}); + fail('should not authenticate'); + } catch (e) { + expect(e).toBeDefined(); + } }); it('should allow PUT request with stale auth Data', done => { diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 9af6d5e449..a20aa6d0c5 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -1,20 +1,101 @@ /*eslint no-unused-vars: "off"*/ export class AuthAdapter { - /* + constructor() { + /** + * Usage policy + * default: can be combined with ONE additional auth provider if additional configured on user + * additional: could be only used with a default policy auth provider + * solo: Will ignore ALL additional providers if additional configured on user + */ + this.policy = 'default'; + } + /** @param appIds: the specified app ids in the configuration @param authData: the client provided authData @param options: additional options + @param req: RestWrite instance with config/auth/data @returns a promise that resolves if the applicationId is valid */ - validateAppId(appIds, authData, options) { + validateAppId(appIds, authData, options, req) { + return Promise.resolve({}); + } + + /** + * Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login) + * otherwise you should implement validateSetup, validateLogin and validateUpdate + @param authData: the client provided authData + @param options: additional options + @param req: RestWrite instance with config/auth/data + @param user: Parse.User instance if Parse.User found + @returns a promise that resolves, the resolved value will be handled by the server like: + - resolve undefined|void|{} parse server will save authData + - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse + - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse + - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse + */ + validateAuthData(authData, options, req, user) { return Promise.resolve({}); } - /* + /** + * Triggered when user provide for the first time this auth provider @param authData: the client provided authData @param options: additional options + @param req: RestWrite instance with config/auth/data + @param user: Parse.User instance if Parse.User found + @returns a promise that resolves, the resolved value will be handled by the server like: + - resolve undefined|void|{} parse server will save authData + - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse + - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse + - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse + */ + validateSetUp(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * he is not logged in and has already set this provider before + @param authData: the client provided authData + @param options: additional options + @param req: RestWrite instance with config/auth/data + @param user: Parse.User instance if Parse.User found + @returns a promise that resolves, the resolved value will be handled by the server like: + - resolve undefined|void|{} parse server will save authData + - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse + - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse + - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse + */ + validateLogin(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * he is logged in and has already set this provider before + @param authData: the client provided authData + @param options: additional options + @param req: RestWrite instance with config/auth/data + @param user: Parse.User instance if Parse.User found + @returns a promise that resolves, the resolved value will be handled by the server like: + - resolve undefined|void|{} parse server will save authData + - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse + - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse + - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse + */ + validateUpdate(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered in pre authentication process if needed (like webauthn, SMS OTP) + * @param challengeData: data provided by the client + * @param authData: auth data provided by the client, can be used for validation + * @param options: additional options + * @param req: RestWrite instance with config/auth/data + * @returns a promise that resolves, resolved value will be added to challenge response under challenge key */ - validateAuthData(authData, options) { + challenge(challengeData, authData, options, req) { return Promise.resolve({}); } } diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 00637d1131..39f8572466 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,4 +1,5 @@ import loadAdapter from '../AdapterLoader'; +import Parse from 'parse/node'; const apple = require('./apple'); const gcenter = require('./gcenter'); @@ -61,14 +62,49 @@ const providers = { ldap, }; -function authDataValidator(adapter, appIds, options) { - return function (authData) { - return adapter.validateAuthData(authData, options).then(() => { - if (appIds) { - return adapter.validateAppId(appIds, authData, options); +function authDataValidator(provider, adapter, appIds, options) { + return async function (authData, req, user) { + if (appIds && typeof adapter.validateAppId === 'function') { + await adapter.validateAppId(appIds, authData, options, req, user); + } + if (typeof adapter.validateAuthData === 'function') { + return adapter.validateAuthData(authData, options, req, user); + } else if ( + typeof adapter.validateSetUp === 'function' && + typeof adapter.validateLogin === 'function' && + typeof adapter.validateUpdate === 'function' + ) { + // We can consider for DX purpose when masterKey is detected, we should + // trigger a logged in user + const isLoggedIn = + (req.auth.user && req.auth.user.id === user.id) || (user && req.auth.isMaster); + let isUpdate = false; + let hasAuthDataConfigured = false; + + if (user && user.get('authData') && user.get('authData')[provider]) { + hasAuthDataConfigured = true; + } + + if (isLoggedIn && hasAuthDataConfigured) { + isUpdate = true; + } + + if (isUpdate) { + return adapter.validateUpdate(authData, options, req, user); + } + + if (!isLoggedIn && hasAuthDataConfigured) { + return adapter.validateLogin(authData, options, req, user); + } + + if (!hasAuthDataConfigured) { + return adapter.validateSetUp(authData, options, req, user); } - return Promise.resolve(); - }); + } + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + ); }; } @@ -94,7 +130,13 @@ function loadAuthAdapter(provider, authOptions) { if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - ['validateAuthData', 'validateAppId'].forEach(key => { + [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + ].forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -102,14 +144,6 @@ function loadAuthAdapter(provider, authOptions) { } } - // TODO: create a new module from validateAdapter() in - // src/Controllers/AdaptableController.js so we can use it here for adapter - // validation based on the src/Adapters/Auth/AuthAdapter.js expected class - // signature. - if (!adapter.validateAuthData || !adapter.validateAppId) { - return; - } - return { adapter, appIds, providerOptions }; } @@ -126,7 +160,7 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { const { adapter, appIds, providerOptions } = loadAuthAdapter(provider, authOptions); - return authDataValidator(adapter, appIds, providerOptions); + return authDataValidator(provider, adapter, appIds, providerOptions); }; return Object.freeze({ diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index acacbac048..9a38125779 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -272,7 +272,6 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus continue; } } - const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); if (authDataMatch) { // TODO: Handle querying by _auth_data_provider, authData is stored in authData field @@ -1234,15 +1233,20 @@ export class PostgresStorageAdapter implements StorageAdapter { return; } var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + const authDataAlreadyExists = !!object['authData']; if (authDataMatch) { var provider = authDataMatch[1]; object['authData'] = object['authData'] || {}; object['authData'][provider] = object[fieldName]; delete object[fieldName]; fieldName = 'authData'; + // Avoid pushing authData multiple times to the + // postgres query + if (authDataAlreadyExists) return; } columnsArray.push(fieldName); + if (!schema.fields[fieldName] && className === '_User') { if ( fieldName === '_email_verify_token' || @@ -1729,7 +1733,6 @@ export class PostgresStorageAdapter implements StorageAdapter { caseInsensitive, }); values.push(...where.values); - const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : ''; if (hasLimit) { diff --git a/src/Auth.js b/src/Auth.js index 2d63e785be..2cc0b6c0ec 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -1,6 +1,14 @@ const cryptoUtils = require('./cryptoUtils'); -const RestQuery = require('./RestQuery'); const Parse = require('parse/node'); +import _ from 'lodash'; + +const reducePromise = async (arr, fn, acc, index = 0) => { + if (arr[index]) { + const newAcc = await Promise.resolve(fn(acc, arr[index])); + return reducePromise(arr, fn, newAcc, index + 1); + } + return acc; +}; // An Auth object tells you who is requesting something and whether // the master key was used. @@ -84,7 +92,8 @@ const getAuthForSessionToken = async function ({ limit: 1, include: 'user', }; - + // For cyclic dep + const RestQuery = require('./RestQuery'); const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions); results = (await query.execute()).results; } else { @@ -126,6 +135,8 @@ var getAuthForLegacySessionToken = function ({ config, sessionToken, installatio var restOptions = { limit: 1, }; + // For cyclic dep + const RestQuery = require('./RestQuery'); var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions); return query.execute().then(response => { var results = response.results; @@ -170,6 +181,8 @@ Auth.prototype.getRolesForUser = async function () { objectId: this.user.id, }, }; + // For cyclic dep + const RestQuery = require('./RestQuery'); await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => results.push(result) ); @@ -254,6 +267,8 @@ Auth.prototype.getRolesByIds = async function (ins) { }; }); const restWhere = { roles: { $in: roles } }; + // For cyclic dep + const RestQuery = require('./RestQuery'); await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => results.push(result) ); @@ -332,6 +347,146 @@ const createSession = function ( }; }; +const findUsersWithAuthData = (config, authData) => { + const providers = Object.keys(authData); + const query = providers + .reduce((memo, provider) => { + if (!authData[provider] || (authData && !authData[provider].id)) { + return memo; + } + const queryKey = `authData.${provider}.id`; + const query = {}; + query[queryKey] = authData[provider].id; + memo.push(query); + return memo; + }, []) + .filter(q => { + return typeof q !== 'undefined'; + }); + + let findPromise = Promise.resolve([]); + if (query.length > 0) { + findPromise = config.database.find('_User', { $or: query }, {}); + } + + return findPromise; +}; + +const hasMutatedAuthData = (authData, userAuthData) => { + if (!userAuthData) return { hasMutatedAuthData: true, mutatedAuthData: authData }; + const mutatedAuthData = {}; + Object.keys(authData).forEach(provider => { + // Anonymous provider is not handled this way + if (provider === 'anonymous') return; + const providerData = authData[provider]; + const userProviderAuthData = userAuthData[provider]; + if (!_.isEqual(providerData, userProviderAuthData)) { + mutatedAuthData[provider] = providerData; + } + }); + const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; + return { hasMutatedAuthData, mutatedAuthData }; +}; + +const checkIfUserHasProvidedConfiguredProvidersForLogin = ( + authData = {}, + userAuthData = {}, + config +) => { + const savedUserProviders = Object.keys(userAuthData); + + const hasProvidedASoloProvider = savedUserProviders.some( + provider => + config.auth[provider] && config.auth[provider].policy === 'solo' && authData[provider] + ); + + // Solo providers can be considered as safe + // so we do not have to check if the user need + // to provide an additional provider to login + if (hasProvidedASoloProvider) return; + + const additionProvidersNotFound = []; + const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { + if (config.auth[provider] && config.auth[provider].policy === 'additional') { + if (authData[provider]) { + return true; + } else { + // Push missing provider for plausible error return + additionProvidersNotFound.push(provider); + } + } + }); + if (hasProvidedAtLeastOneAdditionalProvider || !additionProvidersNotFound.length) return; + + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `Missing additional authData ${additionProvidersNotFound.join(',')}` + ); +}; + +// Validate each authData step by step and return the provider responses +const handleAuthDataValidation = async (authData, req, foundUser) => { + let user; + if (foundUser) { + user = Parse.User.fromJSON({ className: '_User', ...foundUser }); + // Find the user by session and current object id + // Only pass user if it's the current one or master key + } else if ( + (req.auth && + req.auth.user && + typeof req.getUserId === 'function' && + req.getUserId() === req.auth.user.id) || + (req.auth && req.auth.isMaster) + ) { + user = new Parse.User(); + user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id; + await user.fetch({ useMasterKey: true }); + } + + // Perform validation as step by step pipeline + // for better error consistency and also to avoid to trigger a provider (like OTP SMS) + // if another one fail + return reducePromise( + // apply sort to run the pipeline each time in the same order + + Object.keys(authData).sort(), + async (acc, provider) => { + if (authData[provider] === null) { + acc.authData[provider] = null; + return acc; + } + const validateAuthData = req.config.authDataManager.getValidatorForProvider(provider); + if (!validateAuthData) { + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); + } + const validationResult = await validateAuthData( + authData[provider], + { config: req.config, auth: req.auth }, + user + ); + if (validationResult) { + if (!Object.keys(validationResult).length) acc.authData[provider] = authData[provider]; + + if (validationResult.response) acc.authDataResponse[provider] = validationResult.response; + // Some auth providers after initialization will avoid + // to replace authData already stored + if (!validationResult.doNotSave) { + acc.authData[provider] = validationResult.save || authData[provider]; + } + } else { + // Support current authData behavior + // no result store the new AuthData + acc.authData[provider] = authData[provider]; + } + return acc; + }, + { authData: {}, authDataResponse: {} } + ); +}; + module.exports = { Auth, master, @@ -340,4 +495,9 @@ module.exports = { getAuthForSessionToken, getAuthForLegacySessionToken, createSession, + findUsersWithAuthData, + hasMutatedAuthData, + checkIfUserHasProvidedConfiguredProvidersForLogin, + reducePromise, + handleAuthDataValidation, }; diff --git a/src/GraphQL/helpers/objectsQueries.js b/src/GraphQL/helpers/objectsQueries.js index 7b973312ce..d33c757bb2 100644 --- a/src/GraphQL/helpers/objectsQueries.js +++ b/src/GraphQL/helpers/objectsQueries.js @@ -58,7 +58,7 @@ const getObject = async ( options.keys = keys; } } catch (e) { - console.log(e); + console.error(e); } if (include) { options.include = include; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index 22d90b52b7..133c30f17b 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -1,3 +1,4 @@ +/* eslint-disable indent */ import { GraphQLID, GraphQLObjectType, @@ -140,11 +141,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla ...fields, [field]: { description: `This is the object ${field}.`, - type: - (className === '_User' && (field === 'username' || field === 'password')) || - parseClass.fields[field].required - ? new GraphQLNonNull(type) - : type, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, }, }; } else { @@ -352,6 +349,14 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla const parseObjectFields = { id: globalIdField(className, obj => obj.objectId), ...defaultGraphQLTypes.PARSE_OBJECT_FIELDS, + ...(className === '_User' + ? { + authDataResponse: { + description: `auth provider response when triggered on signUp/logIn.`, + type: defaultGraphQLTypes.OBJECT, + }, + } + : {}), }; const outputFields = () => { return classOutputFields.reduce((fields, field) => { diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index 9c57c85125..f43c44d0be 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -39,7 +39,7 @@ const load = parseGraphQLSchema => { req: { config, auth, info }, }); - const { sessionToken, objectId } = await objectsMutations.createObject( + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( '_User', parseFields, config, @@ -48,9 +48,15 @@ const load = parseGraphQLSchema => { ); context.info.sessionToken = sessionToken; - + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -109,7 +115,7 @@ const load = parseGraphQLSchema => { req: { config, auth, info }, }); - const { sessionToken, objectId } = await objectsMutations.createObject( + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( '_User', { ...parseFields, authData }, config, @@ -118,9 +124,15 @@ const load = parseGraphQLSchema => { ); context.info.sessionToken = sessionToken; - + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -144,6 +156,10 @@ const load = parseGraphQLSchema => { description: 'This is the password used to log in the user.', type: new GraphQLNonNull(GraphQLString), }, + authData: { + description: 'Auth data payload, needed if some required auth adapters are configured.', + type: OBJECT, + }, }, outputFields: { viewer: { @@ -153,14 +169,15 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - const { username, password } = args; + const { username, password, authData } = args; const { config, auth, info } = context; - const { sessionToken, objectId } = ( + const { sessionToken, objectId, authDataResponse } = ( await usersRouter.handleLogIn({ body: { username, password, + authData, }, query: {}, config, @@ -171,8 +188,15 @@ const load = parseGraphQLSchema => { context.info.sessionToken = sessionToken; + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -298,6 +322,57 @@ const load = parseGraphQLSchema => { true, true ); + + const challengeMutation = mutationWithClientMutationId({ + name: 'Challenge', + description: + 'The challenge mutation can be used to initiate an authentication challenge when an auth adapter need it.', + inputFields: { + username: { + description: 'This is the username used to log in the user.', + type: GraphQLString, + }, + password: { + description: 'This is the password used to log in the user.', + type: GraphQLString, + }, + authData: { + description: + 'Auth data allow to pre identify the user if the auth adapter need pre identification.', + type: OBJECT, + }, + challengeData: { + description: + 'Challenge data payload, could be used to post some data to auth providers if they need data for the response.', + type: OBJECT, + }, + }, + outputFields: { + challengeData: { + description: 'Challenge response from configured auth adapters.', + type: OBJECT, + }, + }, + mutateAndGetPayload: async (input, context) => { + try { + const { config, auth, info } = context; + + const { response } = await usersRouter.handleChallenge({ + body: input, + config, + auth, + info, + }); + return response; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(challengeMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(challengeMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('challenge', challengeMutation, true, true); }; export { load }; diff --git a/src/RestQuery.js b/src/RestQuery.js index ef3846daec..cfea5ce1b3 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -577,7 +577,7 @@ RestQuery.prototype.replaceDontSelect = function () { }); }; -const cleanResultAuthData = function (result) { +RestQuery.prototype.cleanResultAuthData = function (result) { delete result.password; if (result.authData) { Object.keys(result.authData).forEach(provider => { @@ -646,7 +646,7 @@ RestQuery.prototype.runFind = function (options = {}) { .then(results => { if (this.className === '_User' && findOptions.explain !== true) { for (var result of results) { - cleanResultAuthData(result); + this.cleanResultAuthData(result, this.auth, this.config); } } diff --git a/src/RestWrite.js b/src/RestWrite.js index 38b318100c..5a972fe368 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -142,6 +142,12 @@ RestWrite.prototype.execute = function () { return this.cleanUserAuthData(); }) .then(() => { + // Append the authDataResponse if exists + if (this.authDataResponse) { + if (this.response && this.response.response) { + this.response.response.authDataResponse = this.authDataResponse; + } + } return this.response; }); }; @@ -374,7 +380,11 @@ RestWrite.prototype.validateAuthData = function () { return; } - if (!this.query && !this.data.authData) { + const authData = this.data.authData; + const hasUsernameAndPassword = + typeof this.data.username === 'string' && typeof this.data.password === 'string'; + + if (!this.query && !authData) { if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } @@ -384,10 +394,10 @@ RestWrite.prototype.validateAuthData = function () { } if ( - (this.data.authData && !Object.keys(this.data.authData).length) || + (authData && !Object.keys(authData).length) || !Object.prototype.hasOwnProperty.call(this.data, 'authData') ) { - // Handle saving authData to {} or if authData doesn't exist + // Nothing to validate here return; } else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) { // Handle saving authData to null @@ -397,15 +407,14 @@ RestWrite.prototype.validateAuthData = function () { ); } - var authData = this.data.authData; var providers = Object.keys(authData); if (providers.length > 0) { - const canHandleAuthData = providers.reduce((canHandle, provider) => { + const canHandleAuthData = providers.some(provider => { var providerAuthData = authData[provider]; var hasToken = providerAuthData && providerAuthData.id; - return canHandle && (hasToken || providerAuthData == null); - }, true); - if (canHandleAuthData) { + return hasToken || providerAuthData === null; + }); + if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { return this.handleAuthData(authData); } } @@ -415,48 +424,6 @@ RestWrite.prototype.validateAuthData = function () { ); }; -RestWrite.prototype.handleAuthDataValidation = function (authData) { - const validations = Object.keys(authData).map(provider => { - if (authData[provider] === null) { - return Promise.resolve(); - } - const validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); - if (!validateAuthData) { - throw new Parse.Error( - Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.' - ); - } - return validateAuthData(authData[provider]); - }); - return Promise.all(validations); -}; - -RestWrite.prototype.findUsersWithAuthData = function (authData) { - const providers = Object.keys(authData); - const query = providers - .reduce((memo, provider) => { - if (!authData[provider]) { - return memo; - } - const queryKey = `authData.${provider}.id`; - const query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []) - .filter(q => { - return typeof q !== 'undefined'; - }); - - let findPromise = Promise.resolve([]); - if (query.length > 0) { - findPromise = this.config.database.find(this.className, { $or: query }, {}); - } - - return findPromise; -}; - RestWrite.prototype.filteredObjectsByACL = function (objects) { if (this.auth.isMaster) { return objects; @@ -470,106 +437,130 @@ RestWrite.prototype.filteredObjectsByACL = function (objects) { }); }; -RestWrite.prototype.handleAuthData = function (authData) { - let results; - return this.findUsersWithAuthData(authData).then(async r => { - results = this.filteredObjectsByACL(r); - - if (results.length == 1) { - this.storage['authProvider'] = Object.keys(authData).join(','); - - const userResult = results[0]; - const mutatedAuthData = {}; - Object.keys(authData).forEach(provider => { - const providerData = authData[provider]; - const userAuthData = userResult.authData[provider]; - if (!_.isEqual(providerData, userAuthData)) { - mutatedAuthData[provider] = providerData; - } - }); - const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; - let userId; - if (this.query && this.query.objectId) { - userId = this.query.objectId; - } else if (this.auth && this.auth.user && this.auth.user.id) { - userId = this.auth.user.id; - } - if (!userId || userId === userResult.objectId) { - // no user making the call - // OR the user making the call is the right one - // Login with auth data - delete results[0].password; - - // need to set the objectId first otherwise location has trailing undefined - this.data.objectId = userResult.objectId; - - if (!this.query || !this.query.objectId) { - // this a login call, no userId passed - this.response = { - response: userResult, - location: this.location(), - }; - // Run beforeLogin hook before storing any updates - // to authData on the db; changes to userResult - // will be ignored. - await this.runBeforeLoginTrigger(deepcopy(userResult)); - } +RestWrite.prototype.getUserId = function () { + if (this.query && this.query.objectId && this.className === '_User') { + return this.query.objectId; + } else if (this.auth && this.auth.user && this.auth.user.id) { + return this.auth.user.id; + } +}; - // If we didn't change the auth data, just keep going - if (!hasMutatedAuthData) { - return; - } - // We have authData that is updated on login - // that can happen when token are refreshed, - // We should update the token and let the user in - // We should only check the mutated keys - return this.handleAuthDataValidation(mutatedAuthData).then(async () => { - // IF we have a response, we'll skip the database operation / beforeSave / afterSave etc... - // we need to set it up there. - // We are supposed to have a response only on LOGIN with authData, so we skip those - // If we're not logging in, but just updating the current user, we can safely skip that part - if (this.response) { - // Assign the new authData in the response - Object.keys(mutatedAuthData).forEach(provider => { - this.response.response.authData[provider] = mutatedAuthData[provider]; - }); +RestWrite.prototype.handleAuthData = async function (authData) { + const r = await Auth.findUsersWithAuthData(this.config, authData); + const results = this.filteredObjectsByACL(r); - // Run the DB update directly, as 'master' - // Just update the authData part - // Then we're good for the user, early exit of sorts - return this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: mutatedAuthData }, - {} - ); - } + if (results.length > 1) { + // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 + // Let's run some validation before throwing + await Auth.handleAuthDataValidation(authData, this, results[0]); + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + + // No user found with provided authData we need to validate + if (!results.length) { + const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation( + authData, + this + ); + this.authDataResponse = authDataResponse; + // Replace current authData by the new validated one + this.data.authData = validatedAuthData; + return; + } + + // User found with provided authData + if (results.length === 1) { + const userId = this.getUserId(); + const userResult = results[0]; + // Prevent duplicate authData id + if (userId && userId !== userResult.objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + + this.storage['authProvider'] = Object.keys(authData).join(','); + + const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( + authData, + userResult.authData + ); + + const isCurrentUserLoggedOrMaster = + (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || + this.auth.isMaster; + + // Prevent validating if no mutated data detected + if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) { + return; + } + + const isLogin = !userId; + + if (isLogin || hasMutatedAuthData) { + // no user making the call + // OR the user making the call is the right one + // Login with auth data + delete results[0].password; + + // need to set the objectId first otherwise location has trailing undefined + this.data.objectId = userResult.objectId; + + if (isLogin) { + this.response = { + response: userResult, + location: this.location(), + }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to userResult + // will be ignored. + await this.runBeforeLoginTrigger(deepcopy(userResult)); + + // If we are in login operation via authData + // we need to be sure that the user has provided + // required authData + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + authData, + userResult.authData, + this.config + ); + } + + // Force to validate all provided authData on login + // on update only validate mutated ones + const res = await Auth.handleAuthDataValidation( + isLogin ? authData : mutatedAuthData, + this, + userResult + ); + this.data.authData = res.authData; + this.authDataResponse = res.authDataResponse; + + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... + // we need to set it up there. + // We are supposed to have a response only on LOGIN with authData, so we skip those + // If we're not logging in, but just updating the current user, we can safely skip that part + if (isLogin && hasMutatedAuthData) { + // Assign the new authData in the response + Object.keys(mutatedAuthData).forEach(provider => { + this.response.response.authData[provider] = mutatedAuthData[provider]; }); - } else if (userId) { - // Trying to update auth data but users - // are different - if (userResult.objectId !== userId) { - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - // No auth data was mutated, just keep going - if (!hasMutatedAuthData) { - return; - } + + // Run the DB update directly, as 'master' + // Just update the authData part + // Then we're good for the user, early exit of sorts + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); } } - return this.handleAuthDataValidation(authData).then(() => { - if (results.length > 1) { - // More than 1 user with the passed id's - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - }); - }); + } }; // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function () { var promise = Promise.resolve(); - if (this.className !== '_User') { return promise; } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7843cf4674..e1bec25cb9 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -164,7 +164,6 @@ export class UsersRouter extends ClassesRouter { // Remove hidden properties. UsersRouter.removeHiddenProperties(user); - return { response: user }; } }); @@ -172,6 +171,17 @@ export class UsersRouter extends ClassesRouter { async handleLogIn(req) { const user = await this._authenticateUserFromRequest(req); + const authData = req.body && req.body.authData; + // Check if user has provided his required auth providers + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config); + + let authDataResponse; + let validatedAuthData; + if (authData) { + const res = await Auth.handleAuthDataValidation(authData, req, user); + authDataResponse = res.authDataResponse; + validatedAuthData = res.authData; + } // handle password expiry policy if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { @@ -218,6 +228,17 @@ export class UsersRouter extends ClassesRouter { req.config ); + // If we have some new validated authData + // update directly + if (validatedAuthData && Object.keys(validatedAuthData).length) { + await req.config.database.update( + '_User', + { objectId: user.objectId }, + { authData: validatedAuthData }, + {} + ); + } + const { sessionData, createSession } = Auth.createSession(req.config, { userId: user.objectId, createdWith: { @@ -240,6 +261,10 @@ export class UsersRouter extends ClassesRouter { req.config ); + if (authDataResponse) { + user.authDataResponse = authDataResponse; + } + return { response: user }; } @@ -392,6 +417,72 @@ export class UsersRouter extends ClassesRouter { }); } + async handleChallenge(req) { + const { username, email, password, authData, challengeData } = req.body; + + // if username or email provided with password try to find the user with default + // system + let user; + if (username || email) { + if (!password) + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You provided username or email, you need to also provide password.' + ); + user = await this._authenticateUserFromRequest(req); + } + + if (!challengeData) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Nothing to challenge.'); + + if (typeof challengeData !== 'object') + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'challengeData should be an object.'); + + // Try to find user by authData + if (authData) { + if (typeof authData !== 'object') + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'authData should be an object.'); + // To avoid security issue we should only support one identifying method + if (user) + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cant provide username/email and authData, only use one identification method.' + ); + const results = await Auth.findUsersWithAuthData(req.config, authData); + if (results.length > 1) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cant provide more than one authData provider with an id.' + ); + } + if (!results[0]) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); + user = results[0]; + } + + const authAdapters = req.config.auth; + + // Execute challenge step by step + // with consistent order + const challenge = await Auth.reducePromise( + Object.keys(challengeData).sort(), + async (acc, provider) => { + if (typeof authAdapters[provider].challenge === 'function') { + acc[provider] = + (await authAdapters[provider].challenge( + challengeData[provider], + authData && authData[provider], + user ? Parse.User.fromJSON({ className: '_User', ...user }) : undefined, + req, + authAdapters[provider].options + )) || true; + return acc; + } + }, + {} + ); + + return { response: Object.keys(challenge).length ? { challengeData: challenge } : undefined }; + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); @@ -429,6 +520,9 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/verifyPassword', req => { return this.handleVerifyPassword(req); }); + this.route('POST', '/challenge', req => { + return this.handleChallenge(req); + }); } }