diff --git a/test/api/v3/integration/user/auth/GET-user_auth_apple.test.js b/test/api/v3/integration/user/auth/GET-user_auth_apple.test.js index 975fccfa3f..b1c97f6ca4 100644 --- a/test/api/v3/integration/user/auth/GET-user_auth_apple.test.js +++ b/test/api/v3/integration/user/auth/GET-user_auth_apple.test.js @@ -1,3 +1,4 @@ +import { v4 as generateUUID } from 'uuid'; import { generateUser, requester, @@ -9,15 +10,18 @@ describe('GET /user/auth/apple', () => { let api; let user; const appleEndpoint = '/user/auth/apple'; - - before(async () => { - const expectedResult = { id: 'appleId', name: 'an apple user' }; - sandbox.stub(appleAuth, 'appleProfile').returns(Promise.resolve(expectedResult)); - }); + let randomAppleId = '123456'; beforeEach(async () => { api = requester(); user = await generateUser(); + randomAppleId = generateUUID(); + const expectedResult = { id: randomAppleId, name: 'an apple user' }; + sandbox.stub(appleAuth, 'appleProfile').returns(Promise.resolve(expectedResult)); + }); + + afterEach(async () => { + appleAuth.appleProfile.restore(); }); it('registers a new user', async () => { @@ -26,7 +30,7 @@ describe('GET /user/auth/apple', () => { expect(response.apiToken).to.exist; expect(response.id).to.exist; expect(response.newUser).to.be.true; - await expect(getProperty('users', response.id, 'auth.apple.id')).to.eventually.equal('appleId'); + await expect(getProperty('users', response.id, 'auth.apple.id')).to.eventually.equal(randomAppleId); await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('an apple user'); }); diff --git a/test/api/v3/integration/user/auth/POST-user_auth_social.test.js b/test/api/v3/integration/user/auth/POST-user_auth_social.test.js index eff5525603..c4ffe57efb 100644 --- a/test/api/v3/integration/user/auth/POST-user_auth_social.test.js +++ b/test/api/v3/integration/user/auth/POST-user_auth_social.test.js @@ -1,4 +1,5 @@ import passport from 'passport'; +import { v4 as generateUUID } from 'uuid'; import { generateUser, requester, @@ -10,14 +11,15 @@ describe('POST /user/auth/social', () => { let api; let user; const endpoint = '/user/auth/social'; - const randomAccessToken = '123456'; - const facebookId = 'facebookId'; - const googleId = 'googleId'; + let randomAccessToken = '123456'; + let randomFacebookId = 'facebookId'; + let randomGoogleId = 'googleId'; let network = 'NoNetwork'; beforeEach(async () => { api = requester(); user = await generateUser(); + randomAccessToken = generateUUID(); }); it('fails if network is not supported', async () => { @@ -32,12 +34,23 @@ describe('POST /user/auth/social', () => { }); describe('facebook', () => { - before(async () => { - const expectedResult = { id: facebookId, displayName: 'a facebook user' }; + 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 @@ -51,7 +64,8 @@ describe('POST /user/auth/social', () => { 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.facebook.id')).to.eventually.equal(facebookId); + 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 () => { @@ -68,6 +82,57 @@ describe('POST /user/auth/social', () => { 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 () => { @@ -76,11 +141,28 @@ describe('POST /user/auth/social', () => { network, }); - expect(response.apiToken).to.exist; - expect(response.id).to.exist; + 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 @@ -92,12 +174,23 @@ describe('POST /user/auth/social', () => { }); describe('google', () => { - before(async () => { - const expectedResult = { id: googleId, displayName: 'a google user' }; + beforeEach(async () => { + randomGoogleId = generateUUID(); + const expectedResult = { + id: randomGoogleId, + displayName: 'a google user', + emails: [ + { value: `${user.auth.local.username}+google@example.com` }, + ], + }; sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult); network = 'google'; }); + afterEach(async () => { + passport._strategies.google.userProfile.restore(); + }); + it('registers a new user', async () => { const response = await api.post(endpoint, { authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase @@ -107,7 +200,8 @@ describe('POST /user/auth/social', () => { expect(response.apiToken).to.exist; expect(response.id).to.exist; expect(response.newUser).to.be.true; - await expect(getProperty('users', response.id, 'auth.google.id')).to.eventually.equal(googleId); + await expect(getProperty('users', response.id, 'auth.google.id')).to.eventually.equal(randomGoogleId); + await expect(getProperty('users', response.id, 'auth.local.email')).to.eventually.equal(`${user.auth.local.username}+google@example.com`); await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user'); }); @@ -125,6 +219,57 @@ describe('POST /user/auth/social', () => { 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.google.userProfile.restore(); + const expectedResult = { + id: randomGoogleId, + displayName: 'a google user', + emails: [ + { value: user.auth.local.email }, + ], + }; + sandbox.stub(passport._strategies.google, '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.google.userProfile.restore(); + const expectedResult = { + id: randomGoogleId, + displayName: 'a google user', + emails: [ + { value: user.auth.local.email }, + ], + }; + sandbox.stub(passport._strategies.google, '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 () => { @@ -133,11 +278,28 @@ describe('POST /user/auth/social', () => { network, }); - expect(response.apiToken).to.exist; - expect(response.id).to.exist; + 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 diff --git a/website/client/src/components/settings/site.vue b/website/client/src/components/settings/site.vue index 9abd220234..cf9d858db9 100644 --- a/website/client/src/components/settings/site.vue +++ b/website/client/src/components/settings/site.vue @@ -226,7 +226,7 @@ :key="network.key" > -
+
{{ $t('changeEmail') }}
@import '~@/assets/scss/colors.scss'; - input { color: $gray-50; } - .usersettings h5 { margin-top: 1em; } - .iconalert > div > span { line-height: 25px; } - .iconalert > div:after { clear: both; content: ''; display: table; } - .input-error { color: $red-50; font-size: 90%; @@ -768,12 +763,10 @@ export default { if (network === 'apple') { window.location.href = buildAppleAuthUrl(); } else { - const auth = await hello(network).login({ scope: 'email', options: { force: true } }); - + const auth = await hello(network).login({ scope: 'email' }); await this.$store.dispatch('auth:socialAuth', { auth, }); - window.location.href = '/'; } }, @@ -792,7 +785,6 @@ export default { } await axios.post('/api/v4/user/auth/local/register', this.localAuth); window.alert(this.$t('addedLocalAuth')); // eslint-disable-line no-alert - window.location.href = '/'; }, restoreEmptyUsername () { if (this.usernameUpdates.username.length < 1) { diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index 2fb6c00ab2..086d3ce5a6 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -133,6 +133,7 @@ "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.", + "socialAlreadyExists": "This social login is already linked to an existing Habitica account.", "invalidReqParams": "Invalid request parameters.", "memberIdRequired": "\"member\" must be a valid UUID.", "heroIdRequired": "\"heroId\" must be a valid UUID.", diff --git a/website/server/libs/auth/social.js b/website/server/libs/auth/social.js index 02e4d64faf..61883a2f87 100644 --- a/website/server/libs/auth/social.js +++ b/website/server/libs/auth/social.js @@ -1,6 +1,6 @@ import passport from 'passport'; import common from '../../../common'; -import { BadRequest } from '../errors'; +import { BadRequest, NotAuthorized } from '../errors'; import logger from '../logger'; import { generateUsername, @@ -24,7 +24,7 @@ function _passportProfile (network, accessToken) { } export async function loginSocial (req, res) { // eslint-disable-line import/prefer-default-export - const existingUser = res.locals.user; + let existingUser = res.locals.user; const { network } = req.body; const isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS @@ -47,37 +47,52 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre // User already signed up if (user) { + if (existingUser) { + throw new NotAuthorized(res.t('socialAlreadyExists')); + } return loginRes(user, req, res); } - const generatedUsername = generateUsername(); + let email; + if (profile.emails && profile.emails[0] && profile.emails[0].value) { + email = profile.emails[0].value.toLowerCase(); + } - user = { - auth: { - [network]: { - id: profile.id, - emails: profile.emails, - }, - local: { - username: generatedUsername, - lowerCaseUsername: generatedUsername, - }, - }, - profile: { - name: profile.displayName || profile.name || profile.username, - }, - preferences: { - language: req.language, - }, - flags: { - verifiedUsername: true, - }, - }; + if (!existingUser && email) { + existingUser = await User.findOne({ 'auth.local.email': email }).exec(); + } if (existingUser) { - existingUser.auth[network] = user.auth[network]; + existingUser.auth[network] = { + id: profile.id, + emails: profile.emails, + }; user = existingUser; } else { + const generatedUsername = generateUsername(); + + user = { + auth: { + [network]: { + id: profile.id, + emails: profile.emails, + }, + local: { + username: generatedUsername, + lowerCaseUsername: generatedUsername, + email, + }, + }, + profile: { + name: profile.displayName || profile.name || profile.username, + }, + preferences: { + language: req.language, + }, + flags: { + verifiedUsername: true, + }, + }; user = new User(user); user.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used } @@ -85,19 +100,15 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre const savedUser = await user.save(); if (!existingUser) { - user.newUser = true; + savedUser.newUser = true; } - const response = loginRes(user, req, res); + const response = loginRes(savedUser, req, res); // Clean previous email preferences - if ( - savedUser.auth[network].emails - && savedUser.auth[network].emails[0] - && savedUser.auth[network].emails[0].value - ) { + if (email) { EmailUnsubscription - .remove({ email: savedUser.auth[network].emails[0].value.toLowerCase() }) + .remove({ email }) .exec() .then(() => { if (!existingUser) {