diff --git a/config.json.example b/config.json.example index c742eeef5f..617022ee8a 100644 --- a/config.json.example +++ b/config.json.example @@ -7,6 +7,8 @@ "FACEBOOK_ANALYTICS":"1234567890123456", "FACEBOOK_KEY":"123456789012345", "FACEBOOK_SECRET":"aaaabbbbccccddddeeeeffff00001111", + "GOOGLE_CLIENT_ID":"123456789012345", + "GOOGLE_CLIENT_SECRET":"aaaabbbbccccddddeeeeffff00001111", "NODE_DB_URI":"mongodb://localhost/habitrpg", "TEST_DB_URI":"mongodb://localhost/habitrpg_test", "NODE_ENV":"development", diff --git a/package.json b/package.json index 5d4b39ae9c..3808ab88b9 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "pageres": "^4.1.1", "passport": "~0.2.1", "passport-facebook": "2.0.0", + "passport-google-oauth20": "1.0.0", "paypal-ipn": "3.0.0", "paypal-rest-sdk": "^1.2.1", "pretty-data": "^0.40.0", diff --git a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js index cf1354b095..ae4fde82a9 100644 --- a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js +++ b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js @@ -5,36 +5,94 @@ import { describe('DELETE social registration', () => { let user; - let endpoint = '/user/auth/social/facebook'; + beforeEach(async () => { user = await generateUser(); - await user.update({ 'auth.facebook.id': 'some-fb-id' }); - expect(user.auth.local.username).to.not.be.empty; - expect(user.auth.facebook).to.not.be.empty; }); - context('of NOT-FACEBOOK', () => { + + context('NOT-SUPPORTED', () => { it('is not supported', async () => { await expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyFbSupported'), + code: 400, + error: 'BadRequest', + message: t('unsupportedNetwork'), }); }); }); - context('of facebook', () => { - it('fails if local registration does not exist for this user', async () => { - await user.update({ 'auth.local': { ok: true } }); - await expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({ + + context('Facebook', () => { + it('fails if user does not have an alternative registration method', async () => { + await user.update({ + 'auth.facebook.id': 'some-fb-id', + 'auth.local': { ok: true }, + }); + await expect(user.del('/user/auth/social/facebook')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('cantDetachFb'), + message: t('cantDetachSocial'), }); }); - it('succeeds', async () => { - let response = await user.del(endpoint); + + it('succeeds if user has a local registration', async () => { + await user.update({ + 'auth.facebook.id': 'some-fb-id', + }); + + let response = await user.del('/user/auth/social/facebook'); + expect(response).to.eql({}); + await user.sync(); + expect(user.auth.facebook).to.be.empty; + }); + + it('succeeds if user has a google registration', async () => { + await user.update({ + 'auth.facebook.id': 'some-fb-id', + 'auth.google.id': 'some-google-id', + 'auth.local': { ok: true }, + }); + + let response = await user.del('/user/auth/social/facebook'); expect(response).to.eql({}); await user.sync(); expect(user.auth.facebook).to.be.empty; }); }); + + context('Google', () => { + it('fails if user does not have an alternative registration method', async () => { + await user.update({ + 'auth.google.id': 'some-google-id', + 'auth.local': { ok: true }, + }); + await expect(user.del('/user/auth/social/google')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantDetachSocial'), + }); + }); + + it('succeeds if user has a local registration', async () => { + await user.update({ + 'auth.google.id': 'some-google-id', + }); + + let response = await user.del('/user/auth/social/google'); + expect(response).to.eql({}); + await user.sync(); + expect(user.auth.google).to.be.empty; + }); + + it('succeeds if user has a facebook registration', async () => { + await user.update({ + 'auth.google.id': 'some-google-id', + 'auth.facebook.id': 'some-facebook-id', + 'auth.local': { ok: true }, + }); + + let response = await user.del('/user/auth/social/google'); + expect(response).to.eql({}); + await user.sync(); + expect(user.auth.google).to.be.empty; + }); + }); }); diff --git a/test/api/v3/integration/user/auth/POST-user_auth_social.test.js b/test/api/v3/integration/user/auth/POST-user_auth_social.test.js index ff58ec86c0..a128034fb6 100644 --- a/test/api/v3/integration/user/auth/POST-user_auth_social.test.js +++ b/test/api/v3/integration/user/auth/POST-user_auth_social.test.js @@ -12,58 +12,132 @@ describe('POST /user/auth/social', () => { let endpoint = '/user/auth/social'; let randomAccessToken = '123456'; let facebookId = 'facebookId'; - let network = 'facebook'; + let googleId = 'googleId'; + let network = 'NoNetwork'; - before(async () => { + beforeEach(async () => { api = requester(); user = await generateUser(); - - let expectedResult = {id: facebookId}; - let passportFacebookProfile = sandbox.stub(passport._strategies.facebook, 'userProfile'); - passportFacebookProfile.yields(null, expectedResult); }); - it('fails if network is not facebook', async () => { + it('fails if network is not supported', async () => { await expect(api.post(endpoint, { authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase - network: 'NotFacebook', + network, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyFbSupported'), + code: 400, + error: 'BadRequest', + message: t('unsupportedNetwork'), }); }); - it('registers a new user', async () => { - let response = await api.post(endpoint, { - authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase - network, + describe('facebook', () => { + before(async () => { + let expectedResult = {id: facebookId}; + sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult); + network = 'facebook'; }); - expect(response.apiToken).to.exist; - expect(response.id).to.exist; - expect(response.newUser).to.be.true; + it('registers a new user', async () => { + let response = await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + expect(response.apiToken).to.exist; + expect(response.id).to.exist; + expect(response.newUser).to.be.true; + }); + + it('logs an existing user in', async () => { + let registerResponse = await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + let response = await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + expect(response.apiToken).to.eql(registerResponse.apiToken); + expect(response.id).to.eql(registerResponse.id); + expect(response.newUser).to.be.false; + }); + + it('add social auth to an existing user', async () => { + let response = await user.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + expect(response.apiToken).to.exist; + expect(response.id).to.exist; + expect(response.newUser).to.be.false; + }); + + it('enrolls a new user in an A/B test', async () => { + await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string'); + }); }); - it('enrolls a new user in an A/B test', async () => { - await api.post(endpoint, { - authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase - network, + describe('google', () => { + before(async () => { + let expectedResult = {id: googleId}; + sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult); + network = 'google'; }); - await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string'); - }); + it('registers a new user', async () => { + let response = await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); - it('logs an existing user in', async () => { - await user.update({ 'auth.facebook.id': facebookId }); - - let response = await api.post(endpoint, { - authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase - network, + expect(response.apiToken).to.exist; + expect(response.id).to.exist; + expect(response.newUser).to.be.true; }); - expect(response.apiToken).to.eql(user.apiToken); - expect(response.id).to.eql(user._id); - expect(response.newUser).to.be.false; + it('logs an existing user in', async () => { + let registerResponse = await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + let response = await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + expect(response.apiToken).to.eql(registerResponse.apiToken); + expect(response.id).to.eql(registerResponse.id); + expect(response.newUser).to.be.false; + }); + + it('add social auth to an existing user', async () => { + let response = await user.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + expect(response.apiToken).to.exist; + expect(response.id).to.exist; + expect(response.newUser).to.be.false; + }); + + it('enrolls a new user in an A/B test', async () => { + await api.post(endpoint, { + authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase + network, + }); + + await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string'); + }); }); }); diff --git a/test/client-old/spec/controllers/authCtrlSpec.js b/test/client-old/spec/controllers/authCtrlSpec.js index 75ea898fca..f10b732c2d 100644 --- a/test/client-old/spec/controllers/authCtrlSpec.js +++ b/test/client-old/spec/controllers/authCtrlSpec.js @@ -1,12 +1,16 @@ 'use strict'; describe('Auth Controller', function() { - var scope, ctrl, user, $httpBackend, $window, $modal; + var scope, ctrl, user, $httpBackend, $window, $modal, alert, Auth; beforeEach(function(){ module(function($provide) { + Auth = { + runAuth: sandbox.spy(), + }; $provide.value('Analytics', analyticsMock); $provide.value('Chat', { seenMessage: function() {} }); + $provide.value('Auth', Auth); }); inject(function(_$httpBackend_, $rootScope, $controller, _$modal_) { @@ -17,27 +21,27 @@ describe('Auth Controller', function() { $window = { location: { href: ""}, alert: sandbox.spy() }; $modal = _$modal_; user = { user: {}, authenticate: sandbox.spy() }; + alert = { authErrorAlert: sandbox.spy() }; - ctrl = $controller('AuthCtrl', {$scope: scope, $window: $window, User: user}); + ctrl = $controller('AuthCtrl', {$scope: scope, $window: $window, User: user, Alert: alert}); }) }); describe('logging in', function() { - it('should log in users with correct uname / pass', function() { $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond({data: {id: 'abc', apiToken: 'abc'}}); scope.auth(); $httpBackend.flush(); - expect(user.authenticate).to.be.calledOnce; - expect($window.alert).to.not.be.called; + expect(Auth.runAuth).to.be.calledOnce; + expect(alert.authErrorAlert).to.not.be.called; }); it('should not log in users with incorrect uname / pass', function() { $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond(404, ''); scope.auth(); $httpBackend.flush(); - expect(user.authenticate).to.not.be.called; - expect($window.alert).to.be.calledOnce; + expect(Auth.runAuth).to.not.be.called; + expect(alert.authErrorAlert).to.be.calledOnce; }); }); diff --git a/test/client-old/spec/controllers/footerCtrlSpec.js b/test/client-old/spec/controllers/footerCtrlSpec.js index ce5245d099..e141e6e1b3 100644 --- a/test/client-old/spec/controllers/footerCtrlSpec.js +++ b/test/client-old/spec/controllers/footerCtrlSpec.js @@ -13,7 +13,7 @@ describe('Footer Controller', function() { user: user }; scope = $rootScope.$new(); - $controller('FooterCtrl', {$scope: scope, User: User}); + $controller('FooterCtrl', {$scope: scope, User: User, Social: {}}); })); context('Debug mode', function() { diff --git a/website/client-old/js/controllers/authCtrl.js b/website/client-old/js/controllers/authCtrl.js index 4e5b7c65b4..6fee0bcf3d 100644 --- a/website/client-old/js/controllers/authCtrl.js +++ b/website/client-old/js/controllers/authCtrl.js @@ -5,8 +5,8 @@ */ angular.module('habitrpg') - .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrl', '$modal', 'Analytics', - function($scope, $rootScope, User, $http, $location, $window, ApiUrl, $modal, Analytics) { + .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrl', '$modal', 'Alert', 'Analytics', 'Auth', + function($scope, $rootScope, User, $http, $location, $window, ApiUrl, $modal, Alert, Analytics, Auth) { $scope.Analytics = Analytics; $scope.logout = function() { @@ -14,30 +14,6 @@ angular.module('habitrpg') $window.location.href = '/logout'; }; - var runAuth = function(id, token) { - User.authenticate(id, token, function(err) { - if(!err) $scope.registrationInProgress = false; - Analytics.login(); - Analytics.updateUser(); - $window.location.href = ('/' + window.location.hash); - }); - }; - - function errorAlert(data, status, headers, config) { - $scope.registrationInProgress = false; - if (status === 0) { - $window.alert(window.env.t('noReachServer')); - } else if (status === 400 && data.errors && _.isArray(data.errors)) { // bad requests - data.errors.forEach(function (err) { - $window.alert(err.message); - }); - } else if (!!data && !!data.error) { - $window.alert(data.message); - } else { - $window.alert(window.env.t('errorUpCase') + ' ' + status); - } - }; - $scope.registrationInProgress = false; $scope.register = function() { @@ -60,9 +36,12 @@ angular.module('habitrpg') } $http.post(url, scope.registerVals).success(function(res, status, headers, config) { - runAuth(res.data._id, res.data.apiToken); + Auth.runAuth(res.data._id, res.data.apiToken); Analytics.register(); - }).error(errorAlert); + }).error(function(data, status, headers, config) { + $scope.registrationInProgress = false; + Alert.authErrorAlert(data, status, headers, config) + }); }; $scope.auth = function() { @@ -73,8 +52,8 @@ angular.module('habitrpg') //@TODO: Move all the $http methods to a service $http.post(ApiUrl.get() + "/api/v3/user/auth/local/login", data) .success(function(res, status, headers, config) { - runAuth(res.data.id, res.data.apiToken); - }).error(errorAlert); + Auth.runAuth(res.data.id, res.data.apiToken); + }).error(Alert.authErrorAlert); }; $scope.playButtonClick = function() { @@ -113,8 +92,8 @@ angular.module('habitrpg') hello(network).login({scope:'email'}).then(function(auth){ $http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth) .success(function(res, status, headers, config) { - runAuth(res.data.id, res.data.apiToken); - }).error(errorAlert); + Auth.runAuth(res.data.id, res.data.apiToken); + }).error(Alert.authErrorAlert); }, function( e ){ alert("Signin error: " + e.message ); }); diff --git a/website/client-old/js/controllers/settingsCtrl.js b/website/client-old/js/controllers/settingsCtrl.js index dd77da046a..06eef0f561 100644 --- a/website/client-old/js/controllers/settingsCtrl.js +++ b/website/client-old/js/controllers/settingsCtrl.js @@ -2,14 +2,17 @@ // Make user and settings available for everyone through root scope. habitrpg.controller('SettingsCtrl', - ['$scope', 'User', '$rootScope', '$http', 'ApiUrl', 'Guide', '$location', '$timeout', 'Content', 'Notification', 'Shared', '$compile', - function($scope, User, $rootScope, $http, ApiUrl, Guide, $location, $timeout, Content, Notification, Shared, $compile) { + ['$scope', 'User', '$rootScope', '$http', 'ApiUrl', 'Guide', '$location', '$timeout', 'Content', 'Notification', 'Shared', 'Social', '$compile', + function($scope, User, $rootScope, $http, ApiUrl, Guide, $location, $timeout, Content, Notification, Shared, Social, $compile) { var RELEASE_ANIMAL_TYPES = { pets: 'releasePets', mounts: 'releaseMounts', both: 'releaseBoth', }; + var SOCIAL_AUTH_NETWORKS = Shared.constants.SUPPORTED_SOCIAL_NETWORKS; + $scope.SOCIAL_AUTH_NETWORKS = SOCIAL_AUTH_NETWORKS; + // FIXME we have this re-declared everywhere, figure which is the canonical version and delete the rest // $scope.auth = function (id, token) { // User.authenticate(id, token, function (err) { @@ -287,6 +290,40 @@ habitrpg.controller('SettingsCtrl', return Math.floor(numberOfHourglasses); }; + $scope.hasBackupAuthOption = function(user, checkedNetworkKey) { + if (user.auth.local.username) { + return true; + } + return _.find(SOCIAL_AUTH_NETWORKS, function (network) { + if (network.key !== checkedNetworkKey) { + if (user.auth.hasOwnProperty(network.key)) { + return user.auth[network.key].id; + } + } + }); + }; + + $scope.hasSocialAuth = function (user) { + return _.find(SOCIAL_AUTH_NETWORKS, function (network) { + if (user.auth.hasOwnProperty(network.key)) { + return user.auth[network.key].id; + } + }); + }; + + $scope.deleteSocialAuth = function (networkKey) { + var network = _.find(SOCIAL_AUTH_NETWORKS, function (network) { + return network.key === networkKey; + }); + + $http.delete(ApiUrl.get() + "/api/v3/user/auth/social/"+networkKey).success(function(){ + Notification.text(env.t("detachedSocial", {network: network.name})); + User.sync(); + }); + }; + + $scope.socialLogin = Social.socialLogin; + function _calculateNextCron() { $scope.dayStart; diff --git a/website/client-old/js/services/alertServices.js b/website/client-old/js/services/alertServices.js new file mode 100644 index 0000000000..5674982673 --- /dev/null +++ b/website/client-old/js/services/alertServices.js @@ -0,0 +1,32 @@ +'use strict'; + +(function(){ + angular + .module('habitrpg') + .factory('Alert', alertFactory); + + alertFactory.$inject = [ + '$window' + ]; + + function alertFactory($window) { + + function authErrorAlert(data, status, headers, config) { + if (status === 0) { + $window.alert(window.env.t('noReachServer')); + } else if (status === 400 && data.errors && _.isArray(data.errors)) { // bad requests + data.errors.forEach(function (err) { + $window.alert(err.message); + }); + } else if (!!data && !!data.error) { + $window.alert(data.message); + } else { + $window.alert(window.env.t('errorUpCase') + ' ' + status); + } + }; + + return { + authErrorAlert: authErrorAlert, + } + } +}()); diff --git a/website/client-old/js/services/authServices.js b/website/client-old/js/services/authServices.js new file mode 100644 index 0000000000..926146d2b2 --- /dev/null +++ b/website/client-old/js/services/authServices.js @@ -0,0 +1,28 @@ +'use strict'; + +(function(){ + angular + .module('habitrpg') + .factory('Auth', authFactory); + + authFactory.$inject = [ + '$window', + 'User', + 'Analytics' + ]; + + function authFactory($window, User, Analytics) { + + var runAuth = function(id, token) { + User.authenticate(id, token, function(err) { + Analytics.login(); + Analytics.updateUser(); + $window.location.href = ('/' + window.location.hash); + }); + }; + + return { + runAuth: runAuth, + } + } +}()); diff --git a/website/client-old/js/services/socialServices.js b/website/client-old/js/services/socialServices.js index 23ac719118..3d77d25ab2 100644 --- a/website/client-old/js/services/socialServices.js +++ b/website/client-old/js/services/socialServices.js @@ -5,9 +5,11 @@ .module('habitrpg') .factory('Social', socialFactory); - socialFactory.$inject = []; + socialFactory.$inject = [ + '$http','ApiUrl', 'Alert', 'Auth' + ]; - function socialFactory() { + function socialFactory($http, ApiUrl, Alert, Auth) { function loadWidgets() { // Facebook @@ -34,8 +36,27 @@ } } + hello.init({ + facebook : window.env.FACEBOOK_KEY, + google : window.env.GOOGLE_CLIENT_ID + }); + + function socialLogin(network){ + hello(network).login({scope:['email']}).then(function(auth){ + $http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth) + .success(function(res, status, headers, config) { + Auth.runAuth(res.data.id, res.data.apiToken); + }).error(Alert.authErrorAlert); + }, function( err ){ + alert("Signin error: " + err.message ); + }); + }; + + + return { - loadWidgets: loadWidgets + loadWidgets: loadWidgets, + socialLogin: socialLogin } } }()); diff --git a/website/client-old/manifest.json b/website/client-old/manifest.json index a3de35cc09..e536820fa6 100644 --- a/website/client-old/manifest.json +++ b/website/client-old/manifest.json @@ -42,6 +42,8 @@ "js/config.js", "js/services/sharedServices.js", + "js/services/alertServices.js", + "js/services/authServices.js", "js/services/notificationServices.js", "js/directives/directives.js", "js/services/analyticsServices.js", @@ -134,7 +136,9 @@ "js/env.js", "js/static.js", + "js/services/alertServices.js", "js/services/analyticsServices.js", + "js/services/authServices.js", "js/services/notificationServices.js", "js/services/userNotificationsService.js", "js/services/userServices.js", @@ -172,7 +176,9 @@ "js/env.js", "js/static.js", + "js/services/alertServices.js", "js/services/analyticsServices.js", + "js/services/authServices.js", "js/services/notificationServices.js", "js/services/sharedServices.js", "js/services/socialServices.js", diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index cedaf00739..5489bfafa5 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -95,7 +95,8 @@ "leadText": "Habitica is a free habit building and productivity app that treats your real life like a game. With in-game rewards and punishments to motivate you and a strong social network to inspire you, Habitica can help you achieve your goals to become healthy, hard-working, and happy.", "login": "Login", "loginAndReg": "Login / Register", - "loginFacebookAlt": "Login / Register with Facebook", + "loginFacebookAlt": "Sign in with Facebook", + "loginGoogleAlt": "Sign in with Google", "logout": "Log Out", "marketing1Header": "Improve Your Habits By Playing A Game", "marketing1Lead1": "Habitica is a video game to help you improve real life habits. It \"gamifies\" your life by turning all your tasks (habits, dailies, and to-dos) into little monsters you have to conquer. The better you are at this, the more you progress in the game. If you slip up in life, your character starts backsliding in the game.", @@ -258,8 +259,8 @@ "invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".", "invalidCredentials": "There is no account that uses those credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your User ID \"<%= userId %>\" for assistance.", - "onlyFbSupported": "Only Facebook is supported currently.", - "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", + "unsupportedNetwork": "This network is not currently supported.", + "cantDetachSocial": "Account lacks another authentication method; can't detach this authentication method.", "onlySocialAttachLocal": "Local authentication can be added to only a social account.", "invalidReqParams": "Invalid request parameters.", "memberIdRequired": "\"member\" must be a valid UUID.", diff --git a/website/common/locales/en/settings.json b/website/common/locales/en/settings.json index e2b942c2ad..936321a764 100644 --- a/website/common/locales/en/settings.json +++ b/website/common/locales/en/settings.json @@ -91,8 +91,8 @@ "passwordSuccess": "Password successfully changed", "usernameSuccess": "Login Name successfully changed", "emailSuccess": "Email successfully changed", - "detachFacebook": "De-register Facebook", - "detachedFacebook": "Successfully removed Facebook from your account", + "detachSocial": "De-register <%= network %>", + "detachedSocial": "Successfully removed <%= network %> authentication from your account", "addedLocalAuth": "Successfully added local authentication", "data": "Data", "exportData": "Export Data", @@ -101,7 +101,8 @@ "emailChange3": " including both your old and new email address as well as your User ID.", "usernameOrEmail": "Login Name or Email", "email": "Email", - "registeredWithFb": "Registered with Facebook", + "registerWithSocial": "Register with <%= network %>", + "registeredWithSocial": "Registered with <%= network %>", "loginNameDescription1": "This is what you use to login to Habitica. Go to ", "loginNameDescription2": "User->Profile", "loginNameDescription3": "to change the name that appears in your avatar and chat messages.", diff --git a/website/common/script/constants.js b/website/common/script/constants.js index 9be5756caa..f58cc7f8fb 100644 --- a/website/common/script/constants.js +++ b/website/common/script/constants.js @@ -5,3 +5,8 @@ export const ATTRIBUTES = ['str', 'int', 'per', 'con']; export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; export const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = 5000; + +export const SUPPORTED_SOCIAL_NETWORKS = [ + {key: 'facebook', name: 'Facebook'}, + {key: 'google', name: 'Google'}, +]; diff --git a/website/common/script/index.js b/website/common/script/index.js index b79fee6bab..7eec246080 100644 --- a/website/common/script/index.js +++ b/website/common/script/index.js @@ -23,10 +23,12 @@ import { MAX_STAT_POINTS, TAVERN_ID, LARGE_GROUP_COUNT_MESSAGE_CUTOFF, + SUPPORTED_SOCIAL_NETWORKS, } from './constants'; api.constants = { LARGE_GROUP_COUNT_MESSAGE_CUTOFF, + SUPPORTED_SOCIAL_NETWORKS, }; // TODO Move these under api.constants api.maxLevel = MAX_LEVEL; diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index f093a6c9d8..2698accd9e 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -10,7 +10,6 @@ import { BadRequest, NotFound, } from '../../libs/errors'; -import Bluebird from 'bluebird'; import * as passwordUtils from '../../libs/password'; import logger from '../../libs/logger'; import { model as User } from '../../models/user'; @@ -20,6 +19,7 @@ import { sendTxn as sendTxnEmail } from '../../libs/email'; import { decrypt } from '../../libs/encryption'; import { send as sendEmail } from '../../libs/email'; import pusher from '../../libs/pusher'; +import common from '../../../common'; let api = {}; @@ -50,6 +50,18 @@ async function _handleGroupInvitation (user, invite) { } } +function hasBackupAuth (user, networkToRemove) { + if (user.auth.local.username) { + return true; + } + + let hasAlternateNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS.find((network) => { + return network.key !== networkToRemove && user.auth[network.key].id; + }); + + return hasAlternateNetwork; +} + /** * @api {post} /api/v3/user/auth/local/register Register * @apiDescription Register a new user with email, username and password or attach local auth to a social user @@ -69,7 +81,7 @@ api.registerLocal = { middlewares: [authWithHeaders(true)], url: '/user/auth/local/register', async handler (req, res) { - let fbUser = res.locals.user; // If adding local auth to social user + let existingUser = res.locals.user; // If adding local auth to social user req.checkBody({ email: { @@ -121,10 +133,15 @@ api.registerLocal = { }, }; - if (fbUser) { - if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); - fbUser.auth.local = newUser.auth.local; - newUser = fbUser; + if (existingUser) { + let hasSocialAuth = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(network => { + if (existingUser.auth.hasOwnProperty(network.key)) { + return existingUser.auth[network.key].id; + } + }); + if (!hasSocialAuth) throw new NotAuthorized(res.t('onlySocialAttachLocal')); + existingUser.auth.local = newUser.auth.local; + newUser = existingUser; } else { newUser = new User(newUser); newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used @@ -137,7 +154,7 @@ api.registerLocal = { let savedUser = await newUser.save(); - if (savedUser.auth.facebook.id) { + if (existingUser) { res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields } else { res.respond(201, savedUser); @@ -148,7 +165,7 @@ api.registerLocal = { .remove({email: savedUser.auth.local.email}) .then(() => sendTxnEmail(savedUser, 'welcome')); - if (!savedUser.auth.facebook.id) { + if (!existingUser) { res.analytics.track('register', { category: 'acquisition', type: 'local', @@ -228,9 +245,9 @@ api.loginLocal = { }, }; -function _passportFbProfile (accessToken) { - return new Bluebird((resolve, reject) => { - passport._strategies.facebook.userProfile(accessToken, (err, profile) => { +function _passportProfile (network, accessToken) { + return new Promise((resolve, reject) => { + passport._strategies[network].userProfile(accessToken, (err, profile) => { if (err) { reject(err); } else { @@ -243,14 +260,19 @@ function _passportFbProfile (accessToken) { // Called as a callback by Facebook (or other social providers). Internal route api.loginSocial = { method: 'POST', + middlewares: [authWithHeaders(true)], url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 async handler (req, res) { + let existingUser = res.locals.user; let accessToken = req.body.authResponse.access_token; let network = req.body.network; - if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); + let isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(supportedNetwork => { + return supportedNetwork.key === network; + }); + if (!isSupportedNetwork) throw new BadRequest(res.t('unsupportedNetwork')); - let profile = await _passportFbProfile(accessToken); + let profile = await _passportProfile(network, accessToken); let user = await User.findOne({ [`auth.${network}.id`]: profile.id, @@ -260,37 +282,47 @@ api.loginSocial = { if (user) { _loginRes(user, ...arguments); } else { // Create new user - user = new User({ + user = { auth: { [network]: profile, }, preferences: { language: req.language, }, - }); - user.registeredThrough = req.headers['x-client']; + }; + if (existingUser) { + existingUser.auth[network] = user.auth[network]; + user = existingUser; + } else { + user = new User(user); + user.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used + } let savedUser = await user.save(); - user.newUser = true; + if (!existingUser) { + user.newUser = true; + } _loginRes(user, ...arguments); // Clean previous email preferences - if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { + if (savedUser.auth[network].emails && savedUser.auth[network].emails[0] && savedUser.auth[network].emails[0].value) { EmailUnsubscription .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) .exec() .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks } - res.analytics.track('register', { - category: 'acquisition', - type: network, - gaLabel: network, - uuid: savedUser._id, - headers: req.headers, - user: savedUser, - }); + if (!existingUser) { + res.analytics.track('register', { + category: 'acquisition', + type: network, + gaLabel: network, + uuid: savedUser._id, + headers: req.headers, + user: savedUser, + }); + } return null; } @@ -576,11 +608,15 @@ api.deleteSocial = { async handler (req, res) { let user = res.locals.user; let network = req.params.network; - - if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb')); - - await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec(); + let isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(supportedNetwork => { + return supportedNetwork.key === network; + }); + if (!isSupportedNetwork) throw new BadRequest(res.t('unsupportedNetwork')); + if (!hasBackupAuth(user, network)) throw new NotAuthorized(res.t('cantDetachSocial')); + let unset = { + [`auth.${network}`]: 1, + }; + await User.update({_id: user._id}, {$unset: unset}).exec(); res.respond(200, {}); }, diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 09f23cbd6f..fd174b538b 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -607,6 +607,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) { let userToContact = await User.findOne({$or: [ {'auth.local.email': invite.email}, {'auth.facebook.emails.value': invite.email}, + {'auth.google.emails.value': invite.email}, ]}) .select({_id: true, 'preferences.emailNotifications': true}) .exec(); diff --git a/website/server/libs/email.js b/website/server/libs/email.js index 1e1fc79383..39e0256071 100644 --- a/website/server/libs/email.js +++ b/website/server/libs/email.js @@ -4,6 +4,7 @@ import { TAVERN_ID } from '../models/group'; import { encrypt } from './encryption'; import request from 'request'; import logger from './logger'; +import common from '../../common'; const IS_PROD = nconf.get('IS_PROD'); const EMAIL_SERVER = { @@ -47,8 +48,12 @@ export function getUserInfo (user, fields = []) { if (fields.indexOf('email') !== -1) { if (user.auth.local && user.auth.local.email) { info.email = user.auth.local.email; - } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) { - info.email = user.auth.facebook.emails[0].value; + } else { + common.constants.SUPPORTED_SOCIAL_NETWORKS.forEach(network => { + if (user.auth[network.key] && user.auth[network.key].emails && user.auth[network.key].emails[0] && user.auth[network.key].emails[0].value) { + info.email = user.auth[network.key].emails[0].value; + } + }); } } diff --git a/website/server/libs/setupPassport.js b/website/server/libs/setupPassport.js index dd9fdeaaa2..4c0861a1f3 100644 --- a/website/server/libs/setupPassport.js +++ b/website/server/libs/setupPassport.js @@ -1,8 +1,7 @@ import passport from 'passport'; import nconf from 'nconf'; -import passportFacebook from 'passport-facebook'; - -const FacebookStrategy = passportFacebook.Strategy; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; // Passport session setup. // To support persistent login sessions, Passport needs to be able to @@ -22,3 +21,8 @@ passport.use(new FacebookStrategy({ clientSecret: nconf.get('FACEBOOK_SECRET'), // callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" }, (accessToken, refreshToken, profile, done) => done(null, profile))); + +passport.use(new GoogleStrategy({ + clientID: nconf.get('GOOGLE_CLIENT_ID'), + clientSecret: nconf.get('GOOGLE_CLIENT_SECRET'), +}, (accessToken, refreshToken, profile, done) => done(null, profile))); diff --git a/website/server/middlewares/locals.js b/website/server/middlewares/locals.js index f32370b2ac..31e2af78d0 100644 --- a/website/server/middlewares/locals.js +++ b/website/server/middlewares/locals.js @@ -12,7 +12,7 @@ import { mods } from '../models/user'; // To avoid stringifying more data then we need, // items from `env` used on the client will have to be specified in this array const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', - 'FACEBOOK_KEY', 'FACEBOOK_ANALYTICS', 'NODE_ENV', 'BASE_URL', 'GA_ID', + 'FACEBOOK_KEY', 'GOOGLE_CLIENT_ID', 'FACEBOOK_ANALYTICS', 'NODE_ENV', 'BASE_URL', 'GA_ID', 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY', 'worldDmg', 'mods', 'IS_MOBILE', 'PUSHER:KEY', 'PUSHER:ENABLED']; @@ -30,7 +30,7 @@ let env = { }, }; -'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_ANALYTICS FACEBOOK_KEY AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED' +'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_ANALYTICS FACEBOOK_KEY GOOGLE_CLIENT_ID AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED' .split(' ') .forEach(key => { env[key] = nconf.get(key); diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js index a0e31bdc60..cec8d214df 100644 --- a/website/server/models/user/hooks.js +++ b/website/server/models/user/hooks.js @@ -103,14 +103,29 @@ function _setUpNewUser (user) { return _populateDefaultTasks(user, taskTypes); } +function _getFacebookName (fb) { + if (!fb) { + return; + } + let possibleName = fb.displayName || fb.name || fb.username; + + if (possibleName) { + return possibleName; + } + + if (fb.first_name && fb.last_name) { + return `${fb.first_name} ${fb.last_name}`; + } +} + function _setProfileName (user) { - let fb = user.auth.facebook; + let google = user.auth.google; let localUsername = user.auth.local && user.auth.local.username; - let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`); + let googleUsername = google && google.displayName; let anonymous = 'Anonymous'; - return localUsername || facebookUsername || anonymous; + return localUsername || _getFacebookName(user.auth.facebook) || googleUsername || anonymous; } schema.pre('save', true, function preSaveUser (next, done) { diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index a9ddcdfdda..00767a93c5 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -24,6 +24,9 @@ let schema = new Schema({ facebook: {type: Schema.Types.Mixed, default: () => { return {}; }}, + google: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, local: { email: { type: String, diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index 08876ba3ed..a04918f8f4 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -131,9 +131,12 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') .panel-heading span=env.t('registration') .panel-body - div(ng-if='user.auth.facebook.id') - button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb') - button.btn.btn-danger(ng-click='http("delete", "/api/v3/user/auth/social/facebook", null, "detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') + div + ul.list-inline + li(ng-repeat='network in SOCIAL_AUTH_NETWORKS') + button.btn.btn-primary(ng-if='!user.auth[network.key].id', ng-click='socialLogin(network.key, user)')=env.t('registerWithSocial', {network: '{{network.name}}'}) + button.btn.btn-primary(disabled='disabled', ng-if='!hasBackupAuthOption(user, network.key) && user.auth[network.key].id')=env.t('registeredWithSocial', {network: '{{network.name}}'}) + button.btn.btn-danger(ng-click='deleteSocialAuth(network.key)', ng-if='hasBackupAuthOption(user, network.key) && user.auth[network.key].id')=env.t('detachSocial', {network: '{{network.name}}'}) hr div(ng-if='!user.auth.local.username') p=env.t('addLocalAuth') diff --git a/website/views/static/login-modal.jade b/website/views/static/login-modal.jade index 766bfc11f4..b7fffda88b 100644 --- a/website/views/static/login-modal.jade +++ b/website/views/static/login-modal.jade @@ -26,14 +26,11 @@ script(id='modals/login.html', type='text/ng-template') button.close(type='button', ng-click='$close()') × h4.modal-title=env.t('loginAndReg') .modal-body(ng-controller='AuthCtrl') - a.zocial.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")')=env.t('loginFacebookAlt') - //-ul.list-inline + ul.list-inline li - a.zocial.icon.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")') - li - a.zocial.icon.googleplus(alt="Google", ng-click='socialLogin("google")') Google+ - li - a.zocial.icon.twitter(alt="Twitter", ng-click='socialLogin("twitter")') Twitter + a.zocial.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")')=env.t('loginFacebookAlt') + // li + // a.zocial.google(alt="Google", ng-click='socialLogin("google")')=env.t('loginGoogleAlt') hr tabset(justified='true') tab(heading=env.t('login'))