Add Google Signin (#7969)

* Start adding google login

* fix local js issue

* implement syntax suggestions

* fix delete social tests

* Add service for authentication alerts

* fix social login tests

* make suggested google sign in changes

* fix accidentally deleted code

* refactor social network sign in

* fix incorrect find

* implement suggested google sign in changes

* fix(tests): Inject fake Auth module for auth controller

* fix(test): prevent social service from causing page reload

* fix loading user info

* Use lodash's implimentation of find for IE compatibility

* chore: increase test coverage around deletion route

* chore: clean up social auth test

* chore: Fix social login tests

* remove profile from login scope

* fix(api): Allow social accounts to deregister as user has auth backup

* temporarily disable google login button
This commit is contained in:
Phillip Thelen
2016-09-28 12:11:10 +02:00
committed by Matteo Pagliazzi
parent 941000d737
commit e3b484b29a
25 changed files with 465 additions and 150 deletions

View File

@@ -7,6 +7,8 @@
"FACEBOOK_ANALYTICS":"1234567890123456", "FACEBOOK_ANALYTICS":"1234567890123456",
"FACEBOOK_KEY":"123456789012345", "FACEBOOK_KEY":"123456789012345",
"FACEBOOK_SECRET":"aaaabbbbccccddddeeeeffff00001111", "FACEBOOK_SECRET":"aaaabbbbccccddddeeeeffff00001111",
"GOOGLE_CLIENT_ID":"123456789012345",
"GOOGLE_CLIENT_SECRET":"aaaabbbbccccddddeeeeffff00001111",
"NODE_DB_URI":"mongodb://localhost/habitrpg", "NODE_DB_URI":"mongodb://localhost/habitrpg",
"TEST_DB_URI":"mongodb://localhost/habitrpg_test", "TEST_DB_URI":"mongodb://localhost/habitrpg_test",
"NODE_ENV":"development", "NODE_ENV":"development",

View File

@@ -85,6 +85,7 @@
"pageres": "^4.1.1", "pageres": "^4.1.1",
"passport": "~0.2.1", "passport": "~0.2.1",
"passport-facebook": "2.0.0", "passport-facebook": "2.0.0",
"passport-google-oauth20": "1.0.0",
"paypal-ipn": "3.0.0", "paypal-ipn": "3.0.0",
"paypal-rest-sdk": "^1.2.1", "paypal-rest-sdk": "^1.2.1",
"pretty-data": "^0.40.0", "pretty-data": "^0.40.0",

View File

@@ -5,36 +5,94 @@ import {
describe('DELETE social registration', () => { describe('DELETE social registration', () => {
let user; let user;
let endpoint = '/user/auth/social/facebook';
beforeEach(async () => { beforeEach(async () => {
user = await generateUser(); 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 () => { it('is not supported', async () => {
await expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({ await expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('unsupportedNetwork'),
});
});
});
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, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('onlyFbSupported'), message: t('cantDetachSocial'),
}); });
}); });
it('succeeds if user has a local registration', async () => {
await user.update({
'auth.facebook.id': 'some-fb-id',
}); });
context('of facebook', () => {
it('fails if local registration does not exist for this user', async () => { let response = await user.del('/user/auth/social/facebook');
await user.update({ 'auth.local': { ok: true } }); expect(response).to.eql({});
await expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({ await user.sync();
code: 401, expect(user.auth.facebook).to.be.empty;
error: 'NotAuthorized',
message: t('cantDetachFb'),
}); });
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 },
}); });
it('succeeds', async () => {
let response = await user.del(endpoint); let response = await user.del('/user/auth/social/facebook');
expect(response).to.eql({}); expect(response).to.eql({});
await user.sync(); await user.sync();
expect(user.auth.facebook).to.be.empty; 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;
});
});
}); });

View File

@@ -12,28 +12,32 @@ describe('POST /user/auth/social', () => {
let endpoint = '/user/auth/social'; let endpoint = '/user/auth/social';
let randomAccessToken = '123456'; let randomAccessToken = '123456';
let facebookId = 'facebookId'; let facebookId = 'facebookId';
let network = 'facebook'; let googleId = 'googleId';
let network = 'NoNetwork';
before(async () => { beforeEach(async () => {
api = requester(); api = requester();
user = await generateUser(); 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, { await expect(api.post(endpoint, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
network: 'NotFacebook', network,
})).to.eventually.be.rejected.and.eql({ })).to.eventually.be.rejected.and.eql({
code: 401, code: 400,
error: 'NotAuthorized', error: 'BadRequest',
message: t('onlyFbSupported'), message: t('unsupportedNetwork'),
}); });
}); });
describe('facebook', () => {
before(async () => {
let expectedResult = {id: facebookId};
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
network = 'facebook';
});
it('registers a new user', async () => { it('registers a new user', async () => {
let response = await api.post(endpoint, { let response = await api.post(endpoint, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
@@ -45,6 +49,33 @@ describe('POST /user/auth/social', () => {
expect(response.newUser).to.be.true; 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 () => { it('enrolls a new user in an A/B test', async () => {
await api.post(endpoint, { await api.post(endpoint, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
@@ -53,17 +84,60 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string'); await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string');
}); });
});
describe('google', () => {
before(async () => {
let expectedResult = {id: googleId};
sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult);
network = 'google';
});
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 () => { it('logs an existing user in', async () => {
await user.update({ 'auth.facebook.id': facebookId }); let registerResponse = await api.post(endpoint, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
network,
});
let response = await api.post(endpoint, { let response = await api.post(endpoint, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
network, network,
}); });
expect(response.apiToken).to.eql(user.apiToken); expect(response.apiToken).to.eql(registerResponse.apiToken);
expect(response.id).to.eql(user._id); expect(response.id).to.eql(registerResponse.id);
expect(response.newUser).to.be.false; 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');
});
});
}); });

View File

@@ -1,12 +1,16 @@
'use strict'; 'use strict';
describe('Auth Controller', function() { describe('Auth Controller', function() {
var scope, ctrl, user, $httpBackend, $window, $modal; var scope, ctrl, user, $httpBackend, $window, $modal, alert, Auth;
beforeEach(function(){ beforeEach(function(){
module(function($provide) { module(function($provide) {
Auth = {
runAuth: sandbox.spy(),
};
$provide.value('Analytics', analyticsMock); $provide.value('Analytics', analyticsMock);
$provide.value('Chat', { seenMessage: function() {} }); $provide.value('Chat', { seenMessage: function() {} });
$provide.value('Auth', Auth);
}); });
inject(function(_$httpBackend_, $rootScope, $controller, _$modal_) { inject(function(_$httpBackend_, $rootScope, $controller, _$modal_) {
@@ -17,27 +21,27 @@ describe('Auth Controller', function() {
$window = { location: { href: ""}, alert: sandbox.spy() }; $window = { location: { href: ""}, alert: sandbox.spy() };
$modal = _$modal_; $modal = _$modal_;
user = { user: {}, authenticate: sandbox.spy() }; 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() { describe('logging in', function() {
it('should log in users with correct uname / pass', 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'}}); $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond({data: {id: 'abc', apiToken: 'abc'}});
scope.auth(); scope.auth();
$httpBackend.flush(); $httpBackend.flush();
expect(user.authenticate).to.be.calledOnce; expect(Auth.runAuth).to.be.calledOnce;
expect($window.alert).to.not.be.called; expect(alert.authErrorAlert).to.not.be.called;
}); });
it('should not log in users with incorrect uname / pass', function() { it('should not log in users with incorrect uname / pass', function() {
$httpBackend.expectPOST('/api/v3/user/auth/local/login').respond(404, ''); $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond(404, '');
scope.auth(); scope.auth();
$httpBackend.flush(); $httpBackend.flush();
expect(user.authenticate).to.not.be.called; expect(Auth.runAuth).to.not.be.called;
expect($window.alert).to.be.calledOnce; expect(alert.authErrorAlert).to.be.calledOnce;
}); });
}); });

View File

@@ -13,7 +13,7 @@ describe('Footer Controller', function() {
user: user user: user
}; };
scope = $rootScope.$new(); scope = $rootScope.$new();
$controller('FooterCtrl', {$scope: scope, User: User}); $controller('FooterCtrl', {$scope: scope, User: User, Social: {}});
})); }));
context('Debug mode', function() { context('Debug mode', function() {

View File

@@ -5,8 +5,8 @@
*/ */
angular.module('habitrpg') angular.module('habitrpg')
.controller("AuthCtrl", ['$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, Analytics) { function($scope, $rootScope, User, $http, $location, $window, ApiUrl, $modal, Alert, Analytics, Auth) {
$scope.Analytics = Analytics; $scope.Analytics = Analytics;
$scope.logout = function() { $scope.logout = function() {
@@ -14,30 +14,6 @@ angular.module('habitrpg')
$window.location.href = '/logout'; $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.registrationInProgress = false;
$scope.register = function() { $scope.register = function() {
@@ -60,9 +36,12 @@ angular.module('habitrpg')
} }
$http.post(url, scope.registerVals).success(function(res, status, headers, config) { $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(); Analytics.register();
}).error(errorAlert); }).error(function(data, status, headers, config) {
$scope.registrationInProgress = false;
Alert.authErrorAlert(data, status, headers, config)
});
}; };
$scope.auth = function() { $scope.auth = function() {
@@ -73,8 +52,8 @@ angular.module('habitrpg')
//@TODO: Move all the $http methods to a service //@TODO: Move all the $http methods to a service
$http.post(ApiUrl.get() + "/api/v3/user/auth/local/login", data) $http.post(ApiUrl.get() + "/api/v3/user/auth/local/login", data)
.success(function(res, status, headers, config) { .success(function(res, status, headers, config) {
runAuth(res.data.id, res.data.apiToken); Auth.runAuth(res.data.id, res.data.apiToken);
}).error(errorAlert); }).error(Alert.authErrorAlert);
}; };
$scope.playButtonClick = function() { $scope.playButtonClick = function() {
@@ -113,8 +92,8 @@ angular.module('habitrpg')
hello(network).login({scope:'email'}).then(function(auth){ hello(network).login({scope:'email'}).then(function(auth){
$http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth) $http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth)
.success(function(res, status, headers, config) { .success(function(res, status, headers, config) {
runAuth(res.data.id, res.data.apiToken); Auth.runAuth(res.data.id, res.data.apiToken);
}).error(errorAlert); }).error(Alert.authErrorAlert);
}, function( e ){ }, function( e ){
alert("Signin error: " + e.message ); alert("Signin error: " + e.message );
}); });

View File

@@ -2,14 +2,17 @@
// Make user and settings available for everyone through root scope. // Make user and settings available for everyone through root scope.
habitrpg.controller('SettingsCtrl', habitrpg.controller('SettingsCtrl',
['$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, $compile) { function($scope, User, $rootScope, $http, ApiUrl, Guide, $location, $timeout, Content, Notification, Shared, Social, $compile) {
var RELEASE_ANIMAL_TYPES = { var RELEASE_ANIMAL_TYPES = {
pets: 'releasePets', pets: 'releasePets',
mounts: 'releaseMounts', mounts: 'releaseMounts',
both: 'releaseBoth', 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 // FIXME we have this re-declared everywhere, figure which is the canonical version and delete the rest
// $scope.auth = function (id, token) { // $scope.auth = function (id, token) {
// User.authenticate(id, token, function (err) { // User.authenticate(id, token, function (err) {
@@ -287,6 +290,40 @@ habitrpg.controller('SettingsCtrl',
return Math.floor(numberOfHourglasses); 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() { function _calculateNextCron() {
$scope.dayStart; $scope.dayStart;

View File

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

View File

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

View File

@@ -5,9 +5,11 @@
.module('habitrpg') .module('habitrpg')
.factory('Social', socialFactory); .factory('Social', socialFactory);
socialFactory.$inject = []; socialFactory.$inject = [
'$http','ApiUrl', 'Alert', 'Auth'
];
function socialFactory() { function socialFactory($http, ApiUrl, Alert, Auth) {
function loadWidgets() { function loadWidgets() {
// Facebook // 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 { return {
loadWidgets: loadWidgets loadWidgets: loadWidgets,
socialLogin: socialLogin
} }
} }
}()); }());

View File

@@ -42,6 +42,8 @@
"js/config.js", "js/config.js",
"js/services/sharedServices.js", "js/services/sharedServices.js",
"js/services/alertServices.js",
"js/services/authServices.js",
"js/services/notificationServices.js", "js/services/notificationServices.js",
"js/directives/directives.js", "js/directives/directives.js",
"js/services/analyticsServices.js", "js/services/analyticsServices.js",
@@ -134,7 +136,9 @@
"js/env.js", "js/env.js",
"js/static.js", "js/static.js",
"js/services/alertServices.js",
"js/services/analyticsServices.js", "js/services/analyticsServices.js",
"js/services/authServices.js",
"js/services/notificationServices.js", "js/services/notificationServices.js",
"js/services/userNotificationsService.js", "js/services/userNotificationsService.js",
"js/services/userServices.js", "js/services/userServices.js",
@@ -172,7 +176,9 @@
"js/env.js", "js/env.js",
"js/static.js", "js/static.js",
"js/services/alertServices.js",
"js/services/analyticsServices.js", "js/services/analyticsServices.js",
"js/services/authServices.js",
"js/services/notificationServices.js", "js/services/notificationServices.js",
"js/services/sharedServices.js", "js/services/sharedServices.js",
"js/services/socialServices.js", "js/services/socialServices.js",

View File

@@ -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.", "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", "login": "Login",
"loginAndReg": "Login / Register", "loginAndReg": "Login / Register",
"loginFacebookAlt": "Login / Register with Facebook", "loginFacebookAlt": "Sign in with Facebook",
"loginGoogleAlt": "Sign in with Google",
"logout": "Log Out", "logout": "Log Out",
"marketing1Header": "Improve Your Habits By Playing A Game", "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.", "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\".", "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.", "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.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your User ID \"<%= userId %>\" for assistance.",
"onlyFbSupported": "Only Facebook is supported currently.", "unsupportedNetwork": "This network is not currently supported.",
"cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "cantDetachSocial": "Account lacks another authentication method; can't detach this authentication method.",
"onlySocialAttachLocal": "Local authentication can be added to only a social account.", "onlySocialAttachLocal": "Local authentication can be added to only a social account.",
"invalidReqParams": "Invalid request parameters.", "invalidReqParams": "Invalid request parameters.",
"memberIdRequired": "\"member\" must be a valid UUID.", "memberIdRequired": "\"member\" must be a valid UUID.",

View File

@@ -91,8 +91,8 @@
"passwordSuccess": "Password successfully changed", "passwordSuccess": "Password successfully changed",
"usernameSuccess": "Login Name successfully changed", "usernameSuccess": "Login Name successfully changed",
"emailSuccess": "Email successfully changed", "emailSuccess": "Email successfully changed",
"detachFacebook": "De-register Facebook", "detachSocial": "De-register <%= network %>",
"detachedFacebook": "Successfully removed Facebook from your account", "detachedSocial": "Successfully removed <%= network %> authentication from your account",
"addedLocalAuth": "Successfully added local authentication", "addedLocalAuth": "Successfully added local authentication",
"data": "Data", "data": "Data",
"exportData": "Export Data", "exportData": "Export Data",
@@ -101,7 +101,8 @@
"emailChange3": " including both your old and new email address as well as your User ID.", "emailChange3": " including both your old and new email address as well as your User ID.",
"usernameOrEmail": "Login Name or Email", "usernameOrEmail": "Login Name or Email",
"email": "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 ", "loginNameDescription1": "This is what you use to login to Habitica. Go to ",
"loginNameDescription2": "User->Profile", "loginNameDescription2": "User->Profile",
"loginNameDescription3": "to change the name that appears in your avatar and chat messages.", "loginNameDescription3": "to change the name that appears in your avatar and chat messages.",

View File

@@ -5,3 +5,8 @@ export const ATTRIBUTES = ['str', 'int', 'per', 'con'];
export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; export const TAVERN_ID = '00000000-0000-4000-A000-000000000000';
export const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = 5000; export const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = 5000;
export const SUPPORTED_SOCIAL_NETWORKS = [
{key: 'facebook', name: 'Facebook'},
{key: 'google', name: 'Google'},
];

View File

@@ -23,10 +23,12 @@ import {
MAX_STAT_POINTS, MAX_STAT_POINTS,
TAVERN_ID, TAVERN_ID,
LARGE_GROUP_COUNT_MESSAGE_CUTOFF, LARGE_GROUP_COUNT_MESSAGE_CUTOFF,
SUPPORTED_SOCIAL_NETWORKS,
} from './constants'; } from './constants';
api.constants = { api.constants = {
LARGE_GROUP_COUNT_MESSAGE_CUTOFF, LARGE_GROUP_COUNT_MESSAGE_CUTOFF,
SUPPORTED_SOCIAL_NETWORKS,
}; };
// TODO Move these under api.constants // TODO Move these under api.constants
api.maxLevel = MAX_LEVEL; api.maxLevel = MAX_LEVEL;

View File

@@ -10,7 +10,6 @@ import {
BadRequest, BadRequest,
NotFound, NotFound,
} from '../../libs/errors'; } from '../../libs/errors';
import Bluebird from 'bluebird';
import * as passwordUtils from '../../libs/password'; import * as passwordUtils from '../../libs/password';
import logger from '../../libs/logger'; import logger from '../../libs/logger';
import { model as User } from '../../models/user'; import { model as User } from '../../models/user';
@@ -20,6 +19,7 @@ import { sendTxn as sendTxnEmail } from '../../libs/email';
import { decrypt } from '../../libs/encryption'; import { decrypt } from '../../libs/encryption';
import { send as sendEmail } from '../../libs/email'; import { send as sendEmail } from '../../libs/email';
import pusher from '../../libs/pusher'; import pusher from '../../libs/pusher';
import common from '../../../common';
let api = {}; 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 * @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 * @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)], middlewares: [authWithHeaders(true)],
url: '/user/auth/local/register', url: '/user/auth/local/register',
async handler (req, res) { 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({ req.checkBody({
email: { email: {
@@ -121,10 +133,15 @@ api.registerLocal = {
}, },
}; };
if (fbUser) { if (existingUser) {
if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); let hasSocialAuth = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(network => {
fbUser.auth.local = newUser.auth.local; if (existingUser.auth.hasOwnProperty(network.key)) {
newUser = fbUser; return existingUser.auth[network.key].id;
}
});
if (!hasSocialAuth) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
existingUser.auth.local = newUser.auth.local;
newUser = existingUser;
} else { } else {
newUser = new User(newUser); newUser = new User(newUser);
newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used 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(); 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 res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields
} else { } else {
res.respond(201, savedUser); res.respond(201, savedUser);
@@ -148,7 +165,7 @@ api.registerLocal = {
.remove({email: savedUser.auth.local.email}) .remove({email: savedUser.auth.local.email})
.then(() => sendTxnEmail(savedUser, 'welcome')); .then(() => sendTxnEmail(savedUser, 'welcome'));
if (!savedUser.auth.facebook.id) { if (!existingUser) {
res.analytics.track('register', { res.analytics.track('register', {
category: 'acquisition', category: 'acquisition',
type: 'local', type: 'local',
@@ -228,9 +245,9 @@ api.loginLocal = {
}, },
}; };
function _passportFbProfile (accessToken) { function _passportProfile (network, accessToken) {
return new Bluebird((resolve, reject) => { return new Promise((resolve, reject) => {
passport._strategies.facebook.userProfile(accessToken, (err, profile) => { passport._strategies[network].userProfile(accessToken, (err, profile) => {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
@@ -243,14 +260,19 @@ function _passportFbProfile (accessToken) {
// Called as a callback by Facebook (or other social providers). Internal route // Called as a callback by Facebook (or other social providers). Internal route
api.loginSocial = { api.loginSocial = {
method: 'POST', method: 'POST',
middlewares: [authWithHeaders(true)],
url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2
async handler (req, res) { async handler (req, res) {
let existingUser = res.locals.user;
let accessToken = req.body.authResponse.access_token; let accessToken = req.body.authResponse.access_token;
let network = req.body.network; 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({ let user = await User.findOne({
[`auth.${network}.id`]: profile.id, [`auth.${network}.id`]: profile.id,
@@ -260,29 +282,38 @@ api.loginSocial = {
if (user) { if (user) {
_loginRes(user, ...arguments); _loginRes(user, ...arguments);
} else { // Create new user } else { // Create new user
user = new User({ user = {
auth: { auth: {
[network]: profile, [network]: profile,
}, },
preferences: { preferences: {
language: req.language, 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(); let savedUser = await user.save();
if (!existingUser) {
user.newUser = true; user.newUser = true;
}
_loginRes(user, ...arguments); _loginRes(user, ...arguments);
// Clean previous email preferences // 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 EmailUnsubscription
.remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
.exec() .exec()
.then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks
} }
if (!existingUser) {
res.analytics.track('register', { res.analytics.track('register', {
category: 'acquisition', category: 'acquisition',
type: network, type: network,
@@ -291,6 +322,7 @@ api.loginSocial = {
headers: req.headers, headers: req.headers,
user: savedUser, user: savedUser,
}); });
}
return null; return null;
} }
@@ -576,11 +608,15 @@ api.deleteSocial = {
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
let network = req.params.network; let network = req.params.network;
let isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(supportedNetwork => {
if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); return supportedNetwork.key === network;
if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb')); });
if (!isSupportedNetwork) throw new BadRequest(res.t('unsupportedNetwork'));
await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec(); 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, {}); res.respond(200, {});
}, },

View File

@@ -607,6 +607,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
let userToContact = await User.findOne({$or: [ let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email}, {'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email}, {'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
]}) ]})
.select({_id: true, 'preferences.emailNotifications': true}) .select({_id: true, 'preferences.emailNotifications': true})
.exec(); .exec();

View File

@@ -4,6 +4,7 @@ import { TAVERN_ID } from '../models/group';
import { encrypt } from './encryption'; import { encrypt } from './encryption';
import request from 'request'; import request from 'request';
import logger from './logger'; import logger from './logger';
import common from '../../common';
const IS_PROD = nconf.get('IS_PROD'); const IS_PROD = nconf.get('IS_PROD');
const EMAIL_SERVER = { const EMAIL_SERVER = {
@@ -47,8 +48,12 @@ export function getUserInfo (user, fields = []) {
if (fields.indexOf('email') !== -1) { if (fields.indexOf('email') !== -1) {
if (user.auth.local && user.auth.local.email) { if (user.auth.local && user.auth.local.email) {
info.email = 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) { } else {
info.email = user.auth.facebook.emails[0].value; 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;
}
});
} }
} }

View File

@@ -1,8 +1,7 @@
import passport from 'passport'; import passport from 'passport';
import nconf from 'nconf'; import nconf from 'nconf';
import passportFacebook from 'passport-facebook'; import { Strategy as FacebookStrategy } from 'passport-facebook';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
const FacebookStrategy = passportFacebook.Strategy;
// Passport session setup. // Passport session setup.
// To support persistent login sessions, Passport needs to be able to // To support persistent login sessions, Passport needs to be able to
@@ -22,3 +21,8 @@ passport.use(new FacebookStrategy({
clientSecret: nconf.get('FACEBOOK_SECRET'), clientSecret: nconf.get('FACEBOOK_SECRET'),
// callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" // callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback"
}, (accessToken, refreshToken, profile, done) => done(null, profile))); }, (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)));

View File

@@ -12,7 +12,7 @@ import { mods } from '../models/user';
// To avoid stringifying more data then we need, // To avoid stringifying more data then we need,
// items from `env` used on the client will have to be specified in this array // items from `env` used on the client will have to be specified in this array
const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', 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', 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
'worldDmg', 'mods', 'IS_MOBILE', 'PUSHER:KEY', 'PUSHER:ENABLED']; '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(' ') .split(' ')
.forEach(key => { .forEach(key => {
env[key] = nconf.get(key); env[key] = nconf.get(key);

View File

@@ -103,14 +103,29 @@ function _setUpNewUser (user) {
return _populateDefaultTasks(user, taskTypes); 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) { function _setProfileName (user) {
let fb = user.auth.facebook; let google = user.auth.google;
let localUsername = user.auth.local && user.auth.local.username; 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'; let anonymous = 'Anonymous';
return localUsername || facebookUsername || anonymous; return localUsername || _getFacebookName(user.auth.facebook) || googleUsername || anonymous;
} }
schema.pre('save', true, function preSaveUser (next, done) { schema.pre('save', true, function preSaveUser (next, done) {

View File

@@ -24,6 +24,9 @@ let schema = new Schema({
facebook: {type: Schema.Types.Mixed, default: () => { facebook: {type: Schema.Types.Mixed, default: () => {
return {}; return {};
}}, }},
google: {type: Schema.Types.Mixed, default: () => {
return {};
}},
local: { local: {
email: { email: {
type: String, type: String,

View File

@@ -131,9 +131,12 @@ script(type='text/ng-template', id='partials/options.settings.settings.html')
.panel-heading .panel-heading
span=env.t('registration') span=env.t('registration')
.panel-body .panel-body
div(ng-if='user.auth.facebook.id') div
button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb') ul.list-inline
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') 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 hr
div(ng-if='!user.auth.local.username') div(ng-if='!user.auth.local.username')
p=env.t('addLocalAuth') p=env.t('addLocalAuth')

View File

@@ -26,14 +26,11 @@ script(id='modals/login.html', type='text/ng-template')
button.close(type='button', ng-click='$close()') × button.close(type='button', ng-click='$close()') ×
h4.modal-title=env.t('loginAndReg') h4.modal-title=env.t('loginAndReg')
.modal-body(ng-controller='AuthCtrl') .modal-body(ng-controller='AuthCtrl')
ul.list-inline
li
a.zocial.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")')=env.t('loginFacebookAlt') a.zocial.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")')=env.t('loginFacebookAlt')
//-ul.list-inline // li
li // a.zocial.google(alt="Google", ng-click='socialLogin("google")')=env.t('loginGoogleAlt')
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
hr hr
tabset(justified='true') tabset(justified='true')
tab(heading=env.t('login')) tab(heading=env.t('login'))