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_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",

View File

@@ -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",

View File

@@ -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: 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,
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 () => {
await user.update({ 'auth.local': { ok: true } });
await expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('cantDetachFb'),
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 },
});
it('succeeds', async () => {
let response = await user.del(endpoint);
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;
});
});
});

View File

@@ -12,28 +12,32 @@ 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'),
});
});
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 () => {
let response = await api.post(endpoint, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
@@ -45,6 +49,33 @@ describe('POST /user/auth/social', () => {
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
@@ -53,17 +84,60 @@ describe('POST /user/auth/social', () => {
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 () => {
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, {
authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase
network,
});
expect(response.apiToken).to.eql(user.apiToken);
expect(response.id).to.eql(user._id);
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');
});
});
});

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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",

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.",
"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.",

View File

@@ -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.",

View File

@@ -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'},
];

View File

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

View File

@@ -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,29 +282,38 @@ 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();
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
}
if (!existingUser) {
res.analytics.track('register', {
category: 'acquisition',
type: network,
@@ -291,6 +322,7 @@ api.loginSocial = {
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, {});
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')
ul.list-inline
li
a.zocial.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")')=env.t('loginFacebookAlt')
//-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
// li
// a.zocial.google(alt="Google", ng-click='socialLogin("google")')=env.t('loginGoogleAlt')
hr
tabset(justified='true')
tab(heading=env.t('login'))