From d2041fffc51635ce0c64eaa42f18c14d4b45bca5 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 27 Oct 2020 19:38:22 +1100 Subject: [PATCH 01/23] Initial --- package-lock.json | 79 +++ package.json | 1 + spec/ParseUser.2FA.spec.js | 203 ++++++++ src/Adapters/Storage/Mongo/MongoTransform.js | 249 +++------ .../Postgres/PostgresStorageAdapter.js | 481 +++++------------- src/Controllers/DatabaseController.js | 3 + src/Controllers/SchemaController.js | 1 + src/Options/Definitions.js | 5 + src/RestWrite.js | 5 + src/Routers/UsersRouter.js | 141 ++++- 10 files changed, 632 insertions(+), 536 deletions(-) create mode 100644 spec/ParseUser.2FA.spec.js diff --git a/package-lock.json b/package-lock.json index 1f9145fb7f..49e45747b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3120,6 +3120,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.0.1", "resolved": "https://registry.npmjs.org/@parse/fs-files-adapter/-/fs-files-adapter-1.0.1.tgz", @@ -5829,8 +5871,30 @@ "integrity": "sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==", "requires": { "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" + } + } } } } @@ -10149,6 +10213,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", @@ -12012,6 +12086,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 2f90cc18f6..c7b680a24f 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.17.0", "pg-promise": "10.6.2", "pluralize": "8.0.0", diff --git a/spec/ParseUser.2FA.spec.js b/spec/ParseUser.2FA.spec.js new file mode 100644 index 0000000000..a6dc5a4b3d --- /dev/null +++ b/spec/ParseUser.2FA.spec.js @@ -0,0 +1,203 @@ +'use strict'; + +const request = require('../lib/request'); +const otplib = require('otplib'); + +describe('2FA', () => { + function enable2FA(user) { + return request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me/enable2FA', + json: true, + headers: { + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }); + } + + function validate2FA(user, token) { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/users/me/verify2FA', + body: { + token, + }, + headers: { + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + } + + function loginWith2Fa(username, password, token) { + let req = `http://localhost:8378/1/login?username=${username}&password=${password}`; + if (token) { + req += `&token=${token}`; + } + 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 2FA tokens', async () => { + await reconfigureServer({ + twoFactor: { + enabled: true, + }, + appName: 'testApp', + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret, qrcodeURL }, + } = await enable2FA(user); + 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); + await validate2FA(user, token); + await Parse.User.logOut(); + let verifytoken = ''; + const mfaLogin = async () => { + try { + const result = await loginWith2Fa('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 2FA token."}'); + verifytoken = otplib.authenticator.generate(secret); + if (err.text.includes('211')) { + await mfaLogin(); + } + } + }; + await mfaLogin(); + }); + + it('can reject 2FA', async () => { + await reconfigureServer({ + twoFactor: { + enabled: true, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enable2FA(user); + const token = otplib.authenticator.generate(secret); + await validate2FA(user, token); + await Parse.User.logOut(); + try { + await loginWith2Fa('username', 'password', '123102'); + throw 'should not be able to login.'; + } catch (e) { + expect(e.text).toBe('{"code":212,"error":"Invalid 2FA token"}'); + } + }); + + it('can encrypt 2FA tokens', async () => { + await reconfigureServer({ + twoFactor: { + enabled: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enable2FA(user); + const token = otplib.authenticator.generate(secret); + await validate2FA(user, token); + await Parse.User.logOut(); + let verifytoken = ''; + const mfaLogin = async () => { + try { + const result = await loginWith2Fa('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(); + } catch (err) { + expect(err.text).toMatch('{"code":211,"error":"Please provide your 2FA token."}'); + verifytoken = otplib.authenticator.generate(secret); + if (err.text.includes('211')) { + await mfaLogin(); + } + } + }; + await mfaLogin(); + }); + it('cannot set _mfa or mfa', async () => { + await reconfigureServer({ + twoFactor: { + enabled: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const { + data: { secret }, + } = await enable2FA(user); + const token = otplib.authenticator.generate(secret); + await validate2FA(user, token); + user.set('_mfa', 'foo'); + user.set('mfa', 'foo'); + await user.save(null, { sessionToken: user.getSessionToken() }); + await Parse.User.logOut(); + let verifytoken = ''; + const mfaLogin = async () => { + try { + const result = await loginWith2Fa('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(); + } catch (err) { + expect(err.text).toMatch('{"code":211,"error":"Please provide your 2FA token."}'); + verifytoken = otplib.authenticator.generate(secret); + if (err.text.includes('211')) { + await mfaLogin(); + } + } + }; + await mfaLogin(); + }); + it('prevent setting on mfw / 2fa 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(); + }); +}); diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index ff025cfd09..0cc34492b4 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,9 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { break; case '_acl': break; + case '_mfa': + restObject._mfa = mongoObject[key]; + break; case '_email_verify_token': case '_perishable_token': case '_perishable_token_expires_at': @@ -1379,15 +1276,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 +1288,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 +1336,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 +1375,7 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { break; } } - restObject[key] = nestedMongoObjectToNestedParseObject( - mongoObject[key] - ); + restObject[key] = nestedMongoObjectToNestedParseObject(mongoObject[key]); } } @@ -1518,16 +1403,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 +1438,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 +1460,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 +1525,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 +1546,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 3ca9cd43f9..bf9cf29564 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -254,12 +254,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 = []; @@ -267,9 +262,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]; @@ -285,10 +278,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; @@ -325,10 +315,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); @@ -378,9 +365,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) { @@ -389,9 +374,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)`); } } } @@ -422,8 +405,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 && @@ -442,9 +424,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()}]`); } @@ -454,9 +434,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 { @@ -519,13 +497,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; @@ -550,10 +524,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`); @@ -565,22 +536,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; } @@ -595,10 +557,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` @@ -610,9 +569,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; @@ -717,10 +674,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); } @@ -831,9 +785,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)}` ); } } @@ -904,12 +856,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 @@ -937,10 +884,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( @@ -991,13 +935,7 @@ export class PostgresStorageAdapter implements StorageAdapter { 'INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', { className, schema } ); - const q3 = this.setIndexesWithSchemaFormat( - className, - schema.indexes, - {}, - schema.fields, - t - ); + const q3 = this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields, t); // TODO: The test should not verify the returned value, and then // the method can be simplified, to avoid returning useless stuff. return t.batch([q1, q2, q3]); @@ -1009,14 +947,8 @@ export class PostgresStorageAdapter implements StorageAdapter { if (err.data[0].result.code === PostgresTransactionAbortedError) { err = err.data[1].result; } - if ( - err.code === PostgresUniqueIndexViolationError && - err.detail.includes(className) - ) { - throw new Parse.Error( - Parse.Error.DUPLICATE_VALUE, - `Class ${className} already exists.` - ); + if (err.code === PostgresUniqueIndexViolationError && err.detail.includes(className)) { + throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, `Class ${className} already exists.`); } throw err; }); @@ -1039,6 +971,7 @@ 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' }; } let index = 2; const relations = []; @@ -1102,24 +1035,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; @@ -1137,11 +1060,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; @@ -1243,11 +1162,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]; @@ -1266,15 +1181,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); } }); } @@ -1421,10 +1333,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}`; @@ -1436,18 +1345,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 => { @@ -1497,17 +1401,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; } @@ -1532,13 +1430,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. @@ -1580,6 +1474,10 @@ export class PostgresStorageAdapter implements StorageAdapter { update['authData'] = update['authData'] || {}; update['authData'][provider] = value; } + if (fieldName === 'mfa') { + update['_mfa'] = update['mfa']; + delete update.mfa; + } } for (const fieldName in update) { @@ -1601,39 +1499,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; @@ -1687,9 +1574,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') { @@ -1754,12 +1639,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"; @@ -1808,14 +1690,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); } @@ -1832,23 +1710,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); }); } @@ -1877,8 +1744,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); @@ -1901,10 +1767,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()}`; @@ -1935,9 +1798,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) @@ -1952,9 +1813,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)); }); } @@ -1986,10 +1845,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', @@ -2061,37 +1917,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. @@ -2112,15 +1957,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 @@ -2139,12 +1982,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; @@ -2154,13 +1992,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, @@ -2170,8 +2004,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) { @@ -2204,9 +2037,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)) ); } @@ -2244,11 +2075,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) { @@ -2270,9 +2097,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; @@ -2332,10 +2157,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 '; @@ -2354,9 +2176,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; } @@ -2364,18 +2184,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}`; @@ -2399,8 +2214,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}` : ''; } } @@ -2415,17 +2229,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; @@ -2484,11 +2294,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 => { @@ -2508,9 +2314,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, @@ -2522,9 +2326,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) { @@ -2557,18 +2359,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; } @@ -2585,41 +2383,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] || @@ -2767,9 +2558,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/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 5b6bfc083a..a574f69f25 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -60,6 +60,7 @@ const specialQuerykeys = [ '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count', + '_mfa', ]; const isSpecialQueryKey = key => { @@ -222,6 +223,7 @@ const filterSensitiveData = ( delete object._account_lockout_expires_at; delete object._password_changed_at; delete object._password_history; + delete object._mfa; if (aclGroup.indexOf(object.objectId) > -1) { return object; @@ -251,6 +253,7 @@ const specialKeysForUpdate = [ '_perishable_token_expires_at', '_password_changed_at', '_password_history', + '_mfa', ]; const isSpecialUpdateKey = key => { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index a35126f38a..796fb8f460 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -43,6 +43,7 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ password: { type: 'String' }, email: { type: 'String' }, emailVerified: { type: 'Boolean' }, + MFAEnabled: { type: 'Boolean' }, authData: { type: 'Object' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index c3c1271786..cb1baccfcf 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -382,6 +382,11 @@ module.exports.ParseServerOptions = { help: 'Starts the liveQuery server', action: parsers.booleanParser, }, + twoFactor: { + env: 'PARSE_SERVER_TWO_FACTOR', + help: 'Enables two factor authentication.', + default: {}, + }, userSensitiveFields: { env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', help: diff --git a/src/RestWrite.js b/src/RestWrite.js index 0825267eeb..d556d77ba6 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; + } // 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 7e27845621..ca896572af 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -9,6 +9,8 @@ import Auth from '../Auth'; import passwordCrypto from '../password'; import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; import { promiseEnsureIdempotency } from '../middlewares'; +import * as otplib from 'otplib'; +const crypto = require('crypto'); export class UsersRouter extends ClassesRouter { className() { @@ -46,7 +48,7 @@ export class UsersRouter extends ClassesRouter { ) { payload = req.query; } - const { username, email, password } = payload; + const { username, email, password, token } = payload; // TODO: use the right error codes / descriptions. if (!username && !email) { @@ -130,7 +132,20 @@ export class UsersRouter extends ClassesRouter { delete user.authData; } } - + const mfaenabled = req.config.twoFactor || {}; + if (mfaenabled.enabled && user._mfa) { + if (!token) { + throw new Parse.Error(211, 'Please provide your 2FA token.'); + } + return this.decryptMFAKey(user._mfa, req.config.twoFactor.encryptionKey); + } + return Promise.resolve(); + }) + .then(mfaToken => { + if (req.config.twoFactor && !otplib.authenticator.verify({ token, secret: mfaToken })) { + throw new Parse.Error(212, 'Invalid 2FA token'); + } + delete user._mfa; return resolve(user); }) .catch(error => { @@ -289,6 +304,126 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } + encryptMFAKey(mfa, encryptionKey) { + try { + if (!encryptionKey) { + return mfa; + } + 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(212, 'Invalid 2FA token'); + } + } + async decryptMFAKey(mfa, encryptionKey) { + try { + if (encryptionKey == null) { + return mfa; + } + 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(212, 'Invalid 2FA token'); + } + } + async enable2FA(req) { + const mfaenabled = req.config.twoFactor || {}; + if (!mfaenabled.enabled) { + throw new Parse.Error(-1, 'MFA is not enabled.'); + } + const { user } = req.auth; + if (!user) { + throw new Parse.Error(101, 'Unauthorized'); + } + const secret = otplib.authenticator.generateSecret(); + const otpauth = otplib.authenticator.keyuri(user.get('username'), req.config.appName, secret); + const storeKey = this.encryptMFAKey(`pending:${secret}`, req.config.twoFactor.encryptionKey); + await req.config.database.update('_User', { objectId: user.id }, { _mfa: storeKey }); + return { response: { qrcodeURL: otpauth, secret } }; + } + + async verify2FA(req) { + const mfaenabled = req.config.twoFactor || {}; + if (!mfaenabled.enabled) { + throw new Parse.Error(210, 'MFA is not enabled.'); + } + if (!req.auth.user) { + throw new Parse.Error(101, '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( + 213, + 'MFA is not enabled on this account. Please enable MFA before calling this function.' + ); + } + const mfa = await this.decryptMFAKey(user._mfa, req.config.twoFactor.encryptionKey); + if (mfa.indexOf('pending:') !== 0) { + throw new Parse.Error(214, 'MFA is already active'); + } + const secret = mfa.slice('pending:'.length); + const result = otplib.authenticator.verify({ token, secret }); + if (!result) { + throw new Parse.Error(212, 'Invalid token'); + } + const storeKey = this.encryptMFAKey(`${secret}`, req.config.twoFactor.encryptionKey); + await req.config.database.update( + '_User', + { username: user.username }, + { _mfa: storeKey, MFAEnabled: true } + ); + return { response: {} }; + } _runAfterLogoutTrigger(req, session) { // After logout trigger @@ -419,6 +554,8 @@ export class UsersRouter extends ClassesRouter { this.route('POST', '/logout', req => { return this.handleLogOut(req); }); + this.route('GET', '/users/me/enable2FA', req => this.enable2FA(req)); + this.route('POST', '/users/me/verify2FA', req => this.verify2FA(req)); this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }); From bd6aab10f9b89f42dbd10b61928ac09901b8ebf4 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 27 Oct 2020 20:43:35 +1100 Subject: [PATCH 02/23] Fix tests --- spec/ParseUser.2FA.spec.js | 5 +++++ src/Routers/UsersRouter.js | 16 ++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/spec/ParseUser.2FA.spec.js b/spec/ParseUser.2FA.spec.js index a6dc5a4b3d..6bbdc3f67d 100644 --- a/spec/ParseUser.2FA.spec.js +++ b/spec/ParseUser.2FA.spec.js @@ -199,5 +199,10 @@ describe('2FA', () => { expect(user.get('MFAEnabled')).toBeUndefined(); expect(user.get('mfa')).toBeUndefined(); expect(user.get('_mfa')).toBeUndefined(); + await reconfigureServer({ + twoFactor: { + enabled: false, + }, + }); }); }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index ca896572af..cc3bdc3d81 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -99,7 +99,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.'); } @@ -137,13 +137,13 @@ export class UsersRouter extends ClassesRouter { if (!token) { throw new Parse.Error(211, 'Please provide your 2FA token.'); } - return this.decryptMFAKey(user._mfa, req.config.twoFactor.encryptionKey); - } - return Promise.resolve(); - }) - .then(mfaToken => { - if (req.config.twoFactor && !otplib.authenticator.verify({ token, secret: mfaToken })) { - throw new Parse.Error(212, 'Invalid 2FA token'); + const mfaToken = await this.decryptMFAKey( + user._mfa, + req.config.twoFactor.encryptionKey + ); + if (req.config.twoFactor && !otplib.authenticator.verify({ token, secret: mfaToken })) { + throw new Parse.Error(212, 'Invalid 2FA token'); + } } delete user._mfa; return resolve(user); From 9349b173f492d9b4cd6baba12ceed0361f3ff875 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 27 Oct 2020 20:58:03 +1100 Subject: [PATCH 03/23] Fix more tests --- spec/schemas.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 6cdb610e9d..3ff661ab29 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -105,6 +105,7 @@ const userSchema = { email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, + MFAEnabled: { type: 'Boolean' }, }, classLevelPermissions: defaultClassLevelPermissions, }; @@ -1287,6 +1288,7 @@ describe('schemas', () => { emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, newField: { type: 'String' }, + MFAEnabled: { type: 'Boolean' }, ACL: { type: 'ACL' }, }, classLevelPermissions: { @@ -1314,6 +1316,7 @@ describe('schemas', () => { email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, + MFAEnabled: { type: 'Boolean' }, newField: { type: 'String' }, ACL: { type: 'ACL' }, }, From 7f0eac325a4220cecf193e3ed6081bbf5ed1bfc8 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 27 Oct 2020 23:27:56 +1100 Subject: [PATCH 04/23] Change to MFA --- ...User.2FA.spec.js => ParseUser.MFA.spec.js} | 65 ++++++++++--------- src/Options/Definitions.js | 2 +- src/Routers/UsersRouter.js | 38 ++++++----- 3 files changed, 56 insertions(+), 49 deletions(-) rename spec/{ParseUser.2FA.spec.js => ParseUser.MFA.spec.js} (77%) diff --git a/spec/ParseUser.2FA.spec.js b/spec/ParseUser.MFA.spec.js similarity index 77% rename from spec/ParseUser.2FA.spec.js rename to spec/ParseUser.MFA.spec.js index 6bbdc3f67d..f0a80c0c27 100644 --- a/spec/ParseUser.2FA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -3,11 +3,11 @@ const request = require('../lib/request'); const otplib = require('otplib'); -describe('2FA', () => { - function enable2FA(user) { +describe('MFA', () => { + function enableMFA(user) { return request({ method: 'GET', - url: 'http://localhost:8378/1/users/me/enable2FA', + url: 'http://localhost:8378/1/users/me/enableMFA', json: true, headers: { 'X-Parse-Session-Token': user.getSessionToken(), @@ -17,10 +17,10 @@ describe('2FA', () => { }); } - function validate2FA(user, token) { + function validateMFA(user, token) { return request({ method: 'POST', - url: 'http://localhost:8378/1/users/me/verify2FA', + url: 'http://localhost:8378/1/users/me/verifyMFA', body: { token, }, @@ -33,7 +33,7 @@ describe('2FA', () => { }); } - function loginWith2Fa(username, password, token) { + function loginWithMFA(username, password, token) { let req = `http://localhost:8378/1/login?username=${username}&password=${password}`; if (token) { req += `&token=${token}`; @@ -49,9 +49,9 @@ describe('2FA', () => { }); } - it('should enable 2FA tokens', async () => { + it('should enable MFA tokens', async () => { await reconfigureServer({ - twoFactor: { + multiFactorAuth: { enabled: true, }, appName: 'testApp', @@ -59,7 +59,7 @@ describe('2FA', () => { const user = await Parse.User.signUp('username', 'password'); const { data: { secret, qrcodeURL }, - } = await enable2FA(user); + } = 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'); @@ -67,13 +67,13 @@ describe('2FA', () => { expect(qrcodeURL).toContain('period'); expect(qrcodeURL).toContain('digits'); expect(qrcodeURL).toContain('algorithm'); - const token = otplib.authenticator.generate(secret); - await validate2FA(user, token); + const token = otplib.authenticator.generate(secret); // this token would be generated from authenticator + await validateMFA(user, token); // this function would be user.validateMFA() await Parse.User.logOut(); let verifytoken = ''; const mfaLogin = async () => { try { - const result = await loginWith2Fa('username', 'password', verifytoken); + const result = await loginWithMFA('username', 'password', verifytoken); // Parse.User.login('username','password',verifytoken); if (!verifytoken) { throw 'Should not have been able to login.'; } @@ -83,9 +83,10 @@ describe('2FA', () => { expect(newUser.createdAt).toBe(user.createdAt.toISOString()); expect(newUser.MFAEnabled).toBe(true); } catch (err) { - expect(err.text).toMatch('{"code":211,"error":"Please provide your 2FA token."}'); + 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(); } } @@ -93,30 +94,30 @@ describe('2FA', () => { await mfaLogin(); }); - it('can reject 2FA', async () => { + it('can reject MFA', async () => { await reconfigureServer({ - twoFactor: { + multiFactorAuth: { enabled: true, }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enable2FA(user); + } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validate2FA(user, token); + await validateMFA(user, token); await Parse.User.logOut(); try { - await loginWith2Fa('username', 'password', '123102'); + await loginWithMFA('username', 'password', '123102'); throw 'should not be able to login.'; } catch (e) { - expect(e.text).toBe('{"code":212,"error":"Invalid 2FA token"}'); + expect(e.text).toBe('{"code":212,"error":"Invalid MFA token"}'); } }); - it('can encrypt 2FA tokens', async () => { + it('can encrypt MFA tokens', async () => { await reconfigureServer({ - twoFactor: { + multiFactorAuth: { enabled: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, @@ -124,14 +125,14 @@ describe('2FA', () => { const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enable2FA(user); + } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validate2FA(user, token); + await validateMFA(user, token); await Parse.User.logOut(); let verifytoken = ''; const mfaLogin = async () => { try { - const result = await loginWith2Fa('username', 'password', verifytoken); + const result = await loginWithMFA('username', 'password', verifytoken); if (!verifytoken) { throw 'Should not have been able to login.'; } @@ -141,7 +142,7 @@ describe('2FA', () => { expect(newUser.createdAt).toBe(user.createdAt.toISOString()); expect(newUser._mfa).toBeUndefined(); } catch (err) { - expect(err.text).toMatch('{"code":211,"error":"Please provide your 2FA token."}'); + expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); verifytoken = otplib.authenticator.generate(secret); if (err.text.includes('211')) { await mfaLogin(); @@ -152,7 +153,7 @@ describe('2FA', () => { }); it('cannot set _mfa or mfa', async () => { await reconfigureServer({ - twoFactor: { + multiFactorAuth: { enabled: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, @@ -160,9 +161,9 @@ describe('2FA', () => { const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enable2FA(user); + } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validate2FA(user, token); + await validateMFA(user, token); user.set('_mfa', 'foo'); user.set('mfa', 'foo'); await user.save(null, { sessionToken: user.getSessionToken() }); @@ -170,7 +171,7 @@ describe('2FA', () => { let verifytoken = ''; const mfaLogin = async () => { try { - const result = await loginWith2Fa('username', 'password', verifytoken); + const result = await loginWithMFA('username', 'password', verifytoken); if (!verifytoken) { throw 'Should not have been able to login.'; } @@ -180,7 +181,7 @@ describe('2FA', () => { expect(newUser.createdAt).toBe(user.createdAt.toISOString()); expect(newUser._mfa).toBeUndefined(); } catch (err) { - expect(err.text).toMatch('{"code":211,"error":"Please provide your 2FA token."}'); + expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); verifytoken = otplib.authenticator.generate(secret); if (err.text.includes('211')) { await mfaLogin(); @@ -189,7 +190,7 @@ describe('2FA', () => { }; await mfaLogin(); }); - it('prevent setting on mfw / 2fa tokens', async () => { + it('prevent setting on mfw / MFA tokens', async () => { const user = await Parse.User.signUp('username', 'password'); user.set('MFAEnabled', true); user.set('mfa', true); @@ -200,7 +201,7 @@ describe('2FA', () => { expect(user.get('mfa')).toBeUndefined(); expect(user.get('_mfa')).toBeUndefined(); await reconfigureServer({ - twoFactor: { + multiFactorAuth: { enabled: false, }, }); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index cb1baccfcf..7d054664be 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -382,7 +382,7 @@ module.exports.ParseServerOptions = { help: 'Starts the liveQuery server', action: parsers.booleanParser, }, - twoFactor: { + multiFactorAuth: { env: 'PARSE_SERVER_TWO_FACTOR', help: 'Enables two factor authentication.', default: {}, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index cc3bdc3d81..06918a9368 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -132,17 +132,20 @@ export class UsersRouter extends ClassesRouter { delete user.authData; } } - const mfaenabled = req.config.twoFactor || {}; + const mfaenabled = req.config.multiFactorAuth || {}; if (mfaenabled.enabled && user._mfa) { if (!token) { - throw new Parse.Error(211, 'Please provide your 2FA token.'); + throw new Parse.Error(211, 'Please provide your MFA token.'); } const mfaToken = await this.decryptMFAKey( user._mfa, - req.config.twoFactor.encryptionKey + req.config.multiFactorAuth.encryptionKey ); - if (req.config.twoFactor && !otplib.authenticator.verify({ token, secret: mfaToken })) { - throw new Parse.Error(212, 'Invalid 2FA token'); + if ( + req.config.multiFactorAuth && + !otplib.authenticator.verify({ token, secret: mfaToken }) + ) { + throw new Parse.Error(212, 'Invalid MFA token'); } } delete user._mfa; @@ -327,7 +330,7 @@ export class UsersRouter extends ClassesRouter { ]); return encryptedResult.toString('base64'); } catch (e) { - throw new Parse.Error(212, 'Invalid 2FA token'); + throw new Parse.Error(212, 'Invalid MFA token'); } } async decryptMFAKey(mfa, encryptionKey) { @@ -366,11 +369,11 @@ export class UsersRouter extends ClassesRouter { decipher.end(); }); } catch (err) { - throw new Parse.Error(212, 'Invalid 2FA token'); + throw new Parse.Error(212, 'Invalid MFA token'); } } - async enable2FA(req) { - const mfaenabled = req.config.twoFactor || {}; + async enableMFA(req) { + const mfaenabled = req.config.multiFactorAuth || {}; if (!mfaenabled.enabled) { throw new Parse.Error(-1, 'MFA is not enabled.'); } @@ -380,13 +383,16 @@ export class UsersRouter extends ClassesRouter { } const secret = otplib.authenticator.generateSecret(); const otpauth = otplib.authenticator.keyuri(user.get('username'), req.config.appName, secret); - const storeKey = this.encryptMFAKey(`pending:${secret}`, req.config.twoFactor.encryptionKey); + const storeKey = this.encryptMFAKey( + `pending:${secret}`, + req.config.multiFactorAuth.encryptionKey + ); await req.config.database.update('_User', { objectId: user.id }, { _mfa: storeKey }); return { response: { qrcodeURL: otpauth, secret } }; } - async verify2FA(req) { - const mfaenabled = req.config.twoFactor || {}; + async verifyMFA(req) { + const mfaenabled = req.config.multiFactorAuth || {}; if (!mfaenabled.enabled) { throw new Parse.Error(210, 'MFA is not enabled.'); } @@ -407,7 +413,7 @@ export class UsersRouter extends ClassesRouter { 'MFA is not enabled on this account. Please enable MFA before calling this function.' ); } - const mfa = await this.decryptMFAKey(user._mfa, req.config.twoFactor.encryptionKey); + const mfa = await this.decryptMFAKey(user._mfa, req.config.multiFactorAuth.encryptionKey); if (mfa.indexOf('pending:') !== 0) { throw new Parse.Error(214, 'MFA is already active'); } @@ -416,7 +422,7 @@ export class UsersRouter extends ClassesRouter { if (!result) { throw new Parse.Error(212, 'Invalid token'); } - const storeKey = this.encryptMFAKey(`${secret}`, req.config.twoFactor.encryptionKey); + const storeKey = this.encryptMFAKey(`${secret}`, req.config.multiFactorAuth.encryptionKey); await req.config.database.update( '_User', { username: user.username }, @@ -554,8 +560,8 @@ export class UsersRouter extends ClassesRouter { this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('GET', '/users/me/enable2FA', req => this.enable2FA(req)); - this.route('POST', '/users/me/verify2FA', req => this.verify2FA(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); }); From cba983525055fe7dd60f7f73905f15c5ef02a191 Mon Sep 17 00:00:00 2001 From: dblythy Date: Wed, 28 Oct 2020 06:02:00 +1100 Subject: [PATCH 05/23] Update UsersRouter.js --- src/Routers/UsersRouter.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 06918a9368..0a3b0e02e8 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -9,7 +9,7 @@ import Auth from '../Auth'; import passwordCrypto from '../password'; import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; import { promiseEnsureIdempotency } from '../middlewares'; -import * as otplib from 'otplib'; +import { authenticator } from 'otplib'; const crypto = require('crypto'); export class UsersRouter extends ClassesRouter { @@ -141,10 +141,7 @@ export class UsersRouter extends ClassesRouter { user._mfa, req.config.multiFactorAuth.encryptionKey ); - if ( - req.config.multiFactorAuth && - !otplib.authenticator.verify({ token, secret: mfaToken }) - ) { + if (req.config.multiFactorAuth && !authenticator.verify({ token, secret: mfaToken })) { throw new Parse.Error(212, 'Invalid MFA token'); } } @@ -381,8 +378,8 @@ export class UsersRouter extends ClassesRouter { if (!user) { throw new Parse.Error(101, 'Unauthorized'); } - const secret = otplib.authenticator.generateSecret(); - const otpauth = otplib.authenticator.keyuri(user.get('username'), req.config.appName, secret); + const secret = authenticator.generateSecret(); + const otpauth = authenticator.keyuri(user.get('username'), req.config.appName, secret); const storeKey = this.encryptMFAKey( `pending:${secret}`, req.config.multiFactorAuth.encryptionKey @@ -418,7 +415,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(214, 'MFA is already active'); } const secret = mfa.slice('pending:'.length); - const result = otplib.authenticator.verify({ token, secret }); + const result = authenticator.verify({ token, secret }); if (!result) { throw new Parse.Error(212, 'Invalid token'); } From e02d339baad70740b41827123565109aae86eacc Mon Sep 17 00:00:00 2001 From: dblythy Date: Wed, 28 Oct 2020 06:59:18 +1100 Subject: [PATCH 06/23] Check for existing MFA --- spec/ParseUser.MFA.spec.js | 52 ++++++++++++++++++++++++++++++++++++++ src/Routers/UsersRouter.js | 16 +++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index f0a80c0c27..138a7e2782 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -200,10 +200,62 @@ describe('MFA', () => { expect(user.get('MFAEnabled')).toBeUndefined(); expect(user.get('mfa')).toBeUndefined(); expect(user.get('_mfa')).toBeUndefined(); + }); + it('verify throws correct error', async () => { + await reconfigureServer({ + multiFactorAuth: { + enabled: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', + }, + }); + const user = await Parse.User.signUp('username', 'password'); + try { + await enableMFA(user); + await validateMFA(user); + } catch (e) { + expect(e.text).toBe('{"code":211,"error":"Please provide a token."}'); + } + try { + await validateMFA(user, 'tokenhere'); + } catch (e) { + expect(e.text).toBe('{"code":212,"error":"Invalid token"}'); + } + }); + it('can prevent re-enabling MFA', async () => { + await reconfigureServer({ + multiFactorAuth: { + enabled: 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 validateMFA(user, token); + try { + await enableMFA(user); + } catch (e) { + expect(e.text).toBe('{"code":214,"error":"MFA is already enabled on this account."}'); + } + }); + it('disabled MFA throws correct error', async () => { await reconfigureServer({ multiFactorAuth: { enabled: 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 validateMFA(user, 'tokenhere'); + } catch (e) { + expect(e.text).toBe('{"code":210,"error":"MFA is not enabled."}'); + } }); }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 0a3b0e02e8..b7a41d0bb5 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -372,19 +372,27 @@ export class UsersRouter extends ClassesRouter { async enableMFA(req) { const mfaenabled = req.config.multiFactorAuth || {}; if (!mfaenabled.enabled) { - throw new Parse.Error(-1, 'MFA is not enabled.'); + throw new Parse.Error(210, 'MFA is not enabled.'); + } + if (!req.auth) { + throw new Parse.Error(101, 'Unauthorized'); } - const { user } = req.auth; + const [user] = await req.config.database.find('_User', { + objectId: req.auth.user.id, + }); if (!user) { throw new Parse.Error(101, 'Unauthorized'); } + if (user._mfa) { + throw new Parse.Error(214, 'MFA is already enabled on this account.'); + } const secret = authenticator.generateSecret(); - const otpauth = authenticator.keyuri(user.get('username'), req.config.appName, secret); + 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.id }, { _mfa: storeKey }); + await req.config.database.update('_User', { objectId: user.objectId }, { _mfa: storeKey }); return { response: { qrcodeURL: otpauth, secret } }; } From 86f9196d9371be05052d123ef0f7fb1ab6c886d2 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 31 Oct 2020 19:21:07 +1100 Subject: [PATCH 07/23] Update UsersRouter.js --- src/Routers/UsersRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index b7a41d0bb5..e31f9e171d 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -141,7 +141,7 @@ export class UsersRouter extends ClassesRouter { user._mfa, req.config.multiFactorAuth.encryptionKey ); - if (req.config.multiFactorAuth && !authenticator.verify({ token, secret: mfaToken })) { + if (!authenticator.verify({ token, secret: mfaToken })) { throw new Parse.Error(212, 'Invalid MFA token'); } } From 8b10e20f192cf692b3d9f0b65d54fa997fa44c3f Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 31 Oct 2020 23:32:37 +1100 Subject: [PATCH 08/23] Recovery Keys --- spec/CloudCode.Validator.spec.js | 14 ++---- spec/ParseUser.MFA.spec.js | 36 ++++++++++++++- src/Adapters/Storage/Mongo/MongoTransform.js | 3 ++ .../Postgres/PostgresStorageAdapter.js | 1 + src/Controllers/DatabaseController.js | 3 ++ src/RestWrite.js | 2 + src/Routers/UsersRouter.js | 46 +++++++++++++++++-- 7 files changed, 89 insertions(+), 16 deletions(-) diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index d15bc2479d..c740696ea1 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -587,16 +587,10 @@ describe('cloud validator', () => { expect(obj.get('foo')).toBe('bar'); const query = new Parse.Query('beforeFind'); - try { - const first = await query.first({ useMasterKey: true }); - expect(first).toBeDefined(); - expect(first.id).toBe(obj.id); - done(); - } catch (e) { - console.log(e); - console.log(e.code); - throw e; - } + const first = await query.first({ useMasterKey: true }); + expect(first).toBeDefined(); + expect(first.id).toBe(obj.id); + done(); }); it('basic beforeDelete skipWithMasterKey', async function (done) { diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 138a7e2782..b49aeb4d51 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -33,11 +33,14 @@ describe('MFA', () => { }); } - function loginWithMFA(username, password, token) { + function loginWithMFA(username, password, token, recoveryTokens) { let req = `http://localhost:8378/1/login?username=${username}&password=${password}`; if (token) { req += `&token=${token}`; } + if (recoveryTokens) { + req += `&recoveryTokens=${recoveryTokens}`; + } return request({ method: 'POST', url: req, @@ -141,6 +144,7 @@ describe('MFA', () => { 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); @@ -151,6 +155,35 @@ describe('MFA', () => { }; await mfaLogin(); }); + + it('can get and recover MFA', async () => { + await reconfigureServer({ + multiFactorAuth: { + enabled: 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 validateMFA(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('cannot set _mfa or mfa', async () => { await reconfigureServer({ multiFactorAuth: { @@ -180,6 +213,7 @@ describe('MFA', () => { 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); diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 0cc34492b4..0b7e120516 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -1259,6 +1259,9 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { 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': diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index bf9cf29564..a35c6d6264 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -972,6 +972,7 @@ export class PostgresStorageAdapter implements StorageAdapter { 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 = []; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index a574f69f25..d9260427df 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -61,6 +61,7 @@ const specialQuerykeys = [ '_account_lockout_expires_at', '_failed_login_count', '_mfa', + '_mfa_recovery', ]; const isSpecialQueryKey = key => { @@ -224,6 +225,7 @@ const filterSensitiveData = ( delete object._password_changed_at; delete object._password_history; delete object._mfa; + delete object._mfa_recovery; if (aclGroup.indexOf(object.objectId) > -1) { return object; @@ -254,6 +256,7 @@ const specialKeysForUpdate = [ '_password_changed_at', '_password_history', '_mfa', + '_mfa_recovery', ]; const isSpecialUpdateKey = key => { diff --git a/src/RestWrite.js b/src/RestWrite.js index d556d77ba6..d215383e30 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -582,6 +582,8 @@ RestWrite.prototype.transformUser = function () { delete this.data.MFAEnabled; delete this.data.mfa; delete this.data._mfa; + delete this.data.mfa_recovery; + delete this.data._mfa_recovery; } // Do not cleanup session if objectId is not set diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index b7a41d0bb5..4d3143ee5a 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -11,6 +11,7 @@ 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() { @@ -48,7 +49,7 @@ export class UsersRouter extends ClassesRouter { ) { payload = req.query; } - const { username, email, password, token } = payload; + const { username, email, password, token, recoveryTokens } = payload; // TODO: use the right error codes / descriptions. if (!username && !email) { @@ -133,7 +134,35 @@ export class UsersRouter extends ClassesRouter { } } const mfaenabled = req.config.multiFactorAuth || {}; - if (mfaenabled.enabled && user._mfa) { + if (mfaenabled.enabled && recoveryTokens && user._mfa) { + const mfaRecTokens = user._mfa_recovery; + let firstAllowed = false; + let secondAllowed = false; + for (const recToken of mfaRecTokens) { + const setAllowedFromMatch = async (recoveryToken, first) => { + const doesMatch = await passwordCrypto.compare(recoveryToken, recToken); + if (!doesMatch) { + return; + } + if (first) { + firstAllowed = true; + } else { + secondAllowed = true; + } + }; + await setAllowedFromMatch(recoveryTokens.substring(0, 20), true); + await setAllowedFromMatch(recoveryTokens.substring(21, 41)); + } + if (!firstAllowed || !secondAllowed) { + throw new Parse.Error(212, '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.enabled && user._mfa) { if (!token) { throw new Parse.Error(211, 'Please provide your MFA token.'); } @@ -141,7 +170,7 @@ export class UsersRouter extends ClassesRouter { user._mfa, req.config.multiFactorAuth.encryptionKey ); - if (req.config.multiFactorAuth && !authenticator.verify({ token, secret: mfaToken })) { + if (!authenticator.verify({ token, secret: mfaToken })) { throw new Parse.Error(212, 'Invalid MFA token'); } } @@ -428,12 +457,18 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(212, 'Invalid 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: storeKey, MFAEnabled: true, _mfa_recovery: recoveryKeys } ); - return { response: {} }; + return { response: { recoveryKeys: [recoveryKeyOne, recoveryKeyTwo] } }; } _runAfterLogoutTrigger(req, session) { @@ -567,6 +602,7 @@ export class UsersRouter extends ClassesRouter { }); this.route('GET', '/users/me/enableMFA', req => this.enableMFA(req)); this.route('POST', '/users/me/verifyMFA', req => this.verifyMFA(req)); + this.route('GET', '/users/me/recoverMFA', req => this.handleLogIn(req)); this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }); From f4c808a624f8b813df5a689f0ee5da857e424d7a Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 31 Oct 2020 23:48:13 +1100 Subject: [PATCH 09/23] Update PostgresStorageAdapter.js --- src/Adapters/Storage/Postgres/PostgresStorageAdapter.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index a35c6d6264..9078a6f3b3 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1479,6 +1479,10 @@ export class PostgresStorageAdapter implements StorageAdapter { 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) { From 782677e77670f56472c7d8ccf16407c109596433 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sun, 1 Nov 2020 01:09:59 +1100 Subject: [PATCH 10/23] Fix failing tests --- spec/schemas.spec.js | 3 +++ src/Controllers/SchemaController.js | 1 + 2 files changed, 4 insertions(+) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 3ff661ab29..144c22d04d 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -106,6 +106,7 @@ const userSchema = { emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, MFAEnabled: { type: 'Boolean' }, + _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: defaultClassLevelPermissions, }; @@ -1290,6 +1291,7 @@ describe('schemas', () => { newField: { type: 'String' }, MFAEnabled: { type: 'Boolean' }, ACL: { type: 'ACL' }, + _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: { ...defaultClassLevelPermissions, @@ -1319,6 +1321,7 @@ describe('schemas', () => { MFAEnabled: { type: 'Boolean' }, newField: { type: 'String' }, ACL: { type: 'ACL' }, + _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: defaultClassLevelPermissions, }) diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 796fb8f460..36addb9b10 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -45,6 +45,7 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ emailVerified: { type: 'Boolean' }, MFAEnabled: { type: 'Boolean' }, authData: { type: 'Object' }, + _mfa_recovery: { type: 'Array' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) _Installation: { From 97818567553f6be9049cda694bb4f76c7e09e678 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 9 Nov 2020 11:14:46 +1100 Subject: [PATCH 11/23] Add Definitions --- resources/buildConfigDefinitions.js | 5 ++++- src/Options/Definitions.js | 28 +++++++++++++++++++--------- src/Options/docs.js | 7 +++++++ src/Options/index.js | 12 ++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index a640e1c3c7..b37c469bc4 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -55,6 +55,9 @@ function getENVPrefix(iface) { if (iface.id.name === 'IdempotencyOptions') { return 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_'; } + if (iface.id.name === 'MFAOptions') { + return 'PARSE_SERVER_MFA_'; + } } function processProperty(property, iface) { @@ -173,7 +176,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/src/Options/Definitions.js b/src/Options/Definitions.js index 7d054664be..ffe5b93a95 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -149,10 +149,6 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, - encryptionKey: { - env: 'PARSE_SERVER_ENCRYPTION_KEY', - help: 'Key for encrypting your files', - }, expireInactiveSessions: { env: 'PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS', help: 'Sets wether we should expire the inactive sessions, defaults to true', @@ -270,6 +266,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + multiFactorAuth: { + env: 'PARSE_SERVER_MFA', + help: 'Options for multi-factor authentication (2FA)', + action: parsers.objectParser, + default: {}, + }, objectIdSize: { env: 'PARSE_SERVER_OBJECT_ID_SIZE', help: "Sets the number of characters in generated object id's, default 10", @@ -382,11 +384,6 @@ module.exports.ParseServerOptions = { help: 'Starts the liveQuery server', action: parsers.booleanParser, }, - multiFactorAuth: { - env: 'PARSE_SERVER_TWO_FACTOR', - help: 'Enables two factor authentication.', - default: {}, - }, userSensitiveFields: { env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', help: @@ -550,3 +547,16 @@ module.exports.IdempotencyOptions = { default: 300, }, }; +module.exports.MFAOptions = { + enabled: { + env: 'PARSE_SERVER_MFA_ENABLED', + help: 'Whether MFA is enabled', + required: true, + action: parsers.booleanParser, + default: false, + }, + encryptionKey: { + env: 'PARSE_SERVER_MFA_ENCRYPTION_KEY', + help: 'A long, secure key used to encrypt MFA secrets.', + }, +}; diff --git a/src/Options/docs.js b/src/Options/docs.js index febe3d77cc..27ff3119c1 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -50,6 +50,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 (2FA) * @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10 * @property {Any} passwordPolicy Password policy for enforcing password related rules * @property {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground @@ -118,3 +119,9 @@ * @property {String[]} paths An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths. * @property {Number} ttl The duration in seconds after which a request record is discarded from the database, defaults to 300s. */ + +/** + * @interface MFAOptions + * @property {Boolean} enabled Whether MFA is enabled + * @property {String} encryptionKey A long, secure key used to encrypt MFA secrets. + */ diff --git a/src/Options/index.js b/src/Options/index.js index 1283f849a7..6d127be6d1 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -192,6 +192,10 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS :DEFAULT: false */ idempotencyOptions: ?IdempotencyOptions; + /* Options for multi-factor authentication (2FA) + :ENV: PARSE_SERVER_MFA + :DEFAULT: false */ + multiFactorAuth: ?MFAOptions; /* Full path to your GraphQL custom schema.graphql file */ graphQLSchema: ?string; /* Mounts the GraphQL endpoint @@ -285,3 +289,11 @@ export interface IdempotencyOptions { :DEFAULT: 300 */ ttl: ?number; } + +export interface MFAOptions { + /* Whether MFA is enabled + :DEFAULT: false */ + enabled: boolean; + /* A long, secure key used to encrypt MFA secrets.*/ + encryptionKey: ?string; +} From 817ef669a4dc2e1a66ef99c25f7f0e04010d45a4 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 13:50:25 +1100 Subject: [PATCH 12/23] change errors and increase coverage --- package.json | 2 +- resources/buildConfigDefinitions.js | 6 +- spec/ParseUser.MFA.spec.js | 109 ++++++++++++++++++++++------ src/Controllers/SchemaController.js | 2 +- src/Options/Definitions.js | 10 ++- src/Options/docs.js | 1 - src/Options/index.js | 3 +- src/RestWrite.js | 2 +- src/Routers/UsersRouter.js | 26 +++---- 9 files changed, 114 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 1778712d5c..1dd61df56c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "lru-cache": "5.1.1", "mime": "2.4.6", "mongodb": "3.6.2", - "otplib": "^12.0.1", + "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 1f6c6afc2c..5e045bf593 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -47,14 +47,12 @@ 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] } - if (iface.id.name === 'MFAOptions') { - return 'PARSE_SERVER_MFA_'; - } } function processProperty(property, iface) { diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index b49aeb4d51..36cf94e785 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -10,14 +10,14 @@ describe('MFA', () => { url: 'http://localhost:8378/1/users/me/enableMFA', json: true, headers: { - 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-Session-Token': user && user.getSessionToken(), 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', }, }); } - function validateMFA(user, token) { + function verifyMFA(user, token) { return request({ method: 'POST', url: 'http://localhost:8378/1/users/me/verifyMFA', @@ -25,7 +25,7 @@ describe('MFA', () => { token, }, headers: { - 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-Session-Token': user && user.getSessionToken(), 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', 'Content-Type': 'application/json', @@ -71,7 +71,7 @@ describe('MFA', () => { expect(qrcodeURL).toContain('digits'); expect(qrcodeURL).toContain('algorithm'); const token = otplib.authenticator.generate(secret); // this token would be generated from authenticator - await validateMFA(user, token); // this function would be user.validateMFA() + await verifyMFA(user, token); // this function would be user.verifyMFA() await Parse.User.logOut(); let verifytoken = ''; const mfaLogin = async () => { @@ -84,7 +84,7 @@ describe('MFA', () => { expect(newUser.objectId).toBe(user.id); expect(newUser.username).toBe('username'); expect(newUser.createdAt).toBe(user.createdAt.toISOString()); - expect(newUser.MFAEnabled).toBe(true); + expect(newUser.mfaEnabled).toBe(true); } catch (err) { expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); verifytoken = otplib.authenticator.generate(secret); @@ -108,13 +108,13 @@ describe('MFA', () => { data: { secret }, } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validateMFA(user, token); + 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":212,"error":"Invalid MFA token"}'); + expect(e.text).toBe('{"code":210,"error":"Invalid MFA token"}'); } }); @@ -130,7 +130,7 @@ describe('MFA', () => { data: { secret }, } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validateMFA(user, token); + await verifyMFA(user, token); await Parse.User.logOut(); let verifytoken = ''; const mfaLogin = async () => { @@ -144,7 +144,7 @@ describe('MFA', () => { expect(newUser.username).toBe('username'); expect(newUser.createdAt).toBe(user.createdAt.toISOString()); expect(newUser._mfa).toBeUndefined(); - expect(newUser.MFAEnabled).toBe(true); + expect(newUser.mfaEnabled).toBe(true); } catch (err) { expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); verifytoken = otplib.authenticator.generate(secret); @@ -170,7 +170,7 @@ describe('MFA', () => { const token = otplib.authenticator.generate(secret); const { data: { recoveryKeys }, - } = await validateMFA(user, token); + } = await verifyMFA(user, token); expect(recoveryKeys.length).toBe(2); expect(recoveryKeys[0].length).toBe(20); expect(recoveryKeys[1].length).toBe(20); @@ -181,7 +181,29 @@ describe('MFA', () => { expect(newUser.username).toBe('username'); expect(newUser.createdAt).toBe(user.createdAt.toISOString()); expect(newUser._mfa).toBeUndefined(); - expect(newUser.MFAEnabled).toBe(false); + expect(newUser.mfaEnabled).toBe(false); + }); + + it('returns error on invalid recovery', async () => { + await reconfigureServer({ + multiFactorAuth: { + enabled: 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, ['12345678910', '12345678910']); + 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 mfa', async () => { @@ -196,7 +218,7 @@ describe('MFA', () => { data: { secret }, } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validateMFA(user, token); + await verifyMFA(user, token); user.set('_mfa', 'foo'); user.set('mfa', 'foo'); await user.save(null, { sessionToken: user.getSessionToken() }); @@ -213,7 +235,7 @@ describe('MFA', () => { expect(newUser.username).toBe('username'); expect(newUser.createdAt).toBe(user.createdAt.toISOString()); expect(newUser._mfa).toBeUndefined(); - expect(newUser.MFAEnabled).toBe(true); + expect(newUser.mfaEnabled).toBe(true); } catch (err) { expect(err.text).toMatch('{"code":211,"error":"Please provide your MFA token."}'); verifytoken = otplib.authenticator.generate(secret); @@ -224,17 +246,60 @@ describe('MFA', () => { }; await mfaLogin(); }); + + it('cannot call enableMFA without user', async () => { + await reconfigureServer({ + multiFactorAuth: { + enabled: 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: { + enabled: 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('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('mfaEnabled')).toBeUndefined(); expect(user.get('mfa')).toBeUndefined(); expect(user.get('_mfa')).toBeUndefined(); }); + it('verify throws correct error', async () => { await reconfigureServer({ multiFactorAuth: { @@ -245,16 +310,17 @@ describe('MFA', () => { const user = await Parse.User.signUp('username', 'password'); try { await enableMFA(user); - await validateMFA(user); + await verifyMFA(user); } catch (e) { expect(e.text).toBe('{"code":211,"error":"Please provide a token."}'); } try { - await validateMFA(user, 'tokenhere'); + await verifyMFA(user, 'tokenhere'); } catch (e) { - expect(e.text).toBe('{"code":212,"error":"Invalid token"}'); + expect(e.text).toBe('{"code":210,"error":"Invalid token"}'); } }); + it('can prevent re-enabling MFA', async () => { await reconfigureServer({ multiFactorAuth: { @@ -267,13 +333,14 @@ describe('MFA', () => { data: { secret }, } = await enableMFA(user); const token = otplib.authenticator.generate(secret); - await validateMFA(user, token); + await verifyMFA(user, token); try { await enableMFA(user); } catch (e) { - expect(e.text).toBe('{"code":214,"error":"MFA is already enabled on this account."}'); + expect(e.text).toBe('{"code":210,"error":"MFA is already enabled on this account."}'); } }); + it('disabled MFA throws correct error', async () => { await reconfigureServer({ multiFactorAuth: { @@ -287,7 +354,7 @@ describe('MFA', () => { expect(e.text).toBe('{"code":210,"error":"MFA is not enabled."}'); } try { - await validateMFA(user, 'tokenhere'); + await verifyMFA(user, 'tokenhere'); } catch (e) { expect(e.text).toBe('{"code":210,"error":"MFA is not enabled."}'); } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 36addb9b10..5f08dc0d18 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -43,8 +43,8 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ password: { type: 'String' }, email: { type: 'String' }, emailVerified: { type: 'Boolean' }, - MFAEnabled: { type: 'Boolean' }, authData: { type: 'Object' }, + mfaEnabled: { type: 'Boolean' }, _mfa_recovery: { type: 'Array' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index adbf539afc..85a4439018 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, }, @@ -155,6 +156,10 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + encryptionKey: { + env: 'PARSE_SERVER_ENCRYPTION_KEY', + help: 'Key for encrypting your files', + }, expireInactiveSessions: { env: 'PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS', help: 'Sets wether we should expire the inactive sessions, defaults to true', @@ -276,7 +281,6 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_MFA', help: 'Options for multi-factor authentication (2FA)', action: parsers.objectParser, - default: {}, }, objectIdSize: { env: 'PARSE_SERVER_OBJECT_ID_SIZE', @@ -564,7 +568,7 @@ module.exports.MFAOptions = { encryptionKey: { env: 'PARSE_SERVER_MFA_ENCRYPTION_KEY', help: 'A long, secure key used to encrypt MFA secrets.', - } + }, }; module.exports.AccountLockoutOptions = { duration: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 623df6c133..8821de48a1 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -143,5 +143,4 @@ * @property {Number} resetTokenValidityDuration time for token to expire * @property {Function} validatorCallback a callback function to be invoked to validate the password * @property {String} validatorPattern a RegExp object or a regex string representing the pattern to enforce - */ diff --git a/src/Options/index.js b/src/Options/index.js index 858a30534e..0b7dd0cb28 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -199,8 +199,7 @@ export interface ParseServerOptions { :DEFAULT: false */ idempotencyOptions: ?IdempotencyOptions; /* Options for multi-factor authentication (2FA) - :ENV: PARSE_SERVER_MFA - :DEFAULT: false */ + :ENV: PARSE_SERVER_MFA */ multiFactorAuth: ?MFAOptions; /* Full path to your GraphQL custom schema.graphql file */ graphQLSchema: ?string; diff --git a/src/RestWrite.js b/src/RestWrite.js index b8eb55dab9..f596935f02 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -579,7 +579,7 @@ RestWrite.prototype.transformUser = function () { throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } if (!this.auth.isMaster) { - delete this.data.MFAEnabled; + delete this.data.mfaEnabled; delete this.data.mfa; delete this.data._mfa; delete this.data.mfa_recovery; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 942bead70a..d6e679622b 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -154,14 +154,14 @@ export class UsersRouter extends ClassesRouter { await setAllowedFromMatch(recoveryTokens.substring(21, 41)); } if (!firstAllowed || !secondAllowed) { - throw new Parse.Error(212, 'Invalid MFA recovery tokens'); + 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 } + { _mfa: null, mfaEnabled: false, _mfa_recovery: null } ); - user.MFAEnabled = false; + user.mfaEnabled = false; } else if (mfaenabled.enabled && user._mfa) { if (!token) { throw new Parse.Error(211, 'Please provide your MFA token.'); @@ -171,7 +171,7 @@ export class UsersRouter extends ClassesRouter { req.config.multiFactorAuth.encryptionKey ); if (!authenticator.verify({ token, secret: mfaToken })) { - throw new Parse.Error(212, 'Invalid MFA token'); + throw new Parse.Error(210, 'Invalid MFA token'); } } delete user._mfa; @@ -356,7 +356,7 @@ export class UsersRouter extends ClassesRouter { ]); return encryptedResult.toString('base64'); } catch (e) { - throw new Parse.Error(212, 'Invalid MFA token'); + throw new Parse.Error(210, 'Invalid MFA token'); } } async decryptMFAKey(mfa, encryptionKey) { @@ -395,7 +395,7 @@ export class UsersRouter extends ClassesRouter { decipher.end(); }); } catch (err) { - throw new Parse.Error(212, 'Invalid MFA token'); + throw new Parse.Error(210, 'Invalid MFA token'); } } async enableMFA(req) { @@ -403,7 +403,7 @@ export class UsersRouter extends ClassesRouter { if (!mfaenabled.enabled) { throw new Parse.Error(210, 'MFA is not enabled.'); } - if (!req.auth) { + if (!req.auth || !req.auth.user) { throw new Parse.Error(101, 'Unauthorized'); } const [user] = await req.config.database.find('_User', { @@ -413,7 +413,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(101, 'Unauthorized'); } if (user._mfa) { - throw new Parse.Error(214, 'MFA is already enabled on this account.'); + 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); @@ -430,7 +430,7 @@ export class UsersRouter extends ClassesRouter { if (!mfaenabled.enabled) { throw new Parse.Error(210, 'MFA is not enabled.'); } - if (!req.auth.user) { + if (!req.auth || !req.auth.user) { throw new Parse.Error(101, 'Unauthorized'); } const { token } = req.body; @@ -443,18 +443,18 @@ export class UsersRouter extends ClassesRouter { }); if (!user._mfa) { throw new Parse.Error( - 213, + 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(214, 'MFA is already active'); + 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(212, 'Invalid token'); + throw new Parse.Error(210, 'Invalid token'); } const storeKey = this.encryptMFAKey(`${secret}`, req.config.multiFactorAuth.encryptionKey); const recoveryKeyOne = randomString(20); @@ -466,7 +466,7 @@ export class UsersRouter extends ClassesRouter { await req.config.database.update( '_User', { username: user.username }, - { _mfa: storeKey, MFAEnabled: true, _mfa_recovery: recoveryKeys } + { _mfa: storeKey, mfaEnabled: true, _mfa_recovery: recoveryKeys } ); return { response: { recoveryKeys: [recoveryKeyOne, recoveryKeyTwo] } }; } From 77d8b21a6a5e5af96da29c2ebfc3cf9a9a4863f5 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 13:58:55 +1100 Subject: [PATCH 13/23] reorderMFA --- spec/schemas.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 144c22d04d..d853f54a82 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -105,7 +105,7 @@ const userSchema = { email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, - MFAEnabled: { type: 'Boolean' }, + mfaEnabled: { type: 'Boolean' }, _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: defaultClassLevelPermissions, @@ -1289,8 +1289,8 @@ describe('schemas', () => { emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, newField: { type: 'String' }, - MFAEnabled: { type: 'Boolean' }, ACL: { type: 'ACL' }, + mfaEnabled: { type: 'Boolean' }, _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: { @@ -1318,9 +1318,9 @@ describe('schemas', () => { email: { type: 'String' }, emailVerified: { type: 'Boolean' }, authData: { type: 'Object' }, - MFAEnabled: { type: 'Boolean' }, newField: { type: 'String' }, ACL: { type: 'ACL' }, + mfaEnabled: { type: 'Boolean' }, _mfa_recovery: { type: 'Array' }, }, classLevelPermissions: defaultClassLevelPermissions, From de00a686d200c81458ebb37bed67de67eb1495cb Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 15:05:51 +1100 Subject: [PATCH 14/23] more tests and renaming --- resources/buildConfigDefinitions.js | 4 +- spec/CloudCode.Validator.spec.js | 14 ++- spec/ParseUser.MFA.spec.js | 171 +++++++++++++++++----------- src/Config.js | 23 ++++ src/Options/Definitions.js | 12 +- src/Options/docs.js | 8 +- src/Options/index.js | 10 +- src/RestWrite.js | 2 - src/Routers/UsersRouter.js | 32 +++--- 9 files changed, 170 insertions(+), 106 deletions(-) diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 5e045bf593..c100dbd64a 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -48,7 +48,7 @@ function getENVPrefix(iface) { 'IdempotencyOptions' : 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', 'AccountLockoutOptions' : 'PARSE_SERVER_ACCOUNT_LOCKOUT_', 'PasswordPolicyOptions' : 'PARSE_SERVER_PASSWORD_POLICY_', - 'MFAOptions' : 'PARSE_SERVER_MFA_' + 'MfaOptions' : 'PARSE_SERVER_MFA_' } if (options[iface.id.name]) { return options[iface.id.name] @@ -171,7 +171,7 @@ function parseDefaultValue(elt, value, t) { }); literalValue = t.objectExpression(props); } - if (type == 'IdempotencyOptions' || type == 'MFAOptions') { + 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/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index c740696ea1..d15bc2479d 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -587,10 +587,16 @@ describe('cloud validator', () => { expect(obj.get('foo')).toBe('bar'); const query = new Parse.Query('beforeFind'); - const first = await query.first({ useMasterKey: true }); - expect(first).toBeDefined(); - expect(first.id).toBe(obj.id); - done(); + try { + const first = await query.first({ useMasterKey: true }); + expect(first).toBeDefined(); + expect(first.id).toBe(obj.id); + done(); + } catch (e) { + console.log(e); + console.log(e.code); + throw e; + } }); it('basic beforeDelete skipWithMasterKey', async function (done) { diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 36cf94e785..03775b776d 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -2,12 +2,13 @@ const request = require('../lib/request'); const otplib = require('otplib'); +const Config = require('../lib/Config'); describe('MFA', () => { - function enableMFA(user) { + function enableMfa(user) { return request({ method: 'GET', - url: 'http://localhost:8378/1/users/me/enableMFA', + url: 'http://localhost:8378/1/users/me/enableMfa', json: true, headers: { 'X-Parse-Session-Token': user && user.getSessionToken(), @@ -17,10 +18,10 @@ describe('MFA', () => { }); } - function verifyMFA(user, token) { + function verifyMfa(user, token) { return request({ method: 'POST', - url: 'http://localhost:8378/1/users/me/verifyMFA', + url: 'http://localhost:8378/1/users/me/verifyMfa', body: { token, }, @@ -55,14 +56,15 @@ describe('MFA', () => { it('should enable MFA tokens', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + 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 + } = 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'); @@ -71,7 +73,7 @@ describe('MFA', () => { 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 verifyMfa(user, token); // this function would be user.verifyMfa() await Parse.User.logOut(); let verifytoken = ''; const mfaLogin = async () => { @@ -100,15 +102,16 @@ describe('MFA', () => { it('can reject MFA', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, + encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); - await verifyMFA(user, token); + await verifyMfa(user, token); await Parse.User.logOut(); try { await loginWithMFA('username', 'password', '123102'); @@ -121,16 +124,16 @@ describe('MFA', () => { it('can encrypt MFA tokens', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); - await verifyMFA(user, token); + await verifyMfa(user, token); await Parse.User.logOut(); let verifytoken = ''; const mfaLogin = async () => { @@ -159,18 +162,18 @@ describe('MFA', () => { it('can get and recover MFA', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); const { data: { recoveryKeys }, - } = await verifyMFA(user, token); + } = await verifyMfa(user, token); expect(recoveryKeys.length).toBe(2); expect(recoveryKeys[0].length).toBe(20); expect(recoveryKeys[1].length).toBe(20); @@ -187,16 +190,16 @@ describe('MFA', () => { it('returns error on invalid recovery', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); - await verifyMFA(user, token); + await verifyMfa(user, token); await Parse.User.logOut(); try { await loginWithMFA('username', 'password', null, ['12345678910', '12345678910']); @@ -206,62 +209,45 @@ describe('MFA', () => { } }); - it('cannot set _mfa or mfa', async () => { + it('cannot set mfa or recovery token', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); - await verifyMFA(user, token); - user.set('_mfa', 'foo'); + await verifyMfa(user, token); user.set('mfa', 'foo'); + user.set('mfa_recovery', ['1234', '5678']); await user.save(null, { sessionToken: user.getSessionToken() }); - 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(); + 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 () => { + it('cannot call enableMfa without user', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); try { - await enableMFA(); + 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(); + await verifyMfa(); fail('should not be able to enable MFA without a user.'); } catch (err) { expect(err.text).toMatch('{"code":101,"error":"Unauthorized"}'); @@ -271,18 +257,18 @@ describe('MFA', () => { it('throws on second time enabling MFA', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); - await verifyMFA(user, token); + await verifyMfa(user, token); try { - await verifyMFA(user, token); + await verifyMfa(user, token); } catch (err) { expect(err.text).toMatch('{"code":210,"error":"MFA is already active"}'); } @@ -303,39 +289,46 @@ describe('MFA', () => { it('verify throws correct error', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); try { - await enableMFA(user); - await verifyMFA(user); + 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'); + await verifyMfa(user, 'tokenhere'); } catch (e) { - expect(e.text).toBe('{"code":210,"error":"Invalid token"}'); + expect(e.text).toBe('{"code":210,"error":"Invalid MFA token"}'); } }); it('can prevent re-enabling MFA', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: true, + enableMfa: true, encryptionKey: '89E4AFF1-DFE4-4603-9574-BFA16BB446FD', }, }); const user = await Parse.User.signUp('username', 'password'); const { data: { secret }, - } = await enableMFA(user); + } = await enableMfa(user); const token = otplib.authenticator.generate(secret); - await verifyMFA(user, token); + await verifyMfa(user, token); try { - await enableMFA(user); + await enableMfa(user); } catch (e) { expect(e.text).toBe('{"code":210,"error":"MFA is already enabled on this account."}'); } @@ -344,19 +337,63 @@ describe('MFA', () => { it('disabled MFA throws correct error', async () => { await reconfigureServer({ multiFactorAuth: { - enabled: false, + enableMfa: false, }, }); const user = await Parse.User.signUp('username', 'password'); try { - await enableMFA(user); + await enableMfa(user); } catch (e) { expect(e.text).toBe('{"code":210,"error":"MFA is not enabled."}'); } try { - await verifyMFA(user, 'tokenhere'); + 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/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/Options/Definitions.js b/src/Options/Definitions.js index 85a4439018..72210ce56c 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -557,17 +557,19 @@ module.exports.IdempotencyOptions = { default: 300, }, }; -module.exports.MFAOptions = { - enabled: { - env: 'PARSE_SERVER_MFA_ENABLED', - help: 'Whether MFA is enabled', +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 long, secure key used to encrypt MFA secrets.', + 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 = { diff --git a/src/Options/docs.js b/src/Options/docs.js index 8821de48a1..9d0c9af38e 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -52,7 +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 (2FA) + * @property {MfaOptions} multiFactorAuth Options for multi-factor authentication (2FA) * @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 @@ -123,9 +123,9 @@ */ /** - * @interface MFAOptions - * @property {Boolean} enabled Whether MFA is enabled - * @property {String} encryptionKey A long, secure key used to encrypt MFA secrets. + * @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. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index 0b7dd0cb28..1950084cc1 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -200,7 +200,7 @@ export interface ParseServerOptions { idempotencyOptions: ?IdempotencyOptions; /* Options for multi-factor authentication (2FA) :ENV: PARSE_SERVER_MFA */ - multiFactorAuth: ?MFAOptions; + multiFactorAuth: ?MfaOptions; /* Full path to your GraphQL custom schema.graphql file */ graphQLSchema: ?string; /* Mounts the GraphQL endpoint @@ -295,11 +295,11 @@ export interface IdempotencyOptions { ttl: ?number; } -export interface MFAOptions { - /* Whether MFA is enabled +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 */ - enabled: boolean; - /* A long, secure key used to encrypt MFA secrets.*/ + 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; } diff --git a/src/RestWrite.js b/src/RestWrite.js index f596935f02..81985ef922 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -581,9 +581,7 @@ RestWrite.prototype.transformUser = function () { if (!this.auth.isMaster) { delete this.data.mfaEnabled; delete this.data.mfa; - delete this.data._mfa; delete this.data.mfa_recovery; - delete this.data._mfa_recovery; } // Do not cleanup session if objectId is not set diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index d6e679622b..0c52b8595d 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -133,8 +133,8 @@ export class UsersRouter extends ClassesRouter { delete user.authData; } } - const mfaenabled = req.config.multiFactorAuth || {}; - if (mfaenabled.enabled && recoveryTokens && user._mfa) { + const mfaEnabled = req.config.multiFactorAuth || {}; + if (mfaEnabled.enableMfa && recoveryTokens && user._mfa) { const mfaRecTokens = user._mfa_recovery; let firstAllowed = false; let secondAllowed = false; @@ -162,7 +162,7 @@ export class UsersRouter extends ClassesRouter { { _mfa: null, mfaEnabled: false, _mfa_recovery: null } ); user.mfaEnabled = false; - } else if (mfaenabled.enabled && user._mfa) { + } else if (mfaEnabled.enableMfa && user._mfa) { if (!token) { throw new Parse.Error(211, 'Please provide your MFA token.'); } @@ -333,7 +333,7 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } - encryptMFAKey(mfa, encryptionKey) { + encryptMfaKey(mfa, encryptionKey) { try { if (!encryptionKey) { return mfa; @@ -345,9 +345,7 @@ export class UsersRouter extends ClassesRouter { .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(), @@ -398,9 +396,9 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(210, 'Invalid MFA token'); } } - async enableMFA(req) { - const mfaenabled = req.config.multiFactorAuth || {}; - if (!mfaenabled.enabled) { + 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) { @@ -417,7 +415,7 @@ export class UsersRouter extends ClassesRouter { } const secret = authenticator.generateSecret(); const otpauth = authenticator.keyuri(user.username, req.config.appName, secret); - const storeKey = this.encryptMFAKey( + const storeKey = this.encryptMfaKey( `pending:${secret}`, req.config.multiFactorAuth.encryptionKey ); @@ -425,9 +423,9 @@ export class UsersRouter extends ClassesRouter { return { response: { qrcodeURL: otpauth, secret } }; } - async verifyMFA(req) { - const mfaenabled = req.config.multiFactorAuth || {}; - if (!mfaenabled.enabled) { + 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) { @@ -454,9 +452,9 @@ export class UsersRouter extends ClassesRouter { const secret = mfa.slice('pending:'.length); const result = authenticator.verify({ token, secret }); if (!result) { - throw new Parse.Error(210, 'Invalid token'); + throw new Parse.Error(210, 'Invalid MFA token'); } - const storeKey = this.encryptMFAKey(`${secret}`, req.config.multiFactorAuth.encryptionKey); + const storeKey = this.encryptMfaKey(`${secret}`, req.config.multiFactorAuth.encryptionKey); const recoveryKeyOne = randomString(20); const recoveryKeyTwo = randomString(20); const recoveryKeys = await Promise.all([ @@ -601,8 +599,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('GET', '/users/me/enableMfa', req => this.enableMfa(req)); + this.route('POST', '/users/me/verifyMfa', req => this.verifyMfa(req)); this.route('GET', '/users/me/recoverMFA', req => this.handleLogIn(req)); this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); From 1c81ba87eb458d8b05f2ae5b06756d5d606e2196 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 15:17:48 +1100 Subject: [PATCH 15/23] Update UsersRouter.js --- src/Routers/UsersRouter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 0c52b8595d..9fcca37035 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -402,13 +402,13 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(210, 'MFA is not enabled.'); } if (!req.auth || !req.auth.user) { - throw new Parse.Error(101, 'Unauthorized'); + 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(101, 'Unauthorized'); + 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.'); @@ -429,7 +429,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(210, 'MFA is not enabled.'); } if (!req.auth || !req.auth.user) { - throw new Parse.Error(101, 'Unauthorized'); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Unauthorized'); } const { token } = req.body; if (!token) { From 5e53a4262d7f2e361386795eb5458048d5dbf911 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 15:32:57 +1100 Subject: [PATCH 16/23] Update UsersRouter.js --- src/Routers/UsersRouter.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 9fcca37035..ba62daef99 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -335,9 +335,6 @@ export class UsersRouter extends ClassesRouter { } encryptMfaKey(mfa, encryptionKey) { try { - if (!encryptionKey) { - return mfa; - } const algorithm = 'aes-256-gcm'; const encryption = crypto .createHash('sha256') @@ -359,9 +356,6 @@ export class UsersRouter extends ClassesRouter { } async decryptMFAKey(mfa, encryptionKey) { try { - if (encryptionKey == null) { - return mfa; - } const algorithm = 'aes-256-gcm'; const encryption = crypto .createHash('sha256') From c18519ce536772c52133fc3a83c0057cd0236b8a Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 16:00:17 +1100 Subject: [PATCH 17/23] log failing test --- spec/ParseUser.MFA.spec.js | 89 ++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 03775b776d..25a944fad2 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -54,49 +54,54 @@ describe('MFA', () => { } it('should enable MFA tokens', async () => { - 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(); + 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(); + }; + await mfaLogin(); + } catch (e) { + console.log(e); + throw e; + } }); it('can reject MFA', async () => { From 9113c99bee7b4a56a2af243bda85548dd009b01e Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 16:11:47 +1100 Subject: [PATCH 18/23] undo log error --- spec/ParseUser.MFA.spec.js | 89 ++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 25a944fad2..03775b776d 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -54,54 +54,49 @@ describe('MFA', () => { } 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 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.'; } - }; - await mfaLogin(); - } catch (e) { - console.log(e); - throw e; - } + 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(); }); it('can reject MFA', async () => { From 1c8306a402e73387cbc8fba9cc194fd2189f76f7 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 4 Dec 2020 16:27:01 +1100 Subject: [PATCH 19/23] Revert "undo log error" This reverts commit 9113c99bee7b4a56a2af243bda85548dd009b01e. --- spec/ParseUser.MFA.spec.js | 89 ++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 03775b776d..25a944fad2 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -54,49 +54,54 @@ describe('MFA', () => { } it('should enable MFA tokens', async () => { - 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(); + 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(); + }; + await mfaLogin(); + } catch (e) { + console.log(e); + throw e; + } }); it('can reject MFA', async () => { From f5dcc06765c3e385c8392f6995bd69f052addfe2 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 7 Dec 2020 02:57:40 +1100 Subject: [PATCH 20/23] consistent recoveryKeys naming --- spec/ParseUser.MFA.spec.js | 6 +++--- src/Routers/UsersRouter.js | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 25a944fad2..609c46d55d 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -34,13 +34,13 @@ describe('MFA', () => { }); } - function loginWithMFA(username, password, token, recoveryTokens) { + function loginWithMFA(username, password, token, recoveryKeys) { let req = `http://localhost:8378/1/login?username=${username}&password=${password}`; if (token) { req += `&token=${token}`; } - if (recoveryTokens) { - req += `&recoveryTokens=${recoveryTokens}`; + if (recoveryKeys) { + req += `&recoveryKeys=${recoveryKeys}`; } return request({ method: 'POST', diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index ba62daef99..adce055f0e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -49,7 +49,7 @@ export class UsersRouter extends ClassesRouter { ) { payload = req.query; } - const { username, email, password, token, recoveryTokens } = payload; + const { username, email, password, token, recoveryKeys } = payload; // TODO: use the right error codes / descriptions. if (!username && !email) { @@ -134,13 +134,13 @@ export class UsersRouter extends ClassesRouter { } } const mfaEnabled = req.config.multiFactorAuth || {}; - if (mfaEnabled.enableMfa && recoveryTokens && user._mfa) { + if (mfaEnabled.enableMfa && recoveryKeys && user._mfa) { const mfaRecTokens = user._mfa_recovery; let firstAllowed = false; let secondAllowed = false; for (const recToken of mfaRecTokens) { - const setAllowedFromMatch = async (recoveryToken, first) => { - const doesMatch = await passwordCrypto.compare(recoveryToken, recToken); + const setAllowedFromMatch = async (recoveryKey, first) => { + const doesMatch = await passwordCrypto.compare(recoveryKey, recToken); if (!doesMatch) { return; } @@ -150,8 +150,8 @@ export class UsersRouter extends ClassesRouter { secondAllowed = true; } }; - await setAllowedFromMatch(recoveryTokens.substring(0, 20), true); - await setAllowedFromMatch(recoveryTokens.substring(21, 41)); + await setAllowedFromMatch(recoveryKeys.substring(0, 20), true); + await setAllowedFromMatch(recoveryKeys.substring(21, 41)); } if (!firstAllowed || !secondAllowed) { throw new Parse.Error(210, 'Invalid MFA recovery tokens'); @@ -162,7 +162,7 @@ export class UsersRouter extends ClassesRouter { { _mfa: null, mfaEnabled: false, _mfa_recovery: null } ); user.mfaEnabled = false; - } else if (mfaEnabled.enableMfa && user._mfa) { + } else if (mfaEnabled.enableMfa && user.mfaEnabled) { if (!token) { throw new Parse.Error(211, 'Please provide your MFA token.'); } From cc499fd7a77acca4f5888dfec261dc720ba3082c Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 7 Dec 2020 03:15:17 +1100 Subject: [PATCH 21/23] stringify recovery keys --- spec/ParseUser.MFA.spec.js | 11 ++++++++++- src/Routers/UsersRouter.js | 10 +++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/spec/ParseUser.MFA.spec.js b/spec/ParseUser.MFA.spec.js index 609c46d55d..0eadab1a36 100644 --- a/spec/ParseUser.MFA.spec.js +++ b/spec/ParseUser.MFA.spec.js @@ -207,7 +207,16 @@ describe('MFA', () => { await verifyMfa(user, token); await Parse.User.logOut(); try { - await loginWithMFA('username', 'password', null, ['12345678910', '12345678910']); + 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"}'); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index adce055f0e..94099b8404 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -134,10 +134,14 @@ export class UsersRouter extends ClassesRouter { } } const mfaEnabled = req.config.multiFactorAuth || {}; - if (mfaEnabled.enableMfa && recoveryKeys && user._mfa) { + 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); @@ -150,8 +154,8 @@ export class UsersRouter extends ClassesRouter { secondAllowed = true; } }; - await setAllowedFromMatch(recoveryKeys.substring(0, 20), true); - await setAllowedFromMatch(recoveryKeys.substring(21, 41)); + 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'); From 580754c6f6868e15cc3575c93dfa16a58106deb3 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 7 Dec 2020 04:33:31 +1100 Subject: [PATCH 22/23] Update UsersRouter.js --- src/Routers/UsersRouter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 94099b8404..43c8294c8e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -599,7 +599,6 @@ export class UsersRouter extends ClassesRouter { }); this.route('GET', '/users/me/enableMfa', req => this.enableMfa(req)); this.route('POST', '/users/me/verifyMfa', req => this.verifyMfa(req)); - this.route('GET', '/users/me/recoverMFA', req => this.handleLogIn(req)); this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }); From 260aa24119099e3293a82cf90d68eb726324ef28 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 7 Dec 2020 10:50:10 +1100 Subject: [PATCH 23/23] change 2fa to mfa --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 72210ce56c..e083e03114 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -279,7 +279,7 @@ module.exports.ParseServerOptions = { }, multiFactorAuth: { env: 'PARSE_SERVER_MFA', - help: 'Options for multi-factor authentication (2FA)', + help: 'Options for multi-factor authentication (MFA)', action: parsers.objectParser, }, objectIdSize: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 9d0c9af38e..eb00e0174f 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -52,7 +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 (2FA) + * @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 diff --git a/src/Options/index.js b/src/Options/index.js index 1950084cc1..e179045a0d 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -198,7 +198,7 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS :DEFAULT: false */ idempotencyOptions: ?IdempotencyOptions; - /* Options for multi-factor authentication (2FA) + /* Options for multi-factor authentication (MFA) :ENV: PARSE_SERVER_MFA */ multiFactorAuth: ?MfaOptions; /* Full path to your GraphQL custom schema.graphql file */