Add support for master key clients to create user sessions (#7406)
* 6641: Implement support for user impersonation: master key clients can log in as any user, without access to the user's credentials, and without presuming the user already has a session * reworded changelog * rebuilt package lock * fit test * using lodash flatMap * bump to node 12 for postgres test * revert test fit * add node version to postgres CI * revert package-lock Co-authored-by: gormanfletcher <git@gormanfletcher.com> Co-authored-by: Manuel <5673677+mtrezza@users.noreply.github.com>
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user