Skip to content

Feat: Add dashboard router to store Dashboard User Settings #7588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 16 additions & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"mime": "2.5.2",
"mongodb": "3.6.11",
"mustache": "4.2.0",
"otpauth": "7.0.6",
"parse": "3.3.0",
"pg-monitor": "1.4.1",
"pg-promise": "10.11.0",
Expand Down
178 changes: 178 additions & 0 deletions spec/DashboardRouter.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const request = require('../lib/request');
const TOTP = require('otpauth').TOTP;

describe('Dashboard', () => {
const signup = (master, mfa) =>
request({
url: `${Parse.serverURL}/dashboardSignup`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': master ? Parse.masterKey : null,
'Content-Type': 'application/json',
},
method: 'POST',
body: {
username: '[email protected]',
password: 'password',
mfa,
mfaOptions: {
algorithm: 'SHA1',
period: 30,
digits: 6,
},
features: {
globalConfig: {
create: true,
read: true,
update: true,
delete: true,
},
hooks: {
create: true,
read: true,
update: true,
delete: true,
},
cloudCode: {
jobs: true,
},
logs: {
level: true,
size: true,
order: true,
until: true,
from: true,
},
push: {
immediatePush: true,
pushAudiences: true,
localization: true,
},
schemas: {
addField: true,
removeField: true,
addClass: true,
removeClass: true,
clearAllDataFromClass: true,
exportClass: false,
editClassLevelPermissions: true,
editPointerPermissions: true,
},
},
},
followRedirects: false,
}).catch(e => e);

const login = (password, otp) =>
request({
url: `${Parse.serverURL}/dashboardLogin`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': Parse.masterKey,
'Content-Type': 'application/json',
},
method: 'POST',
body: {
username: '[email protected]',
password,
otp,
},
followRedirects: false,
}).catch(e => e);
it('creating a user responds with 403 without masterKey', async () => {
const response = await signup();
expect(response.status).toBe(403);
});

it('creating a user responds with masterKey', async () => {
const response = await signup(true);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
});

it('cannot query dashboard user class', async () => {
const response = await signup(true);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
await expectAsync(new Parse.Query('_DashboardUser').first()).toBeRejectedWith(
new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
"Clients aren't allowed to perform the find operation on the _DashboardUser collection."
)
);
});

it('can query dashboard user class with masterKey', async () => {
const response = await signup(true);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
const [user] = await new Parse.Query('_DashboardUser').find({ useMasterKey: true });
expect(user).toBeDefined();
expect(user.get('password')).toBeUndefined();
expect(user.get('mfaOptions')).toBeUndefined();
});

it('dashboard can signup and then login', async () => {
const response = await signup(true, true);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
const { mfaSecret } = JSON.parse(response.text);
const totp = new TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: mfaSecret,
});
const loginResponse = await login('password', totp.generate());
expect(loginResponse.status).toEqual(200);
const user = JSON.parse(loginResponse.text);
expect(user.username).toEqual('[email protected]');
expect(user.features).toBeDefined();
expect(user.features.globalConfig).toBeDefined();
expect(user.features.hooks).toBeDefined();
expect(user.features.cloudCode).toBeDefined();
expect(user.features.logs).toBeDefined();
expect(user.features.push).toBeDefined();
expect(user.features.schemas).toBeDefined();
});

it('dashboard can signup and then login without mfa', async () => {
const response = await signup(true, false);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
const loginResponse = await login('password');
expect(loginResponse.status).toEqual(200);
const user = JSON.parse(loginResponse.text);
expect(user.username).toEqual('[email protected]');
expect(user.features).toBeDefined();
expect(user.features.globalConfig).toBeDefined();
expect(user.features.hooks).toBeDefined();
expect(user.features.cloudCode).toBeDefined();
expect(user.features.logs).toBeDefined();
expect(user.features.push).toBeDefined();
expect(user.features.schemas).toBeDefined();
});

it('dashboard can signup and rejects login with invalid password', async () => {
const response = await signup(true, false);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
const loginResponse = await login('password2');
expect(loginResponse.status).toEqual(404);
expect(loginResponse.text).toEqual(`{"code":101,"error":"Invalid username/password."}`);
});

it('dashboard can signup and rejects login with invalid mfa', async () => {
const response = await signup(true, true);
expect(response.status).toBe(201);
expect(response.text).toContain('[email protected]');
const loginResponse = await login('password');
expect(loginResponse.status).toEqual(400);
expect(loginResponse.text).toEqual(
`{"code":211,"error":"Please specify a One Time password."}`
);
const invalidMFAResponse = await login('password', 123456);
expect(invalidMFAResponse.status).toEqual(400);
expect(invalidMFAResponse.text).toEqual(`{"code":210,"error":"Invalid One Time Password."}`);
});
});
7 changes: 7 additions & 0 deletions src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -1704,6 +1704,13 @@ class DatabaseController {
throw error;
});

await this.adapter
.ensureUniqueness('_DashboardUser', requiredUserFields, ['username'])
.catch(error => {
logger.warn('Unable to ensure uniqueness for usernames: ', error);
throw error;
});

await this.adapter
.ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true)
.catch(error => {
Expand Down
19 changes: 19 additions & 0 deletions src/Controllers/SchemaController.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({
reqId: { type: 'String' },
expire: { type: 'Date' },
},
_DashboardUser: {
objectId: { type: 'String' },
username: { type: 'String' },
password: { type: 'String' },
mfaOptions: { type: 'Object' },
features: { type: 'Object' },
},
});

const requiredColumns = Object.freeze({
Expand All @@ -168,6 +175,7 @@ const systemClasses = Object.freeze([
'_JobSchedule',
'_Audience',
'_Idempotency',
'_DashboardUser',
]);

const volatileClasses = Object.freeze([
Expand All @@ -179,6 +187,7 @@ const volatileClasses = Object.freeze([
'_JobSchedule',
'_Audience',
'_Idempotency',
'_DashboardUser',
]);

// Anything that start with role
Expand Down Expand Up @@ -648,6 +657,15 @@ const _IdempotencySchema = convertSchemaToAdapterSchema(
classLevelPermissions: {},
})
);

const _DashboardUser = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_DashboardUser',
fields: defaultColumns._DashboardUser,
classLevelPermissions: {},
})
);

const VolatileClassesSchemas = [
_HooksSchema,
_JobStatusSchema,
Expand All @@ -657,6 +675,7 @@ const VolatileClassesSchemas = [
_GraphQLConfigSchema,
_AudienceSchema,
_IdempotencySchema,
_DashboardUser,
];

const dbTypeMatchesObjectType = (dbType: SchemaField | string, objectType: SchemaField) => {
Expand Down
2 changes: 2 additions & 0 deletions src/ParseServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import PromiseRouter from './PromiseRouter';
import requiredParameter from './requiredParameter';
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { ClassesRouter } from './Routers/ClassesRouter';
import { DashboardRouter } from './Routers/DashboardRouter';
import { FeaturesRouter } from './Routers/FeaturesRouter';
import { FilesRouter } from './Routers/FilesRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
Expand Down Expand Up @@ -235,6 +236,7 @@ class ParseServer {
new AudiencesRouter(),
new AggregateRouter(),
new SecurityRouter(),
new DashboardRouter(),
];

const routes = routers.reduce((memo, router) => {
Expand Down
6 changes: 6 additions & 0 deletions src/RestQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,12 @@ RestQuery.prototype.runFind = function (options = {}) {
cleanResultAuthData(result);
}
}
if (this.className === '_DashboardUser') {
for (const result of results) {
delete result.password;
delete result.mfaOptions;
}
}

this.config.filesController.expandFilesInObject(this.config, results);

Expand Down
Loading