diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 465fe45708..3b312c54c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,12 +170,16 @@ jobs: include: - name: PostgreSQL 11, PostGIS 3.0 POSTGRES_IMAGE: postgis/postgis:11-3.0 + NODE_VERSION: 14.17.0 - name: PostgreSQL 11, PostGIS 3.1 POSTGRES_IMAGE: postgis/postgis:11-3.1 + NODE_VERSION: 14.17.0 - name: PostgreSQL 12, PostGIS 3.1 POSTGRES_IMAGE: postgis/postgis:12-3.1 + NODE_VERSION: 14.17.0 - name: PostgreSQL 13, PostGIS 3.1 POSTGRES_IMAGE: postgis/postgis:13-3.1 + NODE_VERSION: 14.17.0 fail-fast: false name: ${{ matrix.name }} timeout-minutes: 15 @@ -199,12 +203,13 @@ jobs: env: PARSE_SERVER_TEST_DB: postgres PARSE_SERVER_TEST_DATABASE_URI: postgres://postgres:postgres@localhost:5432/parse_server_postgres_adapter_test_database + NODE_VERSION: ${{ matrix.NODE_VERSION }} steps: - uses: actions/checkout@v2 - - name: Use Node.js 10 + - name: Use Node.js ${{ matrix.NODE_VERSION }} uses: actions/setup-node@v1 with: - node-version: 10 + node-version: ${{ matrix.NODE_VERSION }} - name: Cache Node.js modules uses: actions/cache@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3df1a02a7..0bd81b3fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,8 @@ ___ - EXPERIMENTAL: Added new page router with placeholder rendering and localization of custom and feature pages such as password reset and email verification (Manuel Trezza) [#7128](https://github.com/parse-community/parse-server/pull/7128) - EXPERIMENTAL: Added custom routes to easily customize flows for password reset, email verification or build entirely new flows (Manuel Trezza) [#7231](https://github.com/parse-community/parse-server/pull/7231) - Added Deprecation Policy to govern the introduction of braking changes in a phased pattern that is more predictable for developers (Manuel Trezza) [#7199](https://github.com/parse-community/parse-server/pull/7199) +- Add REST API endpoint `/loginAs` to create session of any user with master key; allows to impersonate another user. (GormanFletcher) [#7406](https://github.com/parse-community/parse-server/pull/7406) + ### Other Changes - Fix error when a not yet inserted job is updated (Antonio Davi Macedo Coelho de Castro) [#7196](https://github.com/parse-community/parse-server/pull/7196) - request.context for afterFind triggers (dblythy) [#7078](https://github.com/parse-community/parse-server/pull/7078) diff --git a/package.json b/package.json index 49499c8bb9..80ae8cec27 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,8 @@ "jsdoc-babel": "0.5.0", "lint-staged": "10.2.3", "madge": "4.0.2", - "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mock-files-adapter": "file:spec/dependencies/mock-files-adapter", + "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mongodb-runner": "4.8.1", "mongodb-version-list": "1.0.0", "node-fetch": "2.6.1", diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 91aeb4920a..bee6f2a279 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -4032,3 +4032,131 @@ describe('Security Advisory GHSA-8w3j-g983-8jh5', function () { expect(user.get('authData')).toEqual({ custom: { id: 'linkedID' } }); }); }); + +describe('login as other user', () => { + it('allows creating a session for another user with the master key', async done => { + await Parse.User.signUp('some_user', 'some_password'); + const userId = Parse.User.current().id; + await Parse.User.logOut(); + + try { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + body: { + userId, + }, + }); + + expect(response.data.sessionToken).toBeDefined(); + } catch (err) { + fail(`no request should fail: ${JSON.stringify(err)}`); + done(); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(1); + + done(); + }); + + it('rejects creating a session for another user if the user does not exist', async done => { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + body: { + userId: 'bogus-user', + }, + }); + + fail('Request should fail without a valid user ID'); + done(); + } catch (err) { + expect(err.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(err.data.error).toBe('user not found'); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(0); + + done(); + }); + + it('rejects creating a session for another user with invalid parameters', async done => { + const invalidUserIds = [undefined, null, '']; + + for (const invalidUserId of invalidUserIds) { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + body: { + userId: invalidUserId, + }, + }); + + fail('Request should fail without a valid user ID'); + done(); + } catch (err) { + expect(err.data.code).toBe(Parse.Error.INVALID_VALUE); + expect(err.data.error).toBe('userId must not be empty, null, or undefined'); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(0); + } + + done(); + }); + + it('rejects creating a session for another user without the master key', async done => { + await Parse.User.signUp('some_user', 'some_password'); + const userId = Parse.User.current().id; + await Parse.User.logOut(); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + userId, + }, + }); + + fail('Request should fail without the master key'); + done(); + } catch (err) { + expect(err.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(err.data.error).toBe('master key is required'); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(0); + + done(); + }); +}); diff --git a/spec/SecurityCheck.spec.js b/spec/SecurityCheck.spec.js index 5f79ca2bbd..647ed909c0 100644 --- a/spec/SecurityCheck.spec.js +++ b/spec/SecurityCheck.spec.js @@ -23,14 +23,20 @@ describe('Security Check', () => { await reconfigureServer(config); } - const securityRequest = (options) => request(Object.assign({ - url: securityUrl, - headers: { - 'X-Parse-Master-Key': Parse.masterKey, - 'X-Parse-Application-Id': Parse.applicationId, - }, - followRedirects: false, - }, options)).catch(e => e); + const securityRequest = options => + request( + Object.assign( + { + url: securityUrl, + headers: { + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Application-Id': Parse.applicationId, + }, + followRedirects: false, + }, + options + ) + ).catch(e => e); beforeEach(async () => { groupName = 'Example Group Name'; @@ -41,7 +47,7 @@ describe('Security Check', () => { solution: 'TestSolution', check: () => { return true; - } + }, }); checkFail = new Check({ group: 'TestGroup', @@ -50,14 +56,14 @@ describe('Security Check', () => { solution: 'TestSolution', check: () => { throw 'Fail'; - } + }, }); Group = class Group extends CheckGroup { setName() { return groupName; } setChecks() { - return [ checkSuccess, checkFail ]; + return [checkSuccess, checkFail]; } }; config = { @@ -154,7 +160,7 @@ describe('Security Check', () => { title: 'string', warning: 'string', solution: 'string', - check: () => {} + check: () => {}, }, { group: 'string', @@ -203,7 +209,9 @@ describe('Security Check', () => { title: 'string', warning: 'string', solution: 'string', - check: () => { throw 'error' }, + check: () => { + throw 'error'; + }, }); expect(check._checkState == CheckState.none); check.run(); @@ -277,7 +285,7 @@ describe('Security Check', () => { }); it('runs all checks of all groups', async () => { - const checkGroups = [ Group, Group ]; + const checkGroups = [Group, Group]; const runner = new CheckRunner({ checkGroups }); const report = await runner.run(); expect(report.report.groups[0].checks[0].state).toBe(CheckState.success); @@ -287,27 +295,27 @@ describe('Security Check', () => { }); it('reports correct default syntax version 1.0.0', async () => { - const checkGroups = [ Group ]; + const checkGroups = [Group]; const runner = new CheckRunner({ checkGroups, enableCheckLog: true }); const report = await runner.run(); expect(report).toEqual({ report: { - version: "1.0.0", - state: "fail", + version: '1.0.0', + state: 'fail', groups: [ { - name: "Example Group Name", - state: "fail", + name: 'Example Group Name', + state: 'fail', checks: [ { - title: "TestTitleSuccess", - state: "success", + title: 'TestTitleSuccess', + state: 'success', }, { - title: "TestTitleFail", - state: "fail", - warning: "TestWarning", - solution: "TestSolution", + title: 'TestTitleFail', + state: 'fail', + warning: 'TestWarning', + solution: 'TestSolution', }, ], }, @@ -319,7 +327,7 @@ describe('Security Check', () => { it('logs report', async () => { const logger = require('../lib/logger').logger; const logSpy = spyOn(logger, 'warn').and.callThrough(); - const checkGroups = [ Group ]; + const checkGroups = [Group]; const runner = new CheckRunner({ checkGroups, enableCheckLog: true }); const report = await runner.run(); const titles = report.report.groups.flatMap(group => group.checks.map(check => check.title)); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 05dda035f4..cdce6a1348 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -31,6 +31,28 @@ export class UsersRouter extends ClassesRouter { } } + /** + * After retrieving a user directly from the database, we need to remove the + * password from the object (for security), and fix an issue some SDKs have + * with null values + */ + _sanitizeAuthData(user) { + delete user.password; + + // Sometimes the authData still has null on that keys + // https://github.com/parse-community/parse-server/issues/935 + if (user.authData) { + Object.keys(user.authData).forEach(provider => { + if (user.authData[provider] === null) { + delete user.authData[provider]; + } + }); + if (Object.keys(user.authData).length == 0) { + delete user.authData; + } + } + } + /** * Validates a password request in login and verifyPassword * @param {Object} req The request @@ -117,20 +139,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } - delete user.password; - - // Sometimes the authData still has null on that keys - // https://github.com/parse-community/parse-server/issues/935 - if (user.authData) { - Object.keys(user.authData).forEach(provider => { - if (user.authData[provider] === null) { - delete user.authData[provider]; - } - }); - if (Object.keys(user.authData).length == 0) { - delete user.authData; - } - } + this._sanitizeAuthData(user); return resolve(user); }) @@ -244,6 +253,57 @@ export class UsersRouter extends ClassesRouter { return { response: user }; } + /** + * This allows master-key clients to create user sessions without access to + * user credentials. This enables systems that can authenticate access another + * way (API key, app administrators) to act on a user's behalf. + * + * We create a new session rather than looking for an existing session; we + * want this to work in situations where the user is logged out on all + * devices, since this can be used by automated systems acting on the user's + * behalf. + * + * For the moment, we're omitting event hooks and lockout checks, since + * immediate use cases suggest /loginAs could be used for semantically + * different reasons from /login + */ + async handleLogInAs(req) { + if (!req.auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'master key is required'); + } + + const userId = req.body.userId || req.query.userId; + if (!userId) { + throw new Parse.Error( + Parse.Error.INVALID_VALUE, + 'userId must not be empty, null, or undefined' + ); + } + + const queryResults = await req.config.database.find('_User', { objectId: userId }); + const user = queryResults[0]; + if (!user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'user not found'); + } + + this._sanitizeAuthData(user); + + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId, + createdWith: { + action: 'login', + authProvider: 'masterkey', + }, + installationId: req.info.installationId, + }); + + user.sessionToken = sessionData.sessionToken; + + await createSession(); + + return { response: user }; + } + handleVerifyPassword(req) { return this._authenticateUserFromRequest(req) .then(user => { @@ -418,6 +478,9 @@ export class UsersRouter extends ClassesRouter { this.route('POST', '/login', req => { return this.handleLogIn(req); }); + this.route('POST', '/loginAs', req => { + return this.handleLogInAs(req); + }); this.route('POST', '/logout', req => { return this.handleLogOut(req); });