diff --git a/spec/OAuth.spec.js b/spec/OAuth.spec.js index 47e4349d93..48f22aceb9 100644 --- a/spec/OAuth.spec.js +++ b/spec/OAuth.spec.js @@ -1,4 +1,4 @@ -var OAuth = require("../src/oauth/OAuth1Client"); +var OAuth = require("../src/authDataManager/OAuth1Client"); var request = require('request'); describe('OAuth', function() { @@ -138,7 +138,7 @@ describe('OAuth', function() { ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter"].map(function(providerName){ it("Should validate structure of "+providerName, (done) => { - var provider = require("../src/oauth/"+providerName); + var provider = require("../src/authDataManager/"+providerName); jequal(typeof provider.validateAuthData, "function"); jequal(typeof provider.validateAppId, "function"); jequal(provider.validateAuthData({}, {}).constructor, Promise.prototype.constructor); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index a74644ae90..1776ad56c1 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -904,6 +904,50 @@ describe('Parse.User testing', () => { } }; }; + + var getMockMyOauthProvider = function() { + return { + authData: { + id: "12345", + access_token: "12345", + expiration_date: new Date().toJSON(), + }, + shouldError: false, + loggedOut: false, + synchronizedUserId: null, + synchronizedAuthToken: null, + synchronizedExpiration: null, + + authenticate: function(options) { + if (this.shouldError) { + options.error(this, "An error occurred"); + } else if (this.shouldCancel) { + options.error(this, null); + } else { + options.success(this, this.authData); + } + }, + restoreAuthentication: function(authData) { + if (!authData) { + this.synchronizedUserId = null; + this.synchronizedAuthToken = null; + this.synchronizedExpiration = null; + return true; + } + this.synchronizedUserId = authData.id; + this.synchronizedAuthToken = authData.access_token; + this.synchronizedExpiration = authData.expiration_date; + return true; + }, + getAuthType: function() { + return "myoauth"; + }, + deauthenticate: function() { + this.loggedOut = true; + this.restoreAuthentication(null); + } + }; + }; var ExtendedUser = Parse.User.extend({ extended: function() { @@ -1284,6 +1328,151 @@ describe('Parse.User testing', () => { } }); }); + + it("link multiple providers", (done) => { + var provider = getMockFacebookProvider(); + var mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + ok(model.extended(), "Should have used the subclass."); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + Parse.User._registerAuthenticationProvider(mockProvider); + let objectId = model.id; + model._linkWith("myoauth", { + success: function(model) { + expect(model.id).toEqual(objectId); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + done(); + }, + error: function(error) { + console.error(error); + fail('SHould not fail'); + done(); + } + }) + }, + error: function(model, error) { + ok(false, "linking should have worked"); + done(); + } + }); + }); + + it("link multiple providers and update token", (done) => { + var provider = getMockFacebookProvider(); + var mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + ok(model.extended(), "Should have used the subclass."); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + Parse.User._registerAuthenticationProvider(mockProvider); + let objectId = model.id; + model._linkWith("myoauth", { + success: function(model) { + expect(model.id).toEqual(objectId); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + model._linkWith("facebook", { + success: () => { + ok(model._isLinked("facebook"), "User should be linked to facebook"); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + done(); + }, + error: () => { + fail('should link again'); + done(); + } + }) + }, + error: function(error) { + console.error(error); + fail('SHould not fail'); + done(); + } + }) + }, + error: function(model, error) { + ok(false, "linking should have worked"); + done(); + } + }); + }); + + it('should fail linking with existing', (done) => { + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + Parse.User.logOut().then(() => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('password'); + return user.signUp().then(() => { + // try to link here + user._linkWith('facebook', { + success: () => { + fail('should not succeed'); + done(); + }, + error: (err) => { + done(); + } + }); + }); + }); + } + }); + }); + + it('should have authData in beforeSave and afterSave', (done) => { + + Parse.Cloud.beforeSave('_User', (request, response) => { + let authData = request.object.get('authData'); + expect(authData).not.toBeUndefined(); + if (authData) { + expect(authData.facebook.id).toEqual('8675309'); + expect(authData.facebook.access_token).toEqual('jenny'); + } else { + fail('authData should be set'); + } + response.success(); + }); + + Parse.Cloud.afterSave('_User', (request, response) => { + let authData = request.object.get('authData'); + expect(authData).not.toBeUndefined(); + if (authData) { + expect(authData.facebook.id).toEqual('8675309'); + expect(authData.facebook.access_token).toEqual('jenny'); + } else { + fail('authData should be set'); + } + response.success(); + }); + + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); + Parse.Cloud._removeHook('Triggers', 'afterSave', Parse.User.className); + done(); + } + }); + }); it('set password then change password', (done) => { Parse.User.signUp('bob', 'barker').then((bob) => { diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index f9b94b379e..d07e18ed0e 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -148,7 +148,8 @@ describe('rest create', () => { }); it('handles no anonymous users config', (done) => { - var NoAnnonConfig = Object.assign({}, config, {enableAnonymousUsers: false}); + var NoAnnonConfig = Object.assign({}, config); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(false); var data1 = { authData: { anonymous: { @@ -162,6 +163,7 @@ describe('rest create', () => { }, (err) => { expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); expect(err.message).toEqual('This authentication method is unsupported.'); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(true); done(); }) }); diff --git a/spec/helper.js b/spec/helper.js index e2daa6ed25..6c7c94144b 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -5,8 +5,9 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; var cache = require('../src/cache').default; var DatabaseAdapter = require('../src/DatabaseAdapter'); var express = require('express'); -var facebook = require('../src/oauth/facebook'); +var facebook = require('../src/authDataManager/facebook'); var ParseServer = require('../src/index').ParseServer; +var path = require('path'); var databaseURI = process.env.DATABASE_URI; var cloudMain = process.env.CLOUD_CODE_MAIN || '../spec/cloud/main.js'; @@ -36,7 +37,7 @@ var defaultConfiguration = { oauth: { // Override the facebook provider facebook: mockFacebook(), myoauth: { - module: "../spec/myoauth" // relative path as it's run from src + module: path.resolve(__dirname, "myoauth") // relative path as it's run from src } } }; diff --git a/src/Config.js b/src/Config.js index 8042d6db4d..e80c3b2872 100644 --- a/src/Config.js +++ b/src/Config.js @@ -20,7 +20,6 @@ export class Config { this.restAPIKey = cacheInfo.restAPIKey; this.fileKey = cacheInfo.fileKey; this.facebookAppIds = cacheInfo.facebookAppIds; - this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); @@ -34,7 +33,7 @@ export class Config { this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; this.userController = cacheInfo.userController; - this.oauth = cacheInfo.oauth; + this.authDataManager = cacheInfo.authDataManager; this.customPages = cacheInfo.customPages || {}; this.mount = mount; } diff --git a/src/RestWrite.js b/src/RestWrite.js index 9e07c93a10..c2be50af47 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -9,7 +9,6 @@ var Auth = require('./Auth'); var Config = require('./Config'); var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); -var oauth = require("./oauth"); var Parse = require('parse/node'); var triggers = require('./triggers'); @@ -33,7 +32,7 @@ function RestWrite(config, auth, className, query, data, originalData) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + 'is an invalid field name.'); } - + // When the operation is complete, this.response may have several // fields. // response: the actual data to be returned @@ -211,170 +210,96 @@ RestWrite.prototype.validateAuthData = function() { } var authData = this.data.authData; - var anonData = this.data.authData.anonymous; - - if (this.config.enableAnonymousUsers === true && (anonData === null || - (anonData && anonData.id))) { - return this.handleAnonymousAuthData(); - } - - // Not anon, try other providers var providers = Object.keys(authData); - if (!anonData && providers.length == 1) { - var provider = providers[0]; - var providerAuthData = authData[provider]; - var hasToken = (providerAuthData && providerAuthData.id); - if (providerAuthData === null || hasToken) { - return this.handleOAuthAuthData(provider); + if (providers.length > 0) { + let canHandleAuthData = providers.reduce((canHandle, provider) => { + var providerAuthData = authData[provider]; + var hasToken = (providerAuthData && providerAuthData.id); + return canHandle && (hasToken || providerAuthData == null); + }, true); + if (canHandleAuthData) { + return this.handleAuthData(authData); } } throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.'); }; -RestWrite.prototype.handleAnonymousAuthData = function() { - var anonData = this.data.authData.anonymous; - if (anonData === null && this.query) { - // We are unlinking the user from the anonymous provider - this.data._auth_data_anonymous = null; - return; - } - - // Check if this user already exists - return this.config.database.find( - this.className, - {'authData.anonymous.id': anonData.id}, {}) - .then((results) => { - if (results.length > 0) { - if (!this.query) { - // We're signing up, but this user already exists. Short-circuit - delete results[0].password; - this.response = { - response: results[0], - location: this.location() - }; - return; - } - - // If this is a PUT for the same user, allow the linking - if (results[0].objectId === this.query.objectId) { - // Delete the rest format key before saving - delete this.data.authData; - return; - } - - // We're trying to create a duplicate account. Forbid it - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); +RestWrite.prototype.handleAuthDataValidation = function(authData) { + let validations = Object.keys(authData).map((provider) => { + if (authData[provider] === null) { + return Promise.resolve(); } - - // This anonymous user does not already exist, so transform it - // to a saveable format - this.data._auth_data_anonymous = anonData; - - // Delete the rest format key before saving - delete this.data.authData; - }) - -}; - -RestWrite.prototype.handleOAuthAuthData = function(provider) { - var authData = this.data.authData[provider]; - - if (authData === null && this.query) { - // We are unlinking from the provider. - this.data["_auth_data_" + provider ] = null; - return; - } - - var appIds; - var oauthOptions = this.config.oauth[provider]; - if (oauthOptions) { - appIds = oauthOptions.appIds; - } else if (provider == "facebook") { - appIds = this.config.facebookAppIds; - } - - var validateAuthData; - var validateAppId; - - - if (oauth[provider]) { - validateAuthData = oauth[provider].validateAuthData; - validateAppId = oauth[provider].validateAppId; - } - - // Try the configuration methods - if (oauthOptions) { - if (oauthOptions.module) { - validateAuthData = require(oauthOptions.module).validateAuthData; - validateAppId = require(oauthOptions.module).validateAppId; + let validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); + if (!validateAuthData) { + throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.'); }; + return validateAuthData(authData[provider]); + }); + return Promise.all(validations); +} - if (oauthOptions.validateAuthData) { - validateAuthData = oauthOptions.validateAuthData; +RestWrite.prototype.findUsersWithAuthData = function(authData) { + let providers = Object.keys(authData); + let query = providers.reduce((memo, provider) => { + if (!authData[provider]) { + return memo; } - if (oauthOptions.validateAppId) { - validateAppId = oauthOptions.validateAppId; - } - } - // try the custom provider first, fallback on the oauth implementation - - if (!validateAuthData || !validateAppId) { - return false; - }; - - return validateAuthData(authData, oauthOptions) - .then(() => { - if (appIds && typeof validateAppId === "function") { - return validateAppId(appIds, authData, oauthOptions); - } - - // No validation required by the developer - return Promise.resolve(); - - }).then(() => { - // Check if this user already exists - // TODO: does this handle re-linking correctly? - var query = {}; - query['authData.' + provider + '.id'] = authData.id; - return this.config.database.find( + let queryKey = `authData.${provider}.id`; + let query = {}; + query[queryKey] = authData[provider].id; + memo.push(query); + return memo; + }, []).filter((q) => { + return typeof q !== undefined; + }); + + let findPromise = Promise.resolve([]); + if (query.length > 0) { + findPromise = this.config.database.find( this.className, - query, {}); - }).then((results) => { - this.storage['authProvider'] = provider; - if (results.length > 0) { - if (!this.query) { - // We're signing up, but this user already exists. Short-circuit - delete results[0].password; - this.response = { - response: results[0], - location: this.location() - }; - this.data.objectId = results[0].objectId; - return; - } + {'$or': query}, {}) + } + + return findPromise; +} - // If this is a PUT for the same user, allow the linking - if (results[0].objectId === this.query.objectId) { - // Delete the rest format key before saving - delete this.data.authData; - return; - } - // We're trying to create a duplicate oauth auth. Forbid it - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, +RestWrite.prototype.handleAuthData = function(authData) { + let results; + return this.handleAuthDataValidation(authData).then(() => { + return this.findUsersWithAuthData(authData); + }).then((r) => { + results = r; + if (results.length > 1) { + // More than 1 user with the passed id's + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } else { - this.data.username = cryptoUtils.newToken(); + } + + this.storage['authProvider'] = Object.keys(authData).join(','); + + if (results.length == 0) { + this.data.username = cryptoUtils.newToken(); + } else if (!this.query) { + // Login with auth data + // Short circuit + delete results[0].password; + this.response = { + response: results[0], + location: this.location() + }; + this.data.objectId = results[0].objectId; + } else if (this.query && this.query.objectId) { + // Trying to update auth data but users + // are different + if (results[0].objectId !== this.query.objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used'); } - - // This FB auth does not already exist, so transform it to a - // saveable format - this.data["_auth_data_" + provider ] = authData; - - // Delete the rest format key before saving - delete this.data.authData; - }); + } + return Promise.resolve(); + }); } // The non-third-party parts of User transformation diff --git a/src/oauth/OAuth1Client.js b/src/authDataManager/OAuth1Client.js similarity index 100% rename from src/oauth/OAuth1Client.js rename to src/authDataManager/OAuth1Client.js diff --git a/src/oauth/facebook.js b/src/authDataManager/facebook.js similarity index 100% rename from src/oauth/facebook.js rename to src/authDataManager/facebook.js diff --git a/src/oauth/github.js b/src/authDataManager/github.js similarity index 100% rename from src/oauth/github.js rename to src/authDataManager/github.js diff --git a/src/oauth/google.js b/src/authDataManager/google.js similarity index 100% rename from src/oauth/google.js rename to src/authDataManager/google.js diff --git a/src/authDataManager/index.js b/src/authDataManager/index.js new file mode 100644 index 0000000000..77ee7473ea --- /dev/null +++ b/src/authDataManager/index.js @@ -0,0 +1,94 @@ +let facebook = require('./facebook'); +let instagram = require("./instagram"); +let linkedin = require("./linkedin"); +let meetup = require("./meetup"); +let google = require("./google"); +let github = require("./github"); +let twitter = require("./twitter"); + +let anonymous = { + validateAuthData: () => { + return Promise.resolve(); + }, + validateAppId: () => { + return Promise.resolve(); + } +} + +let providers = { + facebook, + instagram, + linkedin, + meetup, + google, + github, + twitter, + anonymous +} + +module.exports = function(oauthOptions = {}, enableAnonymousUsers = true) { + let _enableAnonymousUsers = enableAnonymousUsers; + let setEnableAnonymousUsers = function(enable) { + _enableAnonymousUsers = enable; + } + // To handle the test cases on configuration + let getValidatorForProvider = function(provider) { + + if (provider === 'anonymous' && !_enableAnonymousUsers) { + return; + } + + let defaultProvider = providers[provider]; + let optionalProvider = oauthOptions[provider]; + + if (!defaultProvider && !optionalProvider) { + return; + } + + let appIds; + if (optionalProvider) { + appIds = optionalProvider.appIds; + } + + var validateAuthData; + var validateAppId; + + if (defaultProvider) { + validateAuthData = defaultProvider.validateAuthData; + validateAppId = defaultProvider.validateAppId; + } + + // Try the configuration methods + if (optionalProvider) { + if (optionalProvider.module) { + validateAuthData = require(optionalProvider.module).validateAuthData; + validateAppId = require(optionalProvider.module).validateAppId; + }; + + if (optionalProvider.validateAuthData) { + validateAuthData = optionalProvider.validateAuthData; + } + if (optionalProvider.validateAppId) { + validateAppId = optionalProvider.validateAppId; + } + } + + if (!validateAuthData || !validateAppId) { + return; + } + + return function(authData) { + return validateAuthData(authData, optionalProvider).then(() => { + if (appIds) { + return validateAppId(appIds, authData, optionalProvider); + } + return Promise.resolve(); + }) + } + } + + return Object.freeze({ + getValidatorForProvider, + setEnableAnonymousUsers, + }) +} diff --git a/src/oauth/instagram.js b/src/authDataManager/instagram.js similarity index 100% rename from src/oauth/instagram.js rename to src/authDataManager/instagram.js diff --git a/src/oauth/linkedin.js b/src/authDataManager/linkedin.js similarity index 100% rename from src/oauth/linkedin.js rename to src/authDataManager/linkedin.js diff --git a/src/oauth/meetup.js b/src/authDataManager/meetup.js similarity index 100% rename from src/oauth/meetup.js rename to src/authDataManager/meetup.js diff --git a/src/oauth/twitter.js b/src/authDataManager/twitter.js similarity index 100% rename from src/oauth/twitter.js rename to src/authDataManager/twitter.js diff --git a/src/index.js b/src/index.js index 131f1f691a..d496d161c5 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,8 @@ var batch = require('./batch'), express = require('express'), middlewares = require('./middlewares'), multer = require('multer'), - Parse = require('parse/node').Parse; + Parse = require('parse/node').Parse, + authDataManager = require('./authDataManager'); //import passwordReset from './passwordReset'; import cache from './cache'; @@ -163,9 +164,8 @@ function ParseServer({ hooksController: hooksController, userController: userController, verifyUserEmails: verifyUserEmails, - enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, - oauth: oauth, + authDataManager: authDataManager(oauth, enableAnonymousUsers), appName: appName, publicServerURL: publicServerURL, customPages: customPages, diff --git a/src/oauth/index.js b/src/oauth/index.js deleted file mode 100644 index f39aea07cf..0000000000 --- a/src/oauth/index.js +++ /dev/null @@ -1,17 +0,0 @@ -var facebook = require('./facebook'); -var instagram = require("./instagram"); -var linkedin = require("./linkedin"); -var meetup = require("./meetup"); -var google = require("./google"); -var github = require("./github"); -var twitter = require("./twitter"); - -module.exports = { - facebook: facebook, - github: github, - google: google, - instagram: instagram, - linkedin: linkedin, - meetup: meetup, - twitter: twitter -} \ No newline at end of file diff --git a/src/transform.js b/src/transform.js index 738f245365..aae5cc2af7 100644 --- a/src/transform.js +++ b/src/transform.js @@ -87,7 +87,7 @@ export function transformKeyValue(schema, className, restKey, restValue, options return transformWhere(schema, className, s); }); return {key: '$and', value: mongoSubqueries}; - default: + default: // Other auth data var authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); if (authDataMatch) { @@ -203,6 +203,9 @@ function transformWhere(schema, className, restWhere) { // restCreate is the "create" clause in REST API form. // Returns the mongo form of the object. function transformCreate(schema, className, restCreate) { + if (className == '_User') { + restCreate = transformAuthData(restCreate); + } var mongoCreate = transformACL(restCreate); for (var restKey in restCreate) { var out = transformKeyValue(schema, className, restKey, restCreate[restKey]); @@ -218,6 +221,10 @@ function transformUpdate(schema, className, restUpdate) { if (!restUpdate) { throw 'got empty restUpdate'; } + if (className == '_User') { + restUpdate = transformAuthData(restUpdate); + } + var mongoUpdate = {}; var acl = transformACL(restUpdate); if (acl._rperm || acl._wperm) { @@ -250,6 +257,16 @@ function transformUpdate(schema, className, restUpdate) { return mongoUpdate; } +function transformAuthData(restObject) { + if (restObject.authData) { + Object.keys(restObject.authData).forEach((provider) => { + restObject[`_auth_data_${provider}`] = restObject.authData[provider]; + }); + delete restObject.authData; + } + return restObject; +} + // Transforms a REST API formatted ACL object to our two-field mongo format. // This mutates the restObject passed in to remove the ACL key. function transformACL(restObject) {