Merge pull request #952 from ParsePlatform/flovilmart.OAuthImprovements

AuthData logic refactor
This commit is contained in:
Florent Vilmart
2016-03-11 15:38:19 -05:00
18 changed files with 391 additions and 181 deletions

View File

@@ -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);

View File

@@ -906,6 +906,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() {
return true;
@@ -1286,6 +1330,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) => {
bob.setPassword('meower');

View File

@@ -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();
})
});

View File

@@ -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
}
}
};

View File

@@ -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;
}

View File

@@ -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');
@@ -211,169 +210,95 @@ 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];
if (providers.length > 0) {
let canHandleAuthData = providers.reduce((canHandle, provider) => {
var providerAuthData = authData[provider];
var hasToken = (providerAuthData && providerAuthData.id);
if (providerAuthData === null || hasToken) {
return this.handleOAuthAuthData(provider);
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;
RestWrite.prototype.handleAuthDataValidation = function(authData) {
let validations = Object.keys(authData).map((provider) => {
if (authData[provider] === null) {
return Promise.resolve();
}
// 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()
let validateAuthData = this.config.authDataManager.getValidatorForProvider(provider);
if (!validateAuthData) {
throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE,
'This authentication method is unsupported.');
};
return;
return validateAuthData(authData[provider]);
});
return Promise.all(validations);
}
RestWrite.prototype.findUsersWithAuthData = function(authData) {
let providers = Object.keys(authData);
let query = providers.reduce((memo, provider) => {
if (!authData[provider]) {
return memo;
}
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,
{'$or': query}, {})
}
// 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;
}
return findPromise;
}
// We're trying to create a duplicate account. Forbid it
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');
}
// This anonymous user does not already exist, so transform it
// to a saveable format
this.data._auth_data_anonymous = anonData;
this.storage['authProvider'] = Object.keys(authData).join(',');
// 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;
};
if (oauthOptions.validateAuthData) {
validateAuthData = oauthOptions.validateAuthData;
}
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(
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
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;
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 oauth auth. Forbid it
} 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');
} else {
this.data.username = cryptoUtils.newToken();
}
// 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();
});
}

View File

@@ -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,
})
}

View File

@@ -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';
@@ -168,9 +169,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,

View File

@@ -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
}

View File

@@ -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) {