Purge Facebook (#13696)

* Don't sign in user when trying to connect a social account that was already created

* Log social users into matching local auth accounts

If the social account has an email that already exists as a local user, instead of creating a new account log them into their account and add the social auth to the account

* If possible set local authentication email for social users

* Allow password reset emails to be sent to social login users

* lint fixes

* Fix issues and tests

* fix tests

* Fix lint error.

* purge Facebook.

Only keep it in some select places to allow for some compatablilty.

* Fix error

* fix error

* Let settings handle it when you don't have a password set but an email

* fix error

* Fix boolean logic

* fix json conversion

* .

* fix password reset for old social accounts

* Don't sign in user when trying to connect a social account that was already created

* Log social users into matching local auth accounts

If the social account has an email that already exists as a local user, instead of creating a new account log them into their account and add the social auth to the account

* If possible set local authentication email for social users

* Allow password reset emails to be sent to social login users

* lint fixes

* Fix issues and tests

* fix tests

* Fix lint error.

* purge Facebook.

Only keep it in some select places to allow for some compatablilty.

* Fix error

* fix error

* Let settings handle it when you don't have a password set but an email

* fix error

* Fix boolean logic

* fix json conversion

* fix password reset for old social accounts

* Revert "lint fixes"

This reverts commit c244b1651c.

# Conflicts:
#	website/client/src/components/auth/registerLoginReset.vue
#	website/client/src/components/static/contact.vue

* Revert "fix password reset for old social accounts"

This reverts commit 7e0069a80f.

* fix duplicate code

* chore(misc): remove irrelevant changes

* chore(privacy): update policy page with note about FB

Co-authored-by: SabreCat <sabe@habitica.com>
This commit is contained in:
Phillip Thelen
2022-09-16 01:22:52 +02:00
committed by GitHub
parent 10f5011781
commit 24841346dc
15 changed files with 29 additions and 324 deletions

View File

@@ -13,11 +13,6 @@ function getUser () {
username: 'username',
email: 'email@email',
},
facebook: {
emails: [{
value: 'email@facebook',
}],
},
google: {
emails: [{
value: 'email@google',
@@ -62,30 +57,12 @@ describe('emails', () => {
expect(data).to.have.property('canSend', true);
});
it('returns correct user data [facebook users]', () => {
const attachEmail = requireAgain(pathToEmailLib);
const { getUserInfo } = attachEmail;
const user = getUser();
delete user.profile.name;
delete user.auth.local.email;
delete user.auth.google.emails;
delete user.auth.apple.emails;
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
expect(data).to.have.property('name', user.auth.local.username);
expect(data).to.have.property('email', user.auth.facebook.emails[0].value);
expect(data).to.have.property('_id', user._id);
expect(data).to.have.property('canSend', true);
});
it('returns correct user data [google users]', () => {
const attachEmail = requireAgain(pathToEmailLib);
const { getUserInfo } = attachEmail;
const user = getUser();
delete user.profile.name;
delete user.auth.local.email;
delete user.auth.facebook.emails;
delete user.auth.apple.emails;
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
@@ -103,7 +80,6 @@ describe('emails', () => {
delete user.profile.name;
delete user.auth.local.email;
delete user.auth.google.emails;
delete user.auth.facebook.emails;
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
@@ -118,7 +94,6 @@ describe('emails', () => {
const { getUserInfo } = attachEmail;
const user = getUser();
delete user.auth.local.email;
delete user.auth.facebook;
delete user.auth.google;
delete user.auth.apple;

View File

@@ -246,7 +246,7 @@ describe('Password Utilities', () => {
it('returns false if the user has no local auth', async () => {
const user = await generateUser({
auth: {
facebook: {},
google: {},
},
});
const res = await validatePasswordResetCodeAndFindUser(encrypt(JSON.stringify({

View File

@@ -289,45 +289,6 @@ describe('DELETE /user', () => {
});
});
context('user with Facebook auth', async () => {
beforeEach(async () => {
user = await generateUser({
auth: {
facebook: {
id: 'facebook-id',
},
},
});
});
it('returns an error if confirmation phrase is wrong', async () => {
await expect(user.del('/user', {
password: 'just-do-it',
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('incorrectDeletePhrase', { magicWord: 'DELETE' }),
});
});
it('returns an error if confirmation phrase is not supplied', async () => {
await expect(user.del('/user', {
password: '',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('missingPassword'),
});
});
it('deletes a Facebook user', async () => {
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
});
});
context('user with Google auth', async () => {
beforeEach(async () => {
user = await generateUser({

View File

@@ -20,44 +20,6 @@ describe('DELETE social registration', () => {
});
});
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('cantDetachSocial'),
});
});
it('succeeds if user has a local registration', async () => {
await user.update({
'auth.facebook.id': 'some-fb-id',
});
const response = await user.del('/user/auth/social/facebook');
expect(response).to.eql({});
await user.sync();
expect(user.auth.facebook).to.be.undefined;
});
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 },
});
const response = await user.del('/user/auth/social/facebook');
expect(response).to.eql({});
await user.sync();
expect(user.auth.facebook).to.be.undefined;
});
});
context('Google', () => {
it('fails if user does not have an alternative registration method', async () => {
await user.update({
@@ -81,19 +43,6 @@ describe('DELETE social registration', () => {
await user.sync();
expect(user.auth.google).to.be.undefined;
});
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 },
});
const response = await user.del('/user/auth/social/google');
expect(response).to.eql({});
await user.sync();
expect(user.auth.goodl).to.be.undefined;
});
});
context('Apple', () => {
@@ -119,18 +68,5 @@ describe('DELETE social registration', () => {
await user.sync();
expect(user.auth.apple).to.be.undefined;
});
it('succeeds if user has a facebook registration', async () => {
await user.update({
'auth.apple.id': 'some-apple-id',
'auth.facebook.id': 'some-facebook-id',
'auth.local': { ok: true },
});
const response = await user.del('/user/auth/social/apple');
expect(response).to.eql({});
await user.sync();
expect(user.auth.goodl).to.be.undefined;
});
});
});

View File

@@ -12,7 +12,6 @@ describe('POST /user/auth/social', () => {
let user;
const endpoint = '/user/auth/social';
let randomAccessToken = '123456';
let randomFacebookId = 'facebookId';
let randomGoogleId = 'googleId';
let network = 'NoNetwork';
@@ -33,146 +32,6 @@ describe('POST /user/auth/social', () => {
});
});
describe('facebook', () => {
beforeEach(async () => {
randomFacebookId = generateUUID();
const expectedResult = {
id: randomFacebookId,
displayName: 'a facebook user',
emails: [
{ value: `${user.auth.local.username}+facebook@example.com` },
],
};
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
network = 'facebook';
});
afterEach(async () => {
passport._strategies.facebook.userProfile.restore();
});
it('registers a new user', async () => {
const 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.username).to.exist;
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a facebook user');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.exist;
await expect(getProperty('users', response.id, 'auth.local.email')).to.eventually.equal(`${user.auth.local.username}+facebook@example.com`);
await expect(getProperty('users', response.id, 'auth.facebook.id')).to.eventually.equal(randomFacebookId);
});
it('logs an existing user in', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const 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;
expect(registerResponse.newUser).to.be.true;
});
it('logs an existing user in if they have local auth with matching email', async () => {
passport._strategies.facebook.userProfile.restore();
const expectedResult = {
id: randomFacebookId,
displayName: 'a facebook user',
emails: [
{ value: user.auth.local.email },
],
};
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
const 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.newUser).to.be.false;
});
it('logs an existing user into their social account if they have local auth with matching email', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
expect(registerResponse.newUser).to.be.true;
// This is important for existing accounts before the new social handling
passport._strategies.facebook.userProfile.restore();
const expectedResult = {
id: randomFacebookId,
displayName: 'a facebook user',
emails: [
{ value: user.auth.local.email },
],
};
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
const 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.apiToken).not.to.eql(user.apiToken);
expect(response.id).not.to.eql(user._id);
expect(response.newUser).to.be.false;
});
it('add social auth to an existing user', async () => {
const response = await user.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.newUser).to.be.false;
});
it('does not log into other account if social auth already exists', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
expect(registerResponse.newUser).to.be.true;
await expect(user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('socialAlreadyExists'),
});
});
xit('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, '_ABtests')).to.eventually.be.a('object');
});
});
describe('google', () => {
beforeEach(async () => {
randomGoogleId = generateUUID();

View File

@@ -25,6 +25,19 @@ describe('POST /user/reset-password', async () => {
expect(user.auth.local.hashed_password).to.not.eql(previousPassword);
});
it('resets password for social users', async () => {
const email = `${user.auth.local.username}+google@example.com`;
await user.update({ 'auth.google.emails': [{ value: email }] });
await user.sync();
const previousPassword = user.auth.local.passwordResetCode;
const response = await user.post(endpoint, {
email,
});
expect(response).to.eql({ data: {}, message: t('passwordReset') });
await user.sync();
expect(user.auth.local.passwordResetCode).to.not.eql(previousPassword);
});
it('same message on error as on success', async () => {
const response = await user.post(endpoint, {
email: 'nonExistent@email.com',

View File

@@ -1,21 +1,5 @@
<template>
<div class="form">
<div class="form-group row text-center">
<div class="col-12">
<div
class="btn btn-secondary social-button"
@click="socialAuth('facebook')"
>
<div
class="svg-icon social-icon"
v-html="icons.facebookIcon"
></div>
<span>{{ registering
? $t('signUpWithSocial', {social: 'Facebook'})
: $t('loginWithSocial', {social: 'Facebook'}) }}</span>
</div>
</div>
</div>
<div class="form-group row text-center">
<div class="col-12">
<div
@@ -243,7 +227,6 @@ import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import { setUpAxios, buildAppleAuthUrl } from '@/libs/auth';
import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
import facebookSquareIcon from '@/assets/svg/facebook-square.svg';
import googleIcon from '@/assets/svg/google.svg';
import appleIcon from '@/assets/svg/apple_black.svg';
@@ -260,7 +243,6 @@ export default {
};
data.icons = Object.freeze({
facebookIcon: facebookSquareIcon,
googleIcon,
appleIcon,
});
@@ -308,8 +290,6 @@ export default {
},
mounted () {
hello.init({
facebook: process.env.FACEBOOK_KEY, // eslint-disable-line
// windows: WINDOWS_CLIENT_ID,
google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},

View File

@@ -621,7 +621,6 @@ import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
import exclamation from '@/assets/svg/exclamation.svg';
import gryphon from '@/assets/svg/gryphon.svg';
import habiticaIcon from '@/assets/svg/habitica-logo.svg';
import facebookSquareIcon from '@/assets/svg/facebook-square.svg';
import googleIcon from '@/assets/svg/google.svg';
import appleIcon from '@/assets/svg/apple_black.svg';
@@ -644,7 +643,6 @@ export default {
exclamation,
gryphon,
habiticaIcon,
facebookIcon: facebookSquareIcon,
googleIcon,
appleIcon,
});
@@ -734,8 +732,6 @@ export default {
},
mounted () {
hello.init({
facebook: process.env.FACEBOOK_KEY, // eslint-disable-line
// windows: WINDOWS_CLIENT_ID,
google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},

View File

@@ -387,9 +387,7 @@
{{ $t('saveAndConfirm') }}
</button>
</div>
<h5
v-if="user.auth.local.email"
>
<h5 v-if="user.auth.local.has_password">
{{ $t('changeEmail') }}
</h5>
<div

View File

@@ -3,7 +3,7 @@
<div class="container-fluid">
<h1>Privacy Notice</h1>
<p class="strong pagemeta">
Last Updated: December 10, 2021
Last Updated: September 15, 2022
</p>
<p>
HabitRPG, Inc. (HabitRPG, we, us, or our) welcomes you. This privacy notice (the Privacy
@@ -39,8 +39,9 @@
In connection with the creation of an account on our Platforms, we collect account credentials such as
your email, username, and password. We use this account information to create your account, including to
verify your identity. We also use this information to manage your account, including your transactions. If
you choose to log into your account through Google, Apple or Facebook, we capture and store the User
ID and email address connected to the respective account, so we can verify your identity when you log in.
you choose to log into your account through Google or Apple, we capture and store the User ID and email
address connected to the respective account, so we can verify your identity when you log in.
(We no longer offer the ability to log in using Facebook's authentication procedure.)
</p>
<h3>User Content</h3>
<p>

View File

@@ -23,7 +23,6 @@ const envVars = [
'BASE_URL',
'GA_ID',
'STRIPE_PUB_KEY',
'FACEBOOK_KEY',
'GOOGLE_CLIENT_ID',
'APPLE_AUTH_CLIENT_ID',
'AMPLITUDE_KEY',

View File

@@ -21,7 +21,6 @@ export const CHAT_FLAG_FROM_SHADOW_MUTE = 10;
// @TODO use those constants to replace hard-coded numbers
export const SUPPORTED_SOCIAL_NETWORKS = [
{ key: 'facebook', name: 'Facebook' },
{ key: 'google', name: 'Google' },
{ key: 'apple', name: 'Apple' },
];

View File

@@ -161,13 +161,16 @@ async function registerLocal (req, res, { isV3 = false }) {
};
if (existingUser) {
const hasSocialAuth = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(network => {
const networks = common.constants.SUPPORTED_SOCIAL_NETWORKS;
// need to insert FB here to allow users who only have FB auth to connect local auth.
networks.push({ key: 'facebook', name: 'Facebook' });
const hasSocialAuth = networks.find(network => {
if (existingUser.auth.hasOwnProperty(network.key)) { // eslint-disable-line no-prototype-builtins, max-len
return existingUser.auth[network.key].id;
}
return false;
});
if (!hasSocialAuth) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
if (!hasSocialAuth && existingUser.auth.local.hashed_password) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
existingUser.auth.local = newUser.auth.local;
newUser = existingUser;
} else {

View File

@@ -60,11 +60,6 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
[`auth.${network}.id`]: profile.id,
}, { _id: 1, apiToken: 1, auth: 1 }).exec();
let email;
if (profile.emails && profile.emails[0] && profile.emails[0].value) {
email = profile.emails[0].value.toLowerCase();
}
// User already signed up
if (user) {
if (existingUser) {
@@ -79,6 +74,11 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
return loginRes(user, req, res);
}
let email;
if (profile.emails && profile.emails[0] && profile.emails[0].value) {
email = profile.emails[0].value.toLowerCase();
}
if (!existingUser && email) {
existingUser = await User.findOne({ 'auth.local.email': email }).exec();
}

View File

@@ -1,6 +1,5 @@
import passport from 'passport';
import nconf from 'nconf';
import { Strategy as FacebookStrategy } from 'passport-facebook';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
// Passport session setup.
@@ -13,20 +12,6 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((obj, done) => done(null, obj));
// TODO remove?
// This auth strategy is no longer used.
// It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile)
// The proper fix would be to move to a general OAuth module simply to verify accessTokens
passport.use(new FacebookStrategy({
clientID: nconf.get('FACEBOOK_KEY'),
clientSecret: nconf.get('FACEBOOK_SECRET'),
profileFields: ['id', 'email', 'displayName'],
profileURL: 'https://graph.facebook.com/v2.8/me',
authorizationURL: 'https://www.facebook.com/v2.8/dialog/oauth',
tokenURL: 'https://graph.facebook.com/v2.8/oauth/access_token',
// 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'),