diff --git a/package-lock.json b/package-lock.json index 7564dfdceb..81159756de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3104,6 +3104,48 @@ } } }, + "@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==" + }, + "@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "requires": { + "@otplib/core": "^12.0.1" + } + }, + "@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "requires": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "requires": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "requires": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "@parse/fs-files-adapter": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@parse/fs-files-adapter/-/fs-files-adapter-1.2.0.tgz", @@ -5859,8 +5901,30 @@ "requires": { "call-bind": "^1.0.0", "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.0", "has-symbols": "^1.0.1", "object-keys": "^1.1.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } } } } @@ -10195,6 +10259,16 @@ "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true }, + "otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "requires": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "p-cancelable": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.3.0.tgz", @@ -12049,6 +12123,11 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha1-TKL//AKlEpDSdEueP1V2k8prYno=" + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index ba3c3544cc..1dd61df56c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "lru-cache": "5.1.1", "mime": "2.4.6", "mongodb": "3.6.2", + "otplib": "12.0.1", "parse": "2.18.0", "pg-promise": "10.8.1", "pluralize": "8.0.0", diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 99b57b1379..c100dbd64a 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -47,7 +47,8 @@ function getENVPrefix(iface) { 'LiveQueryOptions' : 'PARSE_SERVER_LIVEQUERY_', 'IdempotencyOptions' : 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', 'AccountLockoutOptions' : 'PARSE_SERVER_ACCOUNT_LOCKOUT_', - 'PasswordPolicyOptions' : 'PARSE_SERVER_PASSWORD_POLICY_' + 'PasswordPolicyOptions' : 'PARSE_SERVER_PASSWORD_POLICY_', + 'MfaOptions' : 'PARSE_SERVER_MFA_' } if (options[iface.id.name]) { return options[iface.id.name] @@ -170,7 +171,7 @@ function parseDefaultValue(elt, value, t) { }); literalValue = t.objectExpression(props); } - if (type == 'IdempotencyOptions') { + if (type == 'IdempotencyOptions' || type == 'MfaOptions') { const object = parsers.objectParser(value); const props = Object.keys(object).map((key) => { return t.objectProperty(key, object[value]); diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js new file mode 100644 index 0000000000..0eadab1a36 --- /dev/null +++ b/spec/ParseUser.MFA.spec.js @@ -0,0 +1,413 @@ +'use strict'; + +const request = require('../lib/request'); +const otplib = require('otplib'); +const Config = require('../lib/Config'); + +describe('MFA', () => { + function enableMfa(user) { + return request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me/enableMfa', + json: true, + headers: { + 'X-Parse-Session-Token': user && user.getSessionToken(), + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }); + } + + function verifyMfa(user, token) { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/users/me/verifyMfa', + body: { + token, + }, + headers: { + 'X-Parse-Session-Token': user && user.getSessionToken(), + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + } + + function loginWithMFA(username, password, token, recoveryKeys) { + let req = `http://localhost:8378/1/login?username=${username}&password=${password}`; + if (token) { + req += `&token=${token}`; + } + if (recoveryKeys) { + req += `&recoveryKeys=${recoveryKeys}`; + } + return request({ + method: 'POST', + url: req, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + } + + it('should enable MFA tokens', async () => { + try { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + appName: 'testApp', + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret, qrcodeURL }, + } = await enableMfa(user); // this function would be user.enable2FA() one SDK is updated + expect(qrcodeURL).toBeDefined(); + expect(qrcodeURL).toContain('otpauth://totp/testApp'); + expect(qrcodeURL).toContain('secret'); + expect(qrcodeURL).toContain('username'); + expect(qrcodeURL).toContain('period'); + expect(qrcodeURL).toContain('digits'); + expect(qrcodeURL).toContain('algorithm'); + const token = otplib.authenticator.generate(secret); // this token would be generated from authenticator + await verifyMfa(user, token); // this function would be user.verifyMfa() + await Parse.User.logOut(); + let verifytoken = ''; + const mfaLogin = async () => { + try { + const result = await loginWithMFA('username', 'password', verifytoken); // Parse.User.login('username','password',verifytoken); + if (!verifytoken) { + throw 'Should not have been able to login.'; + } + const newUser = result.data; + expect(newUser.objectId).toBe(user.id); + expect(newUser.username).toBe('username'); + expect(newUser.createdAt).toBe(user.createdAt.toISOString()); + expect(newUser.mfaEnabled).toBe(true); + } catch (err) { + expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); + verifytoken = otplib.authenticator.generate(secret); + if (err.text.includes('211')) { + // this user is 2FA enroled, get code + await mfaLogin(); + } + } + }; + await mfaLogin(); + } catch (e) { + console.log(e); + throw e; + } + }); + + it('can reject MFA', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + await verifyMfa(user, token); + await Parse.User.logOut(); + try { + await loginWithMFA('username', 'password', '123102'); + throw 'should not be able to login.'; + } catch (e) { + expect(e.text).toBe('{"code":210,"error":"Invalid MFA token"}'); + } + }); + + it('can encrypt MFA tokens', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + await verifyMfa(user, token); + await Parse.User.logOut(); + let verifytoken = ''; + const mfaLogin = async () => { + try { + const result = await loginWithMFA('username', 'password', verifytoken); + if (!verifytoken) { + throw 'Should not have been able to login.'; + } + const newUser = result.data; + expect(newUser.objectId).toBe(user.id); + expect(newUser.username).toBe('username'); + expect(newUser.createdAt).toBe(user.createdAt.toISOString()); + expect(newUser._mfa).toBeUndefined(); + expect(newUser.mfaEnabled).toBe(true); + } catch (err) { + expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); + verifytoken = otplib.authenticator.generate(secret); + if (err.text.includes('211')) { + await mfaLogin(); + } + } + }; + await mfaLogin(); + }); + + it('can get and recover MFA', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + const { + data: { recoveryKeys }, + } = await verifyMfa(user, token); + expect(recoveryKeys.length).toBe(2); + expect(recoveryKeys[0].length).toBe(20); + expect(recoveryKeys[1].length).toBe(20); + await Parse.User.logOut(); + const result = await loginWithMFA('username', 'password', null, recoveryKeys); + const newUser = result.data; + expect(newUser.objectId).toBe(user.id); + expect(newUser.username).toBe('username'); + expect(newUser.createdAt).toBe(user.createdAt.toISOString()); + expect(newUser._mfa).toBeUndefined(); + expect(newUser.mfaEnabled).toBe(false); + }); + + it('returns error on invalid recovery', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + await verifyMfa(user, token); + await Parse.User.logOut(); + try { + await loginWithMFA('username', 'password', null, [ + '01234567890123456789', + '01234567890123456789', + ]); + fail('should have not been able to login with invalid recovery keys'); + } catch (err) { + expect(err.text).toMatch('{"code":210,"error":"Invalid MFA recovery tokens"}'); + } + try { + await loginWithMFA('username', 'password', null, ['a', 'b']); + fail('should have not been able to login with invalid recovery keys'); + } catch (err) { + expect(err.text).toMatch('{"code":210,"error":"Invalid MFA recovery tokens"}'); + } + }); + + it('cannot set mfa or recovery token', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + await verifyMfa(user, token); + user.set('mfa', 'foo'); + user.set('mfa_recovery', ['1234', '5678']); + await user.save(null, { sessionToken: user.getSessionToken() }); + const database = Config.get(Parse.applicationId).database; + const [dbUser] = await database.find('_User', { + username: 'username', + }); + expect(dbUser._mfa).not.toEqual('foo'); + expect(dbUser._mfa_recovery).not.toEqual(['1234', '5678']); + }); + + it('cannot call enableMfa without user', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + try { + await enableMfa(); + fail('should not be able to enable MFA without a user.'); + } catch (err) { + expect(err.text).toMatch('{"code":101,"error":"Unauthorized"}'); + } + try { + await verifyMfa(); + fail('should not be able to enable MFA without a user.'); + } catch (err) { + expect(err.text).toMatch('{"code":101,"error":"Unauthorized"}'); + } + }); + + it('throws on second time enabling MFA', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + await verifyMfa(user, token); + try { + await verifyMfa(user, token); + } catch (err) { + expect(err.text).toMatch('{"code":210,"error":"MFA is already active"}'); + } + }); + + it('prevent setting on mfw / MFA tokens', async () => { + const user = await Parse.User.signUp('username', 'password'); + user.set('mfaEnabled', true); + user.set('mfa', true); + user.set('_mfa', true); + await user.save(null, { sessionToken: user.getSessionToken() }); + await user.fetch({ sessionToken: user.getSessionToken() }); + expect(user.get('mfaEnabled')).toBeUndefined(); + expect(user.get('mfa')).toBeUndefined(); + expect(user.get('_mfa')).toBeUndefined(); + }); + + it('verify throws correct error', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + try { + await verifyMfa(user, 'token'); + } catch (e) { + expect(e.text).toBe( + '{"code":210,"error":"MFA is not enabled on this account. Please enable MFA before calling this function."}' + ); + } + try { + await enableMfa(user); + await verifyMfa(user); + } catch (e) { + expect(e.text).toBe('{"code":211,"error":"Please provide a token."}'); + } + try { + await verifyMfa(user, 'tokenhere'); + } catch (e) { + expect(e.text).toBe('{"code":210,"error":"Invalid MFA token"}'); + } + }); + + it('can prevent re-enabling MFA', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enableMfa(user); + const token = otplib.authenticator.generate(secret); + await verifyMfa(user, token); + try { + await enableMfa(user); + } catch (e) { + expect(e.text).toBe('{"code":210,"error":"MFA is already enabled on this account."}'); + } + }); + + it('disabled MFA throws correct error', async () => { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: false, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + try { + await enableMfa(user); + } catch (e) { + expect(e.text).toBe('{"code":210,"error":"MFA is not enabled."}'); + } + try { + await verifyMfa(user, 'tokenhere'); + } catch (e) { + expect(e.text).toBe('{"code":210,"error":"MFA is not enabled."}'); + } + }); + it('throw on bad MFA config', async () => { + try { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: [], + }, + }); + fail('should throw on bad MFA config'); + } catch (e) { + expect(e).toBe('multiFactorAuth.enableMfa must be a boolean value.'); + } + try { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + }, + }); + fail('should throw on bad MFA config'); + } catch (e) { + expect(e).toBe('to use multiFactorAuth, you must specify an encryption string.'); + } + try { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: [], + }, + }); + fail('should throw on bad MFA config'); + } catch (e) { + expect(e).toBe('multiFactorAuth.encryptionKey must be a string value.'); + } + try { + await reconfigureServer({ + multiFactorAuth: { + enableMfa: true, + encryptionKey: 'weakkey', + }, + }); + fail('should throw on bad MFA config'); + } catch (e) { + expect(e).toBe('multiFactorAuth.encryptionKey must be longer than 10 characters.'); + } + }); +}); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 6cdb610e9d..d853f54a82 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -105,6 +105,8 @@ const userSchema = { email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, + mfaEnabled: { type: 'Boolean' }, + _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: defaultClassLevelPermissions, }; @@ -1288,6 +1290,8 @@ describe('schemas', () => { authData: { type: 'Object' }, newField: { type: 'String' }, ACL: { type: 'ACL' }, + mfaEnabled: { type: 'Boolean' }, + _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: { ...defaultClassLevelPermissions, @@ -1316,6 +1320,8 @@ describe('schemas', () => { authData: { type: 'Object' }, newField: { type: 'String' }, ACL: { type: 'ACL' }, + mfaEnabled: { type: 'Boolean' }, + _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: defaultClassLevelPermissions, }) diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index ff025cfd09..0b7e120516 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -18,29 +18,20 @@ const transformKey = (className, fieldName, schema) => { return '_last_used'; case 'timesUsed': return 'times_used'; + case 'mfa': + return '_mfa'; } - if ( - schema.fields[fieldName] && - schema.fields[fieldName].__type == 'Pointer' - ) { + if (schema.fields[fieldName] && schema.fields[fieldName].__type == 'Pointer') { fieldName = '_p_' + fieldName; - } else if ( - schema.fields[fieldName] && - schema.fields[fieldName].type == 'Pointer' - ) { + } else if (schema.fields[fieldName] && schema.fields[fieldName].type == 'Pointer') { fieldName = '_p_' + fieldName; } return fieldName; }; -const transformKeyValueForUpdate = ( - className, - restKey, - restValue, - parseFormatSchema -) => { +const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSchema) => { // Check if the schema is known since it's a built-in field. var key = restKey; var timeField = false; @@ -106,14 +97,14 @@ const transformKeyValueForUpdate = ( key = 'times_used'; timeField = true; break; + case 'mfa': + key = '_mfa'; + break; } if ( - (parseFormatSchema.fields[key] && - parseFormatSchema.fields[key].type === 'Pointer') || - (!parseFormatSchema.fields[key] && - restValue && - restValue.__type == 'Pointer') + (parseFormatSchema.fields[key] && parseFormatSchema.fields[key].type === 'Pointer') || + (!parseFormatSchema.fields[key] && restValue && restValue.__type == 'Pointer') ) { key = '_p_' + key; } @@ -179,7 +170,7 @@ const isAllValuesRegexOrNone = values => { }; const isAnyValueRegex = values => { - return values.some(function(value) { + return values.some(function (value) { return isRegex(value); }); }; @@ -286,15 +277,14 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { case '_wperm': case '_perishable_token': case '_email_verify_token': + case '_mfa': return { key, value }; case '$or': case '$and': case '$nor': return { key: key, - value: value.map(subQuery => - transformWhere(className, subQuery, schema, count) - ), + value: value.map(subQuery => transformWhere(className, subQuery, schema, count)), }; case 'lastUsed': if (valueAsDate(value)) { @@ -315,17 +305,13 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { } } - const expectedTypeIsArray = - schema && schema.fields[key] && schema.fields[key].type === 'Array'; + const expectedTypeIsArray = schema && schema.fields[key] && schema.fields[key].type === 'Array'; const expectedTypeIsPointer = schema && schema.fields[key] && schema.fields[key].type === 'Pointer'; const field = schema && schema.fields[key]; - if ( - expectedTypeIsPointer || - (!schema && value && value.__type === 'Pointer') - ) { + if (expectedTypeIsPointer || (!schema && value && value.__type === 'Pointer')) { key = '_p_' + key; } @@ -362,23 +348,13 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { function transformWhere(className, restWhere, schema, count = false) { const mongoWhere = {}; for (const restKey in restWhere) { - const out = transformQueryKeyValue( - className, - restKey, - restWhere[restKey], - schema, - count - ); + const out = transformQueryKeyValue(className, restKey, restWhere[restKey], schema, count); mongoWhere[out.key] = out.value; } return mongoWhere; } -const parseObjectKeyValueToMongoObjectKeyValue = ( - restKey, - restValue, - schema -) => { +const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => { // Check if the schema is known since it's a built-in field. let transformedValue; let coercedToDate; @@ -388,37 +364,27 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( case 'expiresAt': transformedValue = transformTopLevelAtom(restValue); coercedToDate = - typeof transformedValue === 'string' - ? new Date(transformedValue) - : transformedValue; + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; return { key: 'expiresAt', value: coercedToDate }; case '_email_verify_token_expires_at': transformedValue = transformTopLevelAtom(restValue); coercedToDate = - typeof transformedValue === 'string' - ? new Date(transformedValue) - : transformedValue; + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; return { key: '_email_verify_token_expires_at', value: coercedToDate }; case '_account_lockout_expires_at': transformedValue = transformTopLevelAtom(restValue); coercedToDate = - typeof transformedValue === 'string' - ? new Date(transformedValue) - : transformedValue; + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; return { key: '_account_lockout_expires_at', value: coercedToDate }; case '_perishable_token_expires_at': transformedValue = transformTopLevelAtom(restValue); coercedToDate = - typeof transformedValue === 'string' - ? new Date(transformedValue) - : transformedValue; + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; return { key: '_perishable_token_expires_at', value: coercedToDate }; case '_password_changed_at': transformedValue = transformTopLevelAtom(restValue); coercedToDate = - typeof transformedValue === 'string' - ? new Date(transformedValue) - : transformedValue; + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; return { key: '_password_changed_at', value: coercedToDate }; case '_failed_login_count': case '_rperm': @@ -432,10 +398,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( default: // Auth data should have been transformed already if (restKey.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error( - Parse.Error.INVALID_KEY_NAME, - 'can only query on ' + restKey - ); + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'can only query on ' + restKey); } // Trust that the auth data has been transformed and save it directly if (restKey.match(/^_auth_data_[a-zA-Z0-9_]+$/)) { @@ -473,9 +436,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( } // Handle normal objects by recursing - if ( - Object.keys(restValue).some(key => key.includes('$') || key.includes('.')) - ) { + if (Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { throw new Parse.Error( Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters" @@ -504,15 +465,11 @@ const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { // Use the legacy mongo format for createdAt and updatedAt if (mongoCreate.createdAt) { - mongoCreate._created_at = new Date( - mongoCreate.createdAt.iso || mongoCreate.createdAt - ); + mongoCreate._created_at = new Date(mongoCreate.createdAt.iso || mongoCreate.createdAt); delete mongoCreate.createdAt; } if (mongoCreate.updatedAt) { - mongoCreate._updated_at = new Date( - mongoCreate.updatedAt.iso || mongoCreate.updatedAt - ); + mongoCreate._updated_at = new Date(mongoCreate.updatedAt.iso || mongoCreate.updatedAt); delete mongoCreate.updatedAt; } @@ -593,22 +550,14 @@ function CannotTransform() {} const transformInteriorAtom = atom => { // TODO: check validity harder for the __type-defined types - if ( - typeof atom === 'object' && - atom && - !(atom instanceof Date) && - atom.__type === 'Pointer' - ) { + if (typeof atom === 'object' && atom && !(atom instanceof Date) && atom.__type === 'Pointer') { return { __type: 'Pointer', className: atom.className, objectId: atom.objectId, }; } else if (typeof atom === 'function' || typeof atom === 'symbol') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `cannot transform value: ${atom}` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); } else if (DateCoder.isValidJSON(atom)) { return DateCoder.JSONToDatabase(atom); } else if (BytesCoder.isValidJSON(atom)) { @@ -640,10 +589,7 @@ function transformTopLevelAtom(atom, field) { return atom; case 'symbol': case 'function': - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `cannot transform value: ${atom}` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); case 'object': if (atom instanceof Date) { // Technically dates are not rest format, but, it seems pretty @@ -822,16 +768,11 @@ function transformConstraint(constraint, field, count = false) { if (typeof constraint !== 'object' || !constraint) { return CannotTransform; } - const transformFunction = inArray - ? transformInteriorAtom - : transformTopLevelAtom; + const transformFunction = inArray ? transformInteriorAtom : transformTopLevelAtom; const transformer = atom => { const result = transformFunction(atom, field); if (result === CannotTransform) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad atom: ${JSON.stringify(atom)}` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${JSON.stringify(atom)}`); } return result; }; @@ -839,9 +780,7 @@ function transformConstraint(constraint, field, count = false) { // This is a hack so that: // $regex is handled before $options // $nearSphere is handled before $maxDistance - var keys = Object.keys(constraint) - .sort() - .reverse(); + var keys = Object.keys(constraint).sort().reverse(); var answer = {}; for (var key of keys) { switch (key) { @@ -892,10 +831,7 @@ function transformConstraint(constraint, field, count = false) { case '$nin': { const arr = constraint[key]; if (!(arr instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad ' + key + ' value' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } answer[key] = _.flatMap(arr, value => { return (atom => { @@ -911,10 +847,7 @@ function transformConstraint(constraint, field, count = false) { case '$all': { const arr = constraint[key]; if (!(arr instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad ' + key + ' value' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } answer[key] = arr.map(transformInteriorAtom); @@ -939,10 +872,7 @@ function transformConstraint(constraint, field, count = false) { case '$containedBy': { const arr = constraint[key]; if (!(arr instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $containedBy: should be an array` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); } answer.$elemMatch = { $nin: arr.map(transformer), @@ -956,33 +886,21 @@ function transformConstraint(constraint, field, count = false) { case '$text': { const search = constraint[key].$search; if (typeof search !== 'object') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $search, should be object` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $search, should be object`); } if (!search.$term || typeof search.$term !== 'string') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $term, should be string` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $term, should be string`); } else { answer[key] = { $search: search.$term, }; } if (search.$language && typeof search.$language !== 'string') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $language, should be string` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $language, should be string`); } else if (search.$language) { answer[key].$language = search.$language; } - if ( - search.$caseSensitive && - typeof search.$caseSensitive !== 'boolean' - ) { + if (search.$caseSensitive && typeof search.$caseSensitive !== 'boolean') { throw new Parse.Error( Parse.Error.INVALID_JSON, `bad $text: $caseSensitive, should be boolean` @@ -990,10 +908,7 @@ function transformConstraint(constraint, field, count = false) { } else if (search.$caseSensitive) { answer[key].$caseSensitive = search.$caseSensitive; } - if ( - search.$diacriticSensitive && - typeof search.$diacriticSensitive !== 'boolean' - ) { + if (search.$diacriticSensitive && typeof search.$diacriticSensitive !== 'boolean') { throw new Parse.Error( Parse.Error.INVALID_JSON, `bad $text: $diacriticSensitive, should be boolean` @@ -1007,10 +922,7 @@ function transformConstraint(constraint, field, count = false) { const point = constraint[key]; if (count) { answer.$geoWithin = { - $centerSphere: [ - [point.longitude, point.latitude], - constraint.$maxDistance, - ], + $centerSphere: [[point.longitude, point.latitude], constraint.$maxDistance], }; } else { answer[key] = [point.longitude, point.latitude]; @@ -1046,10 +958,7 @@ function transformConstraint(constraint, field, count = false) { case '$within': var box = constraint[key]['$box']; if (!box || box.length != 2) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'malformatted $within arg' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'malformatted $within arg'); } answer[key] = { $box: [ @@ -1092,10 +1001,7 @@ function transformConstraint(constraint, field, count = false) { return point; } if (!GeoPointCoder.isValidJSON(point)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad $geoWithin value' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $geoWithin value'); } else { Parse.GeoPoint._validate(point.latitude, point.longitude); } @@ -1156,10 +1062,7 @@ function transformConstraint(constraint, field, count = false) { } default: if (key.match(/^\$+/)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad constraint: ' + key - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad constraint: ' + key); } return CannotTransform; } @@ -1188,10 +1091,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { case 'Increment': if (typeof amount !== 'number') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'incrementing must provide a number' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); } if (flatten) { return amount; @@ -1202,10 +1102,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { case 'Add': case 'AddUnique': if (!(objects instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'objects to add must be an array' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } var toAdd = objects.map(transformInteriorAtom); if (flatten) { @@ -1220,10 +1117,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { case 'Remove': if (!(objects instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'objects to remove must be an array' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); } var toRemove = objects.map(transformInteriorAtom); if (flatten) { @@ -1362,6 +1256,12 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { break; case '_acl': break; + case '_mfa': + restObject._mfa = mongoObject[key]; + break; + case '_mfa_recovery': + restObject._mfa_recovery = mongoObject[key]; + break; case '_email_verify_token': case '_perishable_token': case '_perishable_token_expires_at': @@ -1379,15 +1279,11 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { break; case 'updatedAt': case '_updated_at': - restObject['updatedAt'] = Parse._encode( - new Date(mongoObject[key]) - ).iso; + restObject['updatedAt'] = Parse._encode(new Date(mongoObject[key])).iso; break; case 'createdAt': case '_created_at': - restObject['createdAt'] = Parse._encode( - new Date(mongoObject[key]) - ).iso; + restObject['createdAt'] = Parse._encode(new Date(mongoObject[key])).iso; break; case 'expiresAt': case '_expiresAt': @@ -1395,9 +1291,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { break; case 'lastUsed': case '_last_used': - restObject['lastUsed'] = Parse._encode( - new Date(mongoObject[key]) - ).iso; + restObject['lastUsed'] = Parse._encode(new Date(mongoObject[key])).iso; break; case 'timesUsed': case 'times_used': @@ -1445,11 +1339,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { if (mongoObject[key] === null) { break; } - restObject[newKey] = transformPointerString( - schema, - newKey, - mongoObject[key] - ); + restObject[newKey] = transformPointerString(schema, newKey, mongoObject[key]); break; } else if (key[0] == '_' && key != '__type') { throw 'bad key in untransform: ' + key; @@ -1488,9 +1378,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { break; } } - restObject[key] = nestedMongoObjectToNestedParseObject( - mongoObject[key] - ); + restObject[key] = nestedMongoObjectToNestedParseObject(mongoObject[key]); } } @@ -1518,16 +1406,12 @@ var DateCoder = { }, isValidJSON(value) { - return ( - typeof value === 'object' && value !== null && value.__type === 'Date' - ); + return typeof value === 'object' && value !== null && value.__type === 'Date'; }, }; var BytesCoder = { - base64Pattern: new RegExp( - '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' - ), + base64Pattern: new RegExp('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'), isBase64Value(object) { if (typeof object !== 'string') { return false; @@ -1557,9 +1441,7 @@ var BytesCoder = { }, isValidJSON(value) { - return ( - typeof value === 'object' && value !== null && value.__type === 'Bytes' - ); + return typeof value === 'object' && value !== null && value.__type === 'Bytes'; }, }; @@ -1581,9 +1463,7 @@ var GeoPointCoder = { }, isValidJSON(value) { - return ( - typeof value === 'object' && value !== null && value.__type === 'GeoPoint' - ); + return typeof value === 'object' && value !== null && value.__type === 'GeoPoint'; }, }; @@ -1648,9 +1528,7 @@ var PolygonCoder = { }, isValidJSON(value) { - return ( - typeof value === 'object' && value !== null && value.__type === 'Polygon' - ); + return typeof value === 'object' && value !== null && value.__type === 'Polygon'; }, }; @@ -1671,9 +1549,7 @@ var FileCoder = { }, isValidJSON(value) { - return ( - typeof value === 'object' && value !== null && value.__type === 'File' - ); + return typeof value === 'object' && value !== null && value.__type === 'File'; }, }; diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index aa2ddf3f40..78b1b63176 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -253,12 +253,7 @@ interface WhereClause { sorts: Array; } -const buildWhereClause = ({ - schema, - query, - index, - caseInsensitive, -}): WhereClause => { +const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClause => { const patterns = []; let values = []; const sorts = []; @@ -266,9 +261,7 @@ const buildWhereClause = ({ schema = toPostgresSchema(schema); for (const fieldName in query) { const isArrayField = - schema.fields && - schema.fields[fieldName] && - schema.fields[fieldName].type === 'Array'; + schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; const initialPatternsLength = patterns.length; const fieldValue = query[fieldName]; @@ -284,10 +277,7 @@ const buildWhereClause = ({ if (authDataMatch) { // TODO: Handle querying by _auth_data_provider, authData is stored in authData field continue; - } else if ( - caseInsensitive && - (fieldName === 'username' || fieldName === 'email') - ) { + } else if (caseInsensitive && (fieldName === 'username' || fieldName === 'email')) { patterns.push(`LOWER($${index}:name) = LOWER($${index + 1})`); values.push(fieldName, fieldValue); index += 2; @@ -324,10 +314,7 @@ const buildWhereClause = ({ } else if (typeof fieldValue === 'boolean') { patterns.push(`$${index}:name = $${index + 1}`); // Can't cast boolean to double precision - if ( - schema.fields[fieldName] && - schema.fields[fieldName].type === 'Number' - ) { + if (schema.fields[fieldName] && schema.fields[fieldName].type === 'Number') { // Should always return zero results const MAX_INT_PLUS_ONE = 9223372036854775808; values.push(fieldName, MAX_INT_PLUS_ONE); @@ -377,9 +364,7 @@ const buildWhereClause = ({ // if not null, we need to manually exclude null if (fieldValue.$ne.__type === 'GeoPoint') { patterns.push( - `($${index}:name <> POINT($${index + 1}, $${ - index + 2 - }) OR $${index}:name IS NULL)` + `($${index}:name <> POINT($${index + 1}, $${index + 2}) OR $${index}:name IS NULL)` ); } else { if (fieldName.indexOf('.') >= 0) { @@ -388,9 +373,7 @@ const buildWhereClause = ({ `(${constraintFieldName} <> $${index} OR ${constraintFieldName} IS NULL)` ); } else { - patterns.push( - `($${index}:name <> $${index + 1} OR $${index}:name IS NULL)` - ); + patterns.push(`($${index}:name <> $${index + 1} OR $${index}:name IS NULL)`); } } } @@ -421,8 +404,7 @@ const buildWhereClause = ({ } } } - const isInOrNin = - Array.isArray(fieldValue.$in) || Array.isArray(fieldValue.$nin); + const isInOrNin = Array.isArray(fieldValue.$in) || Array.isArray(fieldValue.$nin); if ( Array.isArray(fieldValue.$in) && isArrayField && @@ -441,9 +423,7 @@ const buildWhereClause = ({ } }); if (allowNull) { - patterns.push( - `($${index}:name IS NULL OR $${index}:name && ARRAY[${inPatterns.join()}])` - ); + patterns.push(`($${index}:name IS NULL OR $${index}:name && ARRAY[${inPatterns.join()}])`); } else { patterns.push(`$${index}:name && ARRAY[${inPatterns.join()}]`); } @@ -453,9 +433,7 @@ const buildWhereClause = ({ const not = notIn ? ' NOT ' : ''; if (baseArray.length > 0) { if (isArrayField) { - patterns.push( - `${not} array_contains($${index}:name, $${index + 1})` - ); + patterns.push(`${not} array_contains($${index}:name, $${index + 1})`); values.push(fieldName, JSON.stringify(baseArray)); index += 2; } else { @@ -518,13 +496,9 @@ const buildWhereClause = ({ const value = processRegexPattern(fieldValue.$all[i].$regex); fieldValue.$all[i] = value.substring(1) + '%'; } - patterns.push( - `array_contains_all_regex($${index}:name, $${index + 1}::jsonb)` - ); + patterns.push(`array_contains_all_regex($${index}:name, $${index + 1}::jsonb)`); } else { - patterns.push( - `array_contains_all($${index}:name, $${index + 1}::jsonb)` - ); + patterns.push(`array_contains_all($${index}:name, $${index + 1}::jsonb)`); } values.push(fieldName, JSON.stringify(fieldValue.$all)); index += 2; @@ -549,10 +523,7 @@ const buildWhereClause = ({ if (fieldValue.$containedBy) { const arr = fieldValue.$containedBy; if (!(arr instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $containedBy: should be an array` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); } patterns.push(`$${index}:name <@ $${index + 1}::jsonb`); @@ -564,22 +535,13 @@ const buildWhereClause = ({ const search = fieldValue.$text.$search; let language = 'english'; if (typeof search !== 'object') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $search, should be object` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $search, should be object`); } if (!search.$term || typeof search.$term !== 'string') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $term, should be string` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $term, should be string`); } if (search.$language && typeof search.$language !== 'string') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $language, should be string` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $language, should be string`); } else if (search.$language) { language = search.$language; } @@ -594,10 +556,7 @@ const buildWhereClause = ({ `bad $text: $caseSensitive not supported, please use $regex or create a separate lower case column.` ); } - if ( - search.$diacriticSensitive && - typeof search.$diacriticSensitive !== 'boolean' - ) { + if (search.$diacriticSensitive && typeof search.$diacriticSensitive !== 'boolean') { throw new Parse.Error( Parse.Error.INVALID_JSON, `bad $text: $diacriticSensitive, should be boolean` @@ -609,9 +568,7 @@ const buildWhereClause = ({ ); } patterns.push( - `to_tsvector($${index}, $${index + 1}:name) @@ to_tsquery($${ - index + 2 - }, $${index + 3})` + `to_tsvector($${index}, $${index + 1}:name) @@ to_tsquery($${index + 2}, $${index + 3})` ); values.push(language, fieldName, language, search.$term); index += 4; @@ -716,10 +673,7 @@ const buildWhereClause = ({ return `(${point[0]}, ${point[1]})`; } if (typeof point !== 'object' || point.__type !== 'GeoPoint') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad $geoWithin value' - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $geoWithin value'); } else { Parse.GeoPoint._validate(point.latitude, point.longitude); } @@ -830,9 +784,7 @@ const buildWhereClause = ({ if (initialPatternsLength === patterns.length) { throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, - `Postgres doesn't support this query type yet ${JSON.stringify( - fieldValue - )}` + `Postgres doesn't support this query type yet ${JSON.stringify(fieldValue)}` ); } } @@ -903,12 +855,7 @@ export class PostgresStorageAdapter implements StorageAdapter { const self = this; await this._client.task('set-class-level-permissions', async t => { await self._ensureSchemaCollectionExists(t); - const values = [ - className, - 'schema', - 'classLevelPermissions', - JSON.stringify(CLPs), - ]; + const values = [className, 'schema', 'classLevelPermissions', JSON.stringify(CLPs)]; await t.none( `UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className" = $1`, values @@ -936,10 +883,7 @@ export class PostgresStorageAdapter implements StorageAdapter { Object.keys(submittedIndexes).forEach(name => { const field = submittedIndexes[name]; if (existingIndexes[name] && field.__op !== 'Delete') { - throw new Parse.Error( - Parse.Error.INVALID_QUERY, - `Index ${name} exists, cannot update.` - ); + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`); } if (!existingIndexes[name] && field.__op === 'Delete') { throw new Parse.Error( @@ -1030,6 +974,8 @@ export class PostgresStorageAdapter implements StorageAdapter { fields._perishable_token_expires_at = { type: 'Date' }; fields._password_changed_at = { type: 'Date' }; fields._password_history = { type: 'Array' }; + fields._mfa = { type: 'String' }; + fields._mfa_recovery = { type: 'Array' }; } let index = 2; const relations = []; @@ -1093,24 +1039,14 @@ export class PostgresStorageAdapter implements StorageAdapter { const newColumns = Object.keys(schema.fields) .filter(item => columns.indexOf(item) === -1) .map(fieldName => - self.addFieldIfNotExists( - className, - fieldName, - schema.fields[fieldName], - t - ) + self.addFieldIfNotExists(className, fieldName, schema.fields[fieldName], t) ); await t.batch(newColumns); }); } - async addFieldIfNotExists( - className: string, - fieldName: string, - type: any, - conn: any - ) { + async addFieldIfNotExists(className: string, fieldName: string, type: any, conn: any) { // TODO: Must be revised for invalid logic... debug('addFieldIfNotExists', { className, fieldName, type }); conn = conn || this._client; @@ -1128,11 +1064,7 @@ export class PostgresStorageAdapter implements StorageAdapter { ); } catch (error) { if (error.code === PostgresRelationDoesNotExistError) { - return self.createClass( - className, - { fields: { [fieldName]: type } }, - t - ); + return self.createClass(className, { fields: { [fieldName]: type } }, t); } if (error.code !== PostgresDuplicateColumnError) { throw error; @@ -1234,11 +1166,7 @@ export class PostgresStorageAdapter implements StorageAdapter { // may do so. // Returns a Promise. - async deleteFields( - className: string, - schema: SchemaType, - fieldNames: string[] - ): Promise { + async deleteFields(className: string, schema: SchemaType, fieldNames: string[]): Promise { debug('deleteFields', className, fieldNames); fieldNames = fieldNames.reduce((list: Array, fieldName: string) => { const field = schema.fields[fieldName]; @@ -1257,15 +1185,12 @@ export class PostgresStorageAdapter implements StorageAdapter { .join(', DROP COLUMN'); await this._client.tx('delete-fields', async t => { - await t.none( - 'UPDATE "_SCHEMA" SET "schema" = $ WHERE "className" = $', - { schema, className } - ); + await t.none('UPDATE "_SCHEMA" SET "schema" = $ WHERE "className" = $', { + schema, + className, + }); if (values.length > 1) { - await t.none( - `ALTER TABLE $1:name DROP COLUMN IF EXISTS ${columns}`, - values - ); + await t.none(`ALTER TABLE $1:name DROP COLUMN IF EXISTS ${columns}`, values); } }); } @@ -1412,10 +1337,7 @@ export class PostgresStorageAdapter implements StorageAdapter { const fieldName = columnsArray[index]; if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { termination = '::text[]'; - } else if ( - schema.fields[fieldName] && - schema.fields[fieldName].type === 'Array' - ) { + } else if (schema.fields[fieldName] && schema.fields[fieldName].type === 'Array') { termination = '::jsonb'; } return `$${index + 2 + columnsArray.length}${termination}`; @@ -1427,18 +1349,13 @@ export class PostgresStorageAdapter implements StorageAdapter { return `POINT($${l}, $${l + 1})`; }); - const columnsPattern = columnsArray - .map((col, index) => `$${index + 2}:name`) - .join(); + const columnsPattern = columnsArray.map((col, index) => `$${index + 2}:name`).join(); const valuesPattern = initialValues.concat(geoPointsInjects).join(); const qs = `INSERT INTO $1:name (${columnsPattern}) VALUES (${valuesPattern})`; const values = [className, ...columnsArray, ...valuesArray]; debug(qs, values); - const promise = (transactionalSession - ? transactionalSession.t - : this._client - ) + const promise = (transactionalSession ? transactionalSession.t : this._client) .none(qs, values) .then(() => ({ ops: [object] })) .catch(error => { @@ -1488,17 +1405,11 @@ export class PostgresStorageAdapter implements StorageAdapter { } const qs = `WITH deleted AS (DELETE FROM $1:name WHERE ${where.pattern} RETURNING *) SELECT count(*) FROM deleted`; debug(qs, values); - const promise = (transactionalSession - ? transactionalSession.t - : this._client - ) + const promise = (transactionalSession ? transactionalSession.t : this._client) .one(qs, values, a => +a.count) .then(count => { if (count === 0) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.' - ); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } else { return count; } @@ -1523,13 +1434,9 @@ export class PostgresStorageAdapter implements StorageAdapter { transactionalSession: ?any ): Promise { debug('findOneAndUpdate', className, query, update); - return this.updateObjectsByQuery( - className, - schema, - query, - update, - transactionalSession - ).then(val => val[0]); + return this.updateObjectsByQuery(className, schema, query, update, transactionalSession).then( + val => val[0] + ); } // Apply the update to all objects that match the given Parse Query. @@ -1571,6 +1478,14 @@ export class PostgresStorageAdapter implements StorageAdapter { update['authData'] = update['authData'] || {}; update['authData'][provider] = value; } + if (fieldName === 'mfa') { + update['_mfa'] = update['mfa']; + delete update.mfa; + } + if (fieldName === 'mfa_recovery') { + update['_mfa_recovery'] = update['mfa_recovery']; + delete update.mfa_recovery; + } } for (const fieldName in update) { @@ -1592,39 +1507,28 @@ export class PostgresStorageAdapter implements StorageAdapter { const fieldNameIndex = index; index += 1; values.push(fieldName); - const update = Object.keys(fieldValue).reduce( - (lastKey: string, key: string) => { - const str = generate( - lastKey, - `$${index}::text`, - `$${index + 1}::jsonb` - ); - index += 2; - let value = fieldValue[key]; - if (value) { - if (value.__op === 'Delete') { - value = null; - } else { - value = JSON.stringify(value); - } + const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => { + const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`); + index += 2; + let value = fieldValue[key]; + if (value) { + if (value.__op === 'Delete') { + value = null; + } else { + value = JSON.stringify(value); } - values.push(key, value); - return str; - }, - lastKey - ); + } + values.push(key, value); + return str; + }, lastKey); updatePatterns.push(`$${fieldNameIndex}:name = ${update}`); } else if (fieldValue.__op === 'Increment') { - updatePatterns.push( - `$${index}:name = COALESCE($${index}:name, 0) + $${index + 1}` - ); + updatePatterns.push(`$${index}:name = COALESCE($${index}:name, 0) + $${index + 1}`); values.push(fieldName, fieldValue.amount); index += 2; } else if (fieldValue.__op === 'Add') { updatePatterns.push( - `$${index}:name = array_add(COALESCE($${index}:name, '[]'::jsonb), $${ - index + 1 - }::jsonb)` + `$${index}:name = array_add(COALESCE($${index}:name, '[]'::jsonb), $${index + 1}::jsonb)` ); values.push(fieldName, JSON.stringify(fieldValue.objects)); index += 2; @@ -1678,9 +1582,7 @@ export class PostgresStorageAdapter implements StorageAdapter { values.push(fieldName, toPostgresValue(fieldValue)); index += 2; } else if (fieldValue.__type === 'GeoPoint') { - updatePatterns.push( - `$${index}:name = POINT($${index + 1}, $${index + 2})` - ); + updatePatterns.push(`$${index}:name = POINT($${index + 1}, $${index + 2})`); values.push(fieldName, fieldValue.longitude, fieldValue.latitude); index += 3; } else if (fieldValue.__type === 'Polygon') { @@ -1745,12 +1647,9 @@ export class PostgresStorageAdapter implements StorageAdapter { }) .map(k => k.split('.')[1]); - const deletePatterns = keysToDelete.reduce( - (p: string, c: string, i: number) => { - return p + ` - '$${index + 1 + i}:value'`; - }, - '' - ); + const deletePatterns = keysToDelete.reduce((p: string, c: string, i: number) => { + return p + ` - '$${index + 1 + i}:value'`; + }, ''); // Override Object let updateObject = "'{}'::jsonb"; @@ -1799,14 +1698,10 @@ export class PostgresStorageAdapter implements StorageAdapter { }); values.push(...where.values); - const whereClause = - where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`; debug('update: ', qs, values); - const promise = (transactionalSession - ? transactionalSession.t - : this._client - ).any(qs, values); + const promise = (transactionalSession ? transactionalSession.t : this._client).any(qs, values); if (transactionalSession) { transactionalSession.batch.push(promise); } @@ -1823,23 +1718,12 @@ export class PostgresStorageAdapter implements StorageAdapter { ) { debug('upsertOneObject', { className, query, update }); const createValue = Object.assign({}, query, update); - return this.createObject( - className, - schema, - createValue, - transactionalSession - ).catch(error => { + return this.createObject(className, schema, createValue, transactionalSession).catch(error => { // ignore duplicate value errors as it's upsert if (error.code !== Parse.Error.DUPLICATE_VALUE) { throw error; } - return this.findOneAndUpdate( - className, - schema, - query, - update, - transactionalSession - ); + return this.findOneAndUpdate(className, schema, query, update, transactionalSession); }); } @@ -1868,8 +1752,7 @@ export class PostgresStorageAdapter implements StorageAdapter { }); values.push(...where.values); - const wherePattern = - where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : ''; if (hasLimit) { values.push(limit); @@ -1892,10 +1775,7 @@ export class PostgresStorageAdapter implements StorageAdapter { return `${transformKey} DESC`; }) .join(); - sortPattern = - sort !== undefined && Object.keys(sort).length > 0 - ? `ORDER BY ${sorting}` - : ''; + sortPattern = sort !== undefined && Object.keys(sort).length > 0 ? `ORDER BY ${sorting}` : ''; } if (where.sorts && Object.keys((where.sorts: any)).length > 0) { sortPattern = `ORDER BY ${where.sorts.join()}`; @@ -1926,9 +1806,7 @@ export class PostgresStorageAdapter implements StorageAdapter { } const originalQuery = `SELECT ${columns} FROM $1:name ${wherePattern} ${sortPattern} ${limitPattern} ${skipPattern}`; - const qs = explain - ? this.createExplainableQuery(originalQuery) - : originalQuery; + const qs = explain ? this.createExplainableQuery(originalQuery) : originalQuery; debug(qs, values); return this._client .any(qs, values) @@ -1943,9 +1821,7 @@ export class PostgresStorageAdapter implements StorageAdapter { if (explain) { return results; } - return results.map(object => - this.postgresObjectToParseObject(className, object, schema) - ); + return results.map(object => this.postgresObjectToParseObject(className, object, schema)); }); } @@ -1977,10 +1853,7 @@ export class PostgresStorageAdapter implements StorageAdapter { let coords = object[fieldName]; coords = coords.substr(2, coords.length - 4).split('),('); coords = coords.map(point => { - return [ - parseFloat(point.split(',')[1]), - parseFloat(point.split(',')[0]), - ]; + return [parseFloat(point.split(',')[1]), parseFloat(point.split(',')[0])]; }); object[fieldName] = { __type: 'Polygon', @@ -2052,37 +1925,26 @@ export class PostgresStorageAdapter implements StorageAdapter { // As such, we shouldn't expose this function to users of parse until we have an out-of-band // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, // which is why we use sparse indexes. - async ensureUniqueness( - className: string, - schema: SchemaType, - fieldNames: string[] - ) { + async ensureUniqueness(className: string, schema: SchemaType, fieldNames: string[]) { const constraintName = `${className}_unique_${fieldNames.sort().join('_')}`; - const constraintPatterns = fieldNames.map( - (fieldName, index) => `$${index + 3}:name` - ); + const constraintPatterns = fieldNames.map((fieldName, index) => `$${index + 3}:name`); const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $2:name ON $1:name(${constraintPatterns.join()})`; - return this._client - .none(qs, [className, constraintName, ...fieldNames]) - .catch(error => { - if ( - error.code === PostgresDuplicateRelationError && - error.message.includes(constraintName) - ) { - // Index already exists. Ignore error. - } else if ( - error.code === PostgresUniqueIndexViolationError && - error.message.includes(constraintName) - ) { - // Cast the error into the proper parse error - throw new Parse.Error( - Parse.Error.DUPLICATE_VALUE, - 'A duplicate value for a field with unique values was provided' - ); - } else { - throw error; - } - }); + return this._client.none(qs, [className, constraintName, ...fieldNames]).catch(error => { + if (error.code === PostgresDuplicateRelationError && error.message.includes(constraintName)) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(constraintName) + ) { + // Cast the error into the proper parse error + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + } else { + throw error; + } + }); } // Executes a count. @@ -2103,15 +1965,13 @@ export class PostgresStorageAdapter implements StorageAdapter { }); values.push(...where.values); - const wherePattern = - where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; let qs = ''; if (where.pattern.length > 0 || !estimate) { qs = `SELECT count(*) FROM $1:name ${wherePattern}`; } else { - qs = - 'SELECT reltuples AS approximate_row_count FROM pg_class WHERE relname = $1'; + qs = 'SELECT reltuples AS approximate_row_count FROM pg_class WHERE relname = $1'; } return this._client @@ -2130,12 +1990,7 @@ export class PostgresStorageAdapter implements StorageAdapter { }); } - async distinct( - className: string, - schema: SchemaType, - query: QueryType, - fieldName: string - ) { + async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) { debug('distinct', className, query); let field = fieldName; let column = fieldName; @@ -2145,13 +2000,9 @@ export class PostgresStorageAdapter implements StorageAdapter { column = fieldName.split('.')[0]; } const isArrayField = - schema.fields && - schema.fields[fieldName] && - schema.fields[fieldName].type === 'Array'; + schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; const isPointerField = - schema.fields && - schema.fields[fieldName] && - schema.fields[fieldName].type === 'Pointer'; + schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Pointer'; const values = [field, column, className]; const where = buildWhereClause({ schema, @@ -2161,8 +2012,7 @@ export class PostgresStorageAdapter implements StorageAdapter { }); values.push(...where.values); - const wherePattern = - where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const transformer = isArrayField ? 'jsonb_array_elements' : 'ON'; let qs = `SELECT DISTINCT ${transformer}($1:name) $2:name FROM $3:name ${wherePattern}`; if (isNested) { @@ -2195,9 +2045,7 @@ export class PostgresStorageAdapter implements StorageAdapter { return results.map(object => object[column][child]); }) .then(results => - results.map(object => - this.postgresObjectToParseObject(className, object, schema) - ) + results.map(object => this.postgresObjectToParseObject(className, object, schema)) ); } @@ -2235,11 +2083,7 @@ export class PostgresStorageAdapter implements StorageAdapter { index += 1; continue; } - if ( - field === '_id' && - typeof value === 'object' && - Object.keys(value).length !== 0 - ) { + if (field === '_id' && typeof value === 'object' && Object.keys(value).length !== 0) { groupValues = value; const groupByFields = []; for (const alias in value) { @@ -2261,9 +2105,7 @@ export class PostgresStorageAdapter implements StorageAdapter { columns.push( `EXTRACT(${ mongoAggregateToPostgres[operation] - } FROM $${index}:name AT TIME ZONE 'UTC') AS $${ - index + 1 - }:name` + } FROM $${index}:name AT TIME ZONE 'UTC') AS $${index + 1}:name` ); values.push(source, alias); index += 2; @@ -2323,10 +2165,7 @@ export class PostgresStorageAdapter implements StorageAdapter { } if (stage.$match) { const patterns = []; - const orOrAnd = Object.prototype.hasOwnProperty.call( - stage.$match, - '$or' - ) + const orOrAnd = Object.prototype.hasOwnProperty.call(stage.$match, '$or') ? ' OR ' : ' AND '; @@ -2345,9 +2184,7 @@ export class PostgresStorageAdapter implements StorageAdapter { Object.keys(ParseToPosgresComparator).forEach(cmp => { if (value[cmp]) { const pgComparator = ParseToPosgresComparator[cmp]; - matchPatterns.push( - `$${index}:name ${pgComparator} $${index + 1}` - ); + matchPatterns.push(`$${index}:name ${pgComparator} $${index + 1}`); values.push(field, toPostgresValue(value[cmp])); index += 2; } @@ -2355,18 +2192,13 @@ export class PostgresStorageAdapter implements StorageAdapter { if (matchPatterns.length > 0) { patterns.push(`(${matchPatterns.join(' AND ')})`); } - if ( - schema.fields[field] && - schema.fields[field].type && - matchPatterns.length === 0 - ) { + if (schema.fields[field] && schema.fields[field].type && matchPatterns.length === 0) { patterns.push(`$${index}:name = $${index + 1}`); values.push(field, value); index += 2; } } - wherePattern = - patterns.length > 0 ? `WHERE ${patterns.join(` ${orOrAnd} `)}` : ''; + wherePattern = patterns.length > 0 ? `WHERE ${patterns.join(` ${orOrAnd} `)}` : ''; } if (stage.$limit) { limitPattern = `LIMIT $${index}`; @@ -2390,8 +2222,7 @@ export class PostgresStorageAdapter implements StorageAdapter { }) .join(); values.push(...keys); - sortPattern = - sort !== undefined && sorting.length > 0 ? `ORDER BY ${sorting}` : ''; + sortPattern = sort !== undefined && sorting.length > 0 ? `ORDER BY ${sorting}` : ''; } } @@ -2406,17 +2237,13 @@ export class PostgresStorageAdapter implements StorageAdapter { const originalQuery = `SELECT ${columns .filter(Boolean) .join()} FROM $1:name ${wherePattern} ${skipPattern} ${groupPattern} ${sortPattern} ${limitPattern}`; - const qs = explain - ? this.createExplainableQuery(originalQuery) - : originalQuery; + const qs = explain ? this.createExplainableQuery(originalQuery) : originalQuery; debug(qs, values); return this._client.any(qs, values).then(a => { if (explain) { return a; } - const results = a.map(object => - this.postgresObjectToParseObject(className, object, schema) - ); + const results = a.map(object => this.postgresObjectToParseObject(className, object, schema)); results.forEach(result => { if (!Object.prototype.hasOwnProperty.call(result, 'objectId')) { result.objectId = null; @@ -2474,11 +2301,7 @@ export class PostgresStorageAdapter implements StorageAdapter { }); } - async createIndexes( - className: string, - indexes: any, - conn: ?any - ): Promise { + async createIndexes(className: string, indexes: any, conn: ?any): Promise { return (conn || this._client).tx(t => t.batch( indexes.map(i => { @@ -2498,9 +2321,7 @@ export class PostgresStorageAdapter implements StorageAdapter { type: any, conn: ?any ): Promise { - await ( - conn || this._client - ).none('CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', [ + await (conn || this._client).none('CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', [ fieldName, className, type, @@ -2512,9 +2333,7 @@ export class PostgresStorageAdapter implements StorageAdapter { query: 'DROP INDEX $1:name', values: i, })); - await (conn || this._client).tx(t => - t.none(this._pgp.helpers.concat(queries)) - ); + await (conn || this._client).tx(t => t.none(this._pgp.helpers.concat(queries))); } async getIndexes(className: string) { @@ -2547,18 +2366,14 @@ export class PostgresStorageAdapter implements StorageAdapter { } commitTransactionalSession(transactionalSession: any): Promise { - transactionalSession.resolve( - transactionalSession.t.batch(transactionalSession.batch) - ); + transactionalSession.resolve(transactionalSession.t.batch(transactionalSession.batch)); return transactionalSession.result; } abortTransactionalSession(transactionalSession: any): Promise { const result = transactionalSession.result.catch(); transactionalSession.batch.push(Promise.reject()); - transactionalSession.resolve( - transactionalSession.t.batch(transactionalSession.batch) - ); + transactionalSession.resolve(transactionalSession.t.batch(transactionalSession.batch)); return result; } @@ -2575,41 +2390,34 @@ export class PostgresStorageAdapter implements StorageAdapter { const indexNameOptions: Object = indexName != null ? { name: indexName } : { name: defaultIndexName }; const constraintPatterns = caseInsensitive - ? fieldNames.map( - (fieldName, index) => `lower($${index + 3}:name) varchar_pattern_ops` - ) + ? fieldNames.map((fieldName, index) => `lower($${index + 3}:name) varchar_pattern_ops`) : fieldNames.map((fieldName, index) => `$${index + 3}:name`); const qs = `CREATE INDEX IF NOT EXISTS $1:name ON $2:name (${constraintPatterns.join()})`; - await conn - .none(qs, [indexNameOptions.name, className, ...fieldNames]) - .catch(error => { - if ( - error.code === PostgresDuplicateRelationError && - error.message.includes(indexNameOptions.name) - ) { - // Index already exists. Ignore error. - } else if ( - error.code === PostgresUniqueIndexViolationError && - error.message.includes(indexNameOptions.name) - ) { - // Cast the error into the proper parse error - throw new Parse.Error( - Parse.Error.DUPLICATE_VALUE, - 'A duplicate value for a field with unique values was provided' - ); - } else { - throw error; - } - }); + await conn.none(qs, [indexNameOptions.name, className, ...fieldNames]).catch(error => { + if ( + error.code === PostgresDuplicateRelationError && + error.message.includes(indexNameOptions.name) + ) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(indexNameOptions.name) + ) { + // Cast the error into the proper parse error + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + } else { + throw error; + } + }); } } function convertPolygonToSQL(polygon) { if (polygon.length < 3) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `Polygon must have at least 3 values` - ); + throw new Parse.Error(Parse.Error.INVALID_JSON, `Polygon must have at least 3 values`); } if ( polygon[0][0] !== polygon[polygon.length - 1][0] || @@ -2757,9 +2565,7 @@ function literalizeRegexPart(s: string) { var GeoPointCoder = { isValidJSON(value) { - return ( - typeof value === 'object' && value !== null && value.__type === 'GeoPoint' - ); + return typeof value === 'object' && value !== null && value.__type === 'GeoPoint'; }, }; diff --git a/src/Config.js b/src/Config.js index 5c64df180a..23aefdfab6 100644 --- a/src/Config.js +++ b/src/Config.js @@ -71,6 +71,7 @@ export class Config { allowHeaders, idempotencyOptions, emailVerifyTokenReuseIfValid, + multiFactorAuth, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -105,6 +106,7 @@ export class Config { this.validateMaxLimit(maxLimit); this.validateAllowHeaders(allowHeaders); this.validateIdempotencyOptions(idempotencyOptions); + this.validateMultiFactorAuth(multiFactorAuth); } static validateIdempotencyOptions(idempotencyOptions) { @@ -125,6 +127,27 @@ export class Config { } } + static validateMultiFactorAuth(multiFactorAuth) { + if (!multiFactorAuth) { + return; + } + if (multiFactorAuth.enableMfa && typeof multiFactorAuth.enableMfa !== 'boolean') { + throw 'multiFactorAuth.enableMfa must be a boolean value.'; + } + if (!multiFactorAuth.enableMfa) { + return; + } + if (multiFactorAuth.enableMfa && !multiFactorAuth.encryptionKey) { + throw 'to use multiFactorAuth, you must specify an encryption string.'; + } + if (multiFactorAuth.encryptionKey && typeof multiFactorAuth.encryptionKey !== 'string') { + throw 'multiFactorAuth.encryptionKey must be a string value.'; + } + if (multiFactorAuth.encryptionKey.length < 10) { + throw 'multiFactorAuth.encryptionKey must be longer than 10 characters.'; + } + } + static validateAccountLockoutPolicy(accountLockout) { if (accountLockout) { if ( diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 5b6bfc083a..d9260427df 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -60,6 +60,8 @@ const specialQuerykeys = [ '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count', + '_mfa', + '_mfa_recovery', ]; const isSpecialQueryKey = key => { @@ -222,6 +224,8 @@ const filterSensitiveData = ( delete object._account_lockout_expires_at; delete object._password_changed_at; delete object._password_history; + delete object._mfa; + delete object._mfa_recovery; if (aclGroup.indexOf(object.objectId) > -1) { return object; @@ -251,6 +255,8 @@ const specialKeysForUpdate = [ '_perishable_token_expires_at', '_password_changed_at', '_password_history', + '_mfa', + '_mfa_recovery', ]; const isSpecialUpdateKey = key => { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index a35126f38a..5f08dc0d18 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -44,6 +44,8 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, + mfaEnabled: { type: 'Boolean' }, + _mfa_recovery: { type: 'Array' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) _Installation: { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index a615db60f8..e083e03114 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -127,7 +127,8 @@ module.exports.ParseServerOptions = { }, emailVerifyTokenReuseIfValid: { env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID', - help: 'an existing email verify token should be reused when resend verification email is requested', + help: + 'an existing email verify token should be reused when resend verification email is requested', action: parsers.booleanParser, default: false, }, @@ -276,6 +277,11 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + multiFactorAuth: { + env: 'PARSE_SERVER_MFA', + help: 'Options for multi-factor authentication (MFA)', + action: parsers.objectParser, + }, objectIdSize: { env: 'PARSE_SERVER_OBJECT_ID_SIZE', help: "Sets the number of characters in generated object id's, default 10", @@ -551,6 +557,21 @@ module.exports.IdempotencyOptions = { default: 300, }, }; +module.exports.MfaOptions = { + enableMfa: { + env: 'PARSE_SERVER_MFA_ENABLE_MFA', + help: + 'Is true if multi-factor authentication (MFA) can be enabled for users. If a user has MFA enabled, a login requires a code generated by a third-party authenticator (TPA) app. Default is false.', + required: true, + action: parsers.booleanParser, + default: false, + }, + encryptionKey: { + env: 'PARSE_SERVER_MFA_ENCRYPTION_KEY', + help: + 'A secure key that is used to encrypt the multi-factor authentication (MFA) secret of a user. Required if enableMfa is true.', + }, +}; module.exports.AccountLockoutOptions = { duration: { env: 'PARSE_SERVER_ACCOUNT_LOCKOUT_DURATION', diff --git a/src/Options/docs.js b/src/Options/docs.js index 576ff60a14..eb00e0174f 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -52,6 +52,7 @@ * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint * @property {String} mountPath Mount path for the server, defaults to /parse * @property {Boolean} mountPlayground Mounts the GraphQL Playground - never use this option in production + * @property {MfaOptions} multiFactorAuth Options for multi-factor authentication (MFA) * @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10 * @property {PasswordPolicyOptions} passwordPolicy Password policy for enforcing password related rules * @property {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground @@ -121,6 +122,12 @@ * @property {Number} ttl The duration in seconds after which a request record is discarded from the database, defaults to 300s. */ +/** + * @interface MfaOptions + * @property {Boolean} enableMfa Is true if multi-factor authentication (MFA) can be enabled for users. If a user has MFA enabled, a login requires a code generated by a third-party authenticator (TPA) app. Default is false. + * @property {String} encryptionKey A secure key that is used to encrypt the multi-factor authentication (MFA) secret of a user. Required if enableMfa is true. + */ + /** * @interface AccountLockoutOptions * @property {Number} duration number of minutes that a locked-out account remains locked out before automatically becoming unlocked. diff --git a/src/Options/index.js b/src/Options/index.js index d2237e08a8..e179045a0d 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -198,6 +198,9 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS :DEFAULT: false */ idempotencyOptions: ?IdempotencyOptions; + /* Options for multi-factor authentication (MFA) + :ENV: PARSE_SERVER_MFA */ + multiFactorAuth: ?MfaOptions; /* Full path to your GraphQL custom schema.graphql file */ graphQLSchema: ?string; /* Mounts the GraphQL endpoint @@ -292,6 +295,14 @@ export interface IdempotencyOptions { ttl: ?number; } +export interface MfaOptions { + /* Is true if multi-factor authentication (MFA) can be enabled for users. If a user has MFA enabled, a login requires a code generated by a third-party authenticator (TPA) app. Default is false. + :DEFAULT: false */ + enableMfa: boolean; + /* A secure key that is used to encrypt the multi-factor authentication (MFA) secret of a user. Required if enableMfa is true. */ + encryptionKey: ?string; +} + export interface AccountLockoutOptions { /* number of minutes that a locked-out account remains locked out before automatically becoming unlocked. */ duration: ?number; diff --git a/src/RestWrite.js b/src/RestWrite.js index 38b318100c..81985ef922 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -578,6 +578,11 @@ RestWrite.prototype.transformUser = function () { const error = `Clients aren't allowed to manually update email verification.`; throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } + if (!this.auth.isMaster) { + delete this.data.mfaEnabled; + delete this.data.mfa; + delete this.data.mfa_recovery; + } // Do not cleanup session if objectId is not set if (this.query && this.objectId()) { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7843cf4674..43c8294c8e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -9,6 +9,9 @@ import Auth from '../Auth'; import passwordCrypto from '../password'; import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; import { promiseEnsureIdempotency } from '../middlewares'; +import { authenticator } from 'otplib'; +const crypto = require('crypto'); +import { randomString } from '../cryptoUtils'; export class UsersRouter extends ClassesRouter { className() { @@ -46,7 +49,7 @@ export class UsersRouter extends ClassesRouter { ) { payload = req.query; } - const { username, email, password } = payload; + const { username, email, password, token, recoveryKeys } = payload; // TODO: use the right error codes / descriptions. if (!username && !email) { @@ -97,7 +100,7 @@ export class UsersRouter extends ClassesRouter { const accountLockoutPolicy = new AccountLockout(user, req.config); return accountLockoutPolicy.handleLoginAttempt(isValidPassword); }) - .then(() => { + .then(async () => { if (!isValidPassword) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } @@ -130,7 +133,52 @@ export class UsersRouter extends ClassesRouter { delete user.authData; } } - + const mfaEnabled = req.config.multiFactorAuth || {}; + if (mfaEnabled.enableMfa && recoveryKeys && user.mfaEnabled) { + const mfaRecTokens = user._mfa_recovery; + let firstAllowed = false; + let secondAllowed = false; + const recoveryKeysStr = `${recoveryKeys}`; + if (recoveryKeysStr.length < 41) { + throw new Parse.Error(210, 'Invalid MFA recovery tokens'); + } + for (const recToken of mfaRecTokens) { + const setAllowedFromMatch = async (recoveryKey, first) => { + const doesMatch = await passwordCrypto.compare(recoveryKey, recToken); + if (!doesMatch) { + return; + } + if (first) { + firstAllowed = true; + } else { + secondAllowed = true; + } + }; + await setAllowedFromMatch(recoveryKeysStr.substring(0, 20), true); + await setAllowedFromMatch(recoveryKeysStr.substring(21, 41)); + } + if (!firstAllowed || !secondAllowed) { + throw new Parse.Error(210, 'Invalid MFA recovery tokens'); + } + await req.config.database.update( + '_User', + { username: user.username }, + { _mfa: null, mfaEnabled: false, _mfa_recovery: null } + ); + user.mfaEnabled = false; + } else if (mfaEnabled.enableMfa && user.mfaEnabled) { + if (!token) { + throw new Parse.Error(211, 'Please provide your MFA token.'); + } + const mfaToken = await this.decryptMFAKey( + user._mfa, + req.config.multiFactorAuth.encryptionKey + ); + if (!authenticator.verify({ token, secret: mfaToken })) { + throw new Parse.Error(210, 'Invalid MFA token'); + } + } + delete user._mfa; return resolve(user); }) .catch(error => { @@ -289,6 +337,135 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } + encryptMfaKey(mfa, encryptionKey) { + try { + const algorithm = 'aes-256-gcm'; + const encryption = crypto + .createHash('sha256') + .update(String(encryptionKey)) + .digest('base64') + .substr(0, 32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, encryption, iv); + const encryptedResult = Buffer.concat([ + cipher.update(mfa), + cipher.final(), + iv, + cipher.getAuthTag(), + ]); + return encryptedResult.toString('base64'); + } catch (e) { + throw new Parse.Error(210, 'Invalid MFA token'); + } + } + async decryptMFAKey(mfa, encryptionKey) { + try { + const algorithm = 'aes-256-gcm'; + const encryption = crypto + .createHash('sha256') + .update(String(encryptionKey)) + .digest('base64') + .substr(0, 32); + const data = Buffer.from(mfa, 'base64'); + const authTagLocation = data.length - 16; + const ivLocation = data.length - 32; + const authTag = data.slice(authTagLocation); + const iv = data.slice(ivLocation, authTagLocation); + const encrypted = data.slice(0, ivLocation); + const decipher = crypto.createDecipheriv(algorithm, encryption, iv); + decipher.setAuthTag(authTag); + return await new Promise((resolve, reject) => { + let decrypted = ''; + decipher.on('readable', chunk => { + while (null !== (chunk = decipher.read())) { + decrypted += chunk.toString('utf8'); + } + }); + decipher.on('end', () => { + resolve(decrypted); + }); + decipher.on('error', e => { + reject(e); + }); + decipher.write(encrypted); + decipher.end(); + }); + } catch (err) { + throw new Parse.Error(210, 'Invalid MFA token'); + } + } + async enableMfa(req) { + const mfaEnabled = req.config.multiFactorAuth || {}; + if (!mfaEnabled.enableMfa) { + throw new Parse.Error(210, 'MFA is not enabled.'); + } + if (!req.auth || !req.auth.user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Unauthorized'); + } + const [user] = await req.config.database.find('_User', { + objectId: req.auth.user.id, + }); + if (!user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Unauthorized'); + } + if (user._mfa) { + throw new Parse.Error(210, 'MFA is already enabled on this account.'); + } + const secret = authenticator.generateSecret(); + const otpauth = authenticator.keyuri(user.username, req.config.appName, secret); + const storeKey = this.encryptMfaKey( + `pending:${secret}`, + req.config.multiFactorAuth.encryptionKey + ); + await req.config.database.update('_User', { objectId: user.objectId }, { _mfa: storeKey }); + return { response: { qrcodeURL: otpauth, secret } }; + } + + async verifyMfa(req) { + const mfaEnabled = req.config.multiFactorAuth || {}; + if (!mfaEnabled.enableMfa) { + throw new Parse.Error(210, 'MFA is not enabled.'); + } + if (!req.auth || !req.auth.user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Unauthorized'); + } + const { token } = req.body; + if (!token) { + throw new Parse.Error(211, 'Please provide a token.'); + } + // Fetch the user directly from the DB as we need the _mfa + const [user] = await req.config.database.find('_User', { + objectId: req.auth.user.id, + }); + if (!user._mfa) { + throw new Parse.Error( + 210, + 'MFA is not enabled on this account. Please enable MFA before calling this function.' + ); + } + const mfa = await this.decryptMFAKey(user._mfa, req.config.multiFactorAuth.encryptionKey); + if (mfa.indexOf('pending:') !== 0) { + throw new Parse.Error(210, 'MFA is already active'); + } + const secret = mfa.slice('pending:'.length); + const result = authenticator.verify({ token, secret }); + if (!result) { + throw new Parse.Error(210, 'Invalid MFA token'); + } + const storeKey = this.encryptMfaKey(`${secret}`, req.config.multiFactorAuth.encryptionKey); + const recoveryKeyOne = randomString(20); + const recoveryKeyTwo = randomString(20); + const recoveryKeys = await Promise.all([ + passwordCrypto.hash(recoveryKeyOne), + passwordCrypto.hash(recoveryKeyTwo), + ]); + await req.config.database.update( + '_User', + { username: user.username }, + { _mfa: storeKey, mfaEnabled: true, _mfa_recovery: recoveryKeys } + ); + return { response: { recoveryKeys: [recoveryKeyOne, recoveryKeyTwo] } }; + } _runAfterLogoutTrigger(req, session) { // After logout trigger @@ -420,6 +597,8 @@ export class UsersRouter extends ClassesRouter { this.route('POST', '/logout', req => { return this.handleLogOut(req); }); + this.route('GET', '/users/me/enableMfa', req => this.enableMfa(req)); + this.route('POST', '/users/me/verifyMfa', req => this.verifyMfa(req)); this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); });