diff --git a/package-lock.json b/package-lock.json index f2806a65d5..464a44ec63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1249,25 +1249,6 @@ "tslib": "~2.3.0" }, "dependencies": { - "@graphql-tools/merge": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.0.1.tgz", - "integrity": "sha512-YAozogbjC2Oun+UcwG0LZFumhlCiHBmqe68OIf7bqtBdp4pbPAiVuK/J9oJqRVJmzvUqugo6RD9zz1qDTKZaiQ==", - "requires": { - "@graphql-tools/utils": "8.1.1", - "tslib": "~2.3.0" - }, - "dependencies": { - "@graphql-tools/utils": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.1.1.tgz", - "integrity": "sha512-QbFNoBmBiZ+ej4y6mOv8Ba4lNhcrTEKXAhZ0f74AhdEXi7b9xbGUH/slO5JaSyp85sGQYIPmxjRPpXBjLklbmw==", - "requires": { - "tslib": "~2.3.0" - } - } - } - }, "@graphql-tools/schema": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-8.1.1.tgz", @@ -7942,6 +7923,11 @@ } } }, + "jssha": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", + "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==" + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -9196,7 +9182,7 @@ "mkdirp": "^0.5.1", "mongodb": "^3.4.0", "mongodb-dbpath": "^0.0.1", - "mongodb-tools": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "mongodb-tools": "mongodb-tools@github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", "mongodb-version-manager": "^1.4.3", "untildify": "^4.0.0", "which": "^2.0.1" @@ -9220,8 +9206,8 @@ } }, "mongodb-tools": { - "version": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", - "from": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "version": "git+ssh://git@github.com/mongodb-js/mongodb-tools.git#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "from": "mongodb-tools@github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", "dev": true, "requires": { "debug": "^2.2.0", @@ -9922,6 +9908,14 @@ "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true }, + "otpauth": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-7.0.6.tgz", + "integrity": "sha512-XYVJbeCqrbjrLLbpWoydzAc9uvb5BRuu3fi3JqOs8RWs6v+oNxb0nC903Iuo9b3oKOtfLCwopdn08Wwk8BY1JA==", + "requires": { + "jssha": "~3.2.0" + } + }, "p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", diff --git a/package.json b/package.json index 759065e5ed..e1f0d42ff5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/spec/DashboardRouter.spec.js b/spec/DashboardRouter.spec.js new file mode 100644 index 0000000000..8e4bd92c9b --- /dev/null +++ b/spec/DashboardRouter.spec.js @@ -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: 'test@example.com', + 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: 'test@example.com', + 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('test@example.com'); + }); + + it('cannot query dashboard user class', async () => { + const response = await signup(true); + expect(response.status).toBe(201); + expect(response.text).toContain('test@example.com'); + 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('test@example.com'); + 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('test@example.com'); + 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('test@example.com'); + 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('test@example.com'); + const loginResponse = await login('password'); + expect(loginResponse.status).toEqual(200); + const user = JSON.parse(loginResponse.text); + expect(user.username).toEqual('test@example.com'); + 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('test@example.com'); + 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('test@example.com'); + 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."}`); + }); +}); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index be2e61ab42..4b151fac03 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -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 => { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index c9c51e71ad..a90cb576e9 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -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({ @@ -168,6 +175,7 @@ const systemClasses = Object.freeze([ '_JobSchedule', '_Audience', '_Idempotency', + '_DashboardUser', ]); const volatileClasses = Object.freeze([ @@ -179,6 +187,7 @@ const volatileClasses = Object.freeze([ '_JobSchedule', '_Audience', '_Idempotency', + '_DashboardUser', ]); // Anything that start with role @@ -648,6 +657,15 @@ const _IdempotencySchema = convertSchemaToAdapterSchema( classLevelPermissions: {}, }) ); + +const _DashboardUser = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_DashboardUser', + fields: defaultColumns._DashboardUser, + classLevelPermissions: {}, + }) +); + const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, @@ -657,6 +675,7 @@ const VolatileClassesSchemas = [ _GraphQLConfigSchema, _AudienceSchema, _IdempotencySchema, + _DashboardUser, ]; const dbTypeMatchesObjectType = (dbType: SchemaField | string, objectType: SchemaField) => { diff --git a/src/ParseServer.js b/src/ParseServer.js index a8b0ad38cf..6c297b7303 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -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'; @@ -235,6 +236,7 @@ class ParseServer { new AudiencesRouter(), new AggregateRouter(), new SecurityRouter(), + new DashboardRouter(), ]; const routes = routers.reduce((memo, router) => { diff --git a/src/RestQuery.js b/src/RestQuery.js index be96683451..ca11757d9a 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -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); diff --git a/src/Routers/DashboardRouter.js b/src/Routers/DashboardRouter.js new file mode 100644 index 0000000000..401ae26731 --- /dev/null +++ b/src/Routers/DashboardRouter.js @@ -0,0 +1,151 @@ +// These methods handle the Dashboard-related routes. +import Parse from 'parse/node'; +import ClassesRouter from './ClassesRouter'; +import rest from '../rest'; +import passwordCrypto from '../password'; +import _ from 'lodash'; +import { promiseEnsureIdempotency, promiseEnforceMasterKeyAccess } from '../middlewares'; +import { TOTP, Secret } from 'otpauth'; + +export class DashboardRouter extends ClassesRouter { + className() { + return '_DashboardUser'; + } + + async handleDashboardCreate({ body, config, auth, client, info }) { + const data = { + username: body.username, + password: body.password, + }; + let mfaUrl; + if (body.mfaOptions && body.mfa) { + const mfaOptions = body.mfaOptions; + const period = mfaOptions.period || 30; + const digits = mfaOptions.digits || 6; + const algorithm = mfaOptions.algorithm || 'SHA1'; + const secret = new Secret(); + const totp = new TOTP({ + issuer: config.appName, + label: data.username, + algorithm, + digits, + period, + secret, + }); + data.mfaOptions = { + enabled: true, + algorithm, + secret: secret.base32, + }; + mfaUrl = totp.toString(); + } + if (body.features) { + data.features = body.features; + } + if (typeof data.username !== 'string' || _.isEmpty(data.username)) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); + } + if (typeof data.password !== 'string' || _.isEmpty(data.password)) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required'); + } + if (data.password.length < 8) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'Invalid password.'); + } + const hashedPassword = await passwordCrypto.hash(data.password); + data.password = hashedPassword; + + const users = await config.database.find( + this.className(), + { + username: body.username, + }, + { limit: 1, caseInsensitive: true }, + {} + ); + if (users.length !== 0) { + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); + } + const response = await rest.create(config, auth, this.className(), data, client, info.context); + response.response.username = data.username; + if (mfaUrl) { + response.response.mfaUrl = mfaUrl; + response.response.mfaSecret = data.mfaOptions?.secret; + } + return response; + } + + async handleDashboardLogin({ body, config }) { + const { username, password, otp } = body; + if (!username) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username is required.'); + } + if (!password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + } + if (typeof password !== 'string' || (username && typeof username !== 'string')) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + const [user] = await config.database.find( + this.className(), + { + username: body.username, + }, + { limit: 1, caseInsensitive: true }, + {} + ); + if (!user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + const matchPassword = await passwordCrypto.compare(password, user.password); + if (!matchPassword) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + if (user.mfaOptions?.enabled) { + if (!otp) { + throw new Parse.Error( + Parse.Error.MFA_TOKEN_REQUIRED, + 'Please specify a One Time password.' + ); + } + const totp = new TOTP({ + algorithm: user.mfaOptions?.algorithm || 'SHA1', + secret: Secret.fromBase32(user.mfaOptions.secret), + }); + const valid = totp.validate({ + token: otp, + }); + if (valid === null) { + throw new Parse.Error(Parse.Error.MFA_ERROR, 'Invalid One Time Password.'); + } + } + + delete user.password; + delete user.mfaOptions; + + return { response: user }; + } + + mountRoutes() { + this.route( + 'POST', + '/dashboardLogin', + promiseEnsureIdempotency, + promiseEnforceMasterKeyAccess, + req => this.handleDashboardLogin(req) + ); + this.route( + 'POST', + '/dashboardSignup', + promiseEnsureIdempotency, + promiseEnforceMasterKeyAccess, + req => this.handleDashboardCreate(req) + ); + } +} + +export default DashboardRouter; diff --git a/src/rest.js b/src/rest.js index fca3497a5d..19a8c8dc1a 100644 --- a/src/rest.js +++ b/src/rest.js @@ -250,6 +250,7 @@ const classesWithMasterOnlyAccess = [ '_GlobalConfig', '_JobSchedule', '_Idempotency', + '_DashboardUser', ]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) {