From e6dd0d5e8259032786d77d10840e0553f3f54568 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 21 Jul 2017 10:55:53 -0700 Subject: [PATCH] Delete Account with Social Auth (#8796) * feat(accounts): delete social accts * test(integration): social auth delete --- .../v3/integration/user/DELETE-user.test.js | 510 ++++++++++-------- website/common/locales/en/front.json | 1 + website/common/locales/en/settings.json | 1 + website/server/controllers/api-v3/user.js | 14 +- website/views/options/settings/settings.jade | 3 +- website/views/shared/modals/settings.jade | 15 +- 6 files changed, 312 insertions(+), 232 deletions(-) diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index 140577af95..20968c5ed6 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -18,266 +18,328 @@ import { } from '../../../../../website/server/libs/password'; import * as email from '../../../../../website/server/libs/email'; +const DELETE_CONFIRMATION = 'DELETE'; + describe('DELETE /user', () => { let user; let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js - beforeEach(async () => { - user = await generateUser({balance: 10}); - }); - - it('returns an error if password is wrong', async () => { - await expect(user.del('/user', { - password: 'wrong-password', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('wrongPassword'), - }); - }); - - it('returns an error if password is not supplied', async () => { - await expect(user.del('/user', { - password: '', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('missingPassword'), - }); - }); - - it('returns an error if excessive feedback is supplied', async () => { - let feedbackText = 'spam feedback '; - let feedback = feedbackText; - while (feedback.length < 10000) { - feedback = feedback + feedbackText; - } - - await expect(user.del('/user', { - password, - feedback, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Account deletion feedback is limited to 10,000 characters. For lengthy feedback, email admin@habitica.com.', - }); - }); - - it('returns an error if user has active subscription', async () => { - let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); - - await expect(userWithSubscription.del('/user', { - password, - })).to.be.rejected.and.to.eventually.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cannotDeleteActiveAccount'), - }); - }); - - it('deletes the user\'s tasks', async () => { - // gets the user's tasks ids - let ids = []; - each(user.tasksOrder, (idsForOrder) => { - ids.push(...idsForOrder); - }); - - expect(ids.length).to.be.above(0); // make sure the user has some task to delete - - await user.del('/user', { - password, - }); - - await Bluebird.all(map(ids, id => { - return expect(checkExistence('tasks', id)).to.eventually.eql(false); - })); - }); - - it('reduces memberCount in challenges user is linked to', async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 2, - }); - - let group = populatedGroup.group; - let authorizedUser = populatedGroup.members[1]; - - let challenge = await generateChallenge(populatedGroup.groupLeader, group); - await authorizedUser.post(`/challenges/${challenge._id}/join`); - - await challenge.sync(); - - expect(challenge.memberCount).to.eql(2); - - await authorizedUser.del('/user', { - password, - }); - - await challenge.sync(); - - expect(challenge.memberCount).to.eql(1); - }); - - it('deletes the user', async () => { - await user.del('/user', { - password, - }); - await expect(checkExistence('users', user._id)).to.eventually.eql(false); - }); - - it('sends feedback to the admin email', async () => { - sandbox.spy(email, 'sendTxn'); - - let feedback = 'Reasons for Deletion'; - await user.del('/user', { - password, - feedback, - }); - - expect(email.sendTxn).to.be.calledOnce; - - sandbox.restore(); - }); - - it('does not send email if no feedback is supplied', async () => { - sandbox.spy(email, 'sendTxn'); - - await user.del('/user', { - password, - }); - - expect(email.sendTxn).to.not.be.called; - - sandbox.restore(); - }); - - it('deletes the user with a legacy sha1 password', async () => { - let textPassword = 'mySecretPassword'; - let salt = sha1MakeSalt(); - let sha1HashedPassword = sha1EncryptPassword(textPassword, salt); - - await user.update({ - 'auth.local.hashed_password': sha1HashedPassword, - 'auth.local.passwordHashMethod': 'sha1', - 'auth.local.salt': salt, - }); - - await user.sync(); - - expect(user.auth.local.passwordHashMethod).to.equal('sha1'); - expect(user.auth.local.salt).to.equal(salt); - expect(user.auth.local.hashed_password).to.equal(sha1HashedPassword); - - // delete the user - await user.del('/user', { - password: textPassword, - }); - await expect(checkExistence('users', user._id)).to.eventually.eql(false); - }); - - context('last member of a party', () => { - let party; - + context('user with local auth', async () => { beforeEach(async () => { - party = await generateGroup(user, { - type: 'party', - privacy: 'private', + user = await generateUser({balance: 10}); + }); + + it('returns an error if password is wrong', async () => { + await expect(user.del('/user', { + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), }); }); - it('deletes party when user is the only member', async () => { + it('returns an error if password 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 the user', async () => { await user.del('/user', { password, }); - await expect(checkExistence('party', party._id)).to.eventually.eql(false); + await expect(checkExistence('users', user._id)).to.eventually.eql(false); }); - }); - context('last member of a private guild', () => { - let privateGuild; + it('returns an error if excessive feedback is supplied', async () => { + let feedbackText = 'spam feedback '; + let feedback = feedbackText; + while (feedback.length < 10000) { + feedback = feedback + feedbackText; + } - beforeEach(async () => { - privateGuild = await generateGroup(user, { - type: 'guild', - privacy: 'private', + await expect(user.del('/user', { + password, + feedback, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Account deletion feedback is limited to 10,000 characters. For lengthy feedback, email admin@habitica.com.', }); }); - it('deletes guild when user is the only member', async () => { + it('returns an error if user has active subscription', async () => { + let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); + + await expect(userWithSubscription.del('/user', { + password, + })).to.be.rejected.and.to.eventually.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cannotDeleteActiveAccount'), + }); + }); + + it('deletes the user\'s tasks', async () => { + // gets the user's tasks ids + let ids = []; + each(user.tasksOrder, (idsForOrder) => { + ids.push(...idsForOrder); + }); + + expect(ids.length).to.be.above(0); // make sure the user has some task to delete + await user.del('/user', { password, }); - await expect(checkExistence('groups', privateGuild._id)).to.eventually.eql(false); + + await Bluebird.all(map(ids, id => { + return expect(checkExistence('tasks', id)).to.eventually.eql(false); + })); }); - }); - context('groups user is leader of', () => { - let guild, oldLeader, newLeader; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 1, + it('reduces memberCount in challenges user is linked to', async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 2, }); - guild = group; - newLeader = members[0]; - oldLeader = groupLeader; - }); + let group = populatedGroup.group; + let authorizedUser = populatedGroup.members[1]; - it('chooses new group leader for any group user was the leader of', async () => { - await oldLeader.del('/user', { + let challenge = await generateChallenge(populatedGroup.groupLeader, group); + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + + expect(challenge.memberCount).to.eql(2); + + await authorizedUser.del('/user', { password, }); - let updatedGuild = await newLeader.get(`/groups/${guild._id}`); + await challenge.sync(); - expect(updatedGuild.leader).to.exist; - expect(updatedGuild.leader._id).to.not.eql(oldLeader._id); - }); - }); - - context('groups user is a part of', () => { - let group1, group2, userToDelete, otherUser; - - beforeEach(async () => { - userToDelete = await generateUser({balance: 10}); - - group1 = await generateGroup(userToDelete, { - type: 'guild', - privacy: 'public', - }); - - let {group, members} = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 3, - }); - - group2 = group; - otherUser = members[0]; - - await userToDelete.post(`/groups/${group2._id}/join`); + expect(challenge.memberCount).to.eql(1); }); - it('removes user from all groups user was a part of', async () => { - await userToDelete.del('/user', { + it('sends feedback to the admin email', async () => { + sandbox.spy(email, 'sendTxn'); + + let feedback = 'Reasons for Deletion'; + await user.del('/user', { + password, + feedback, + }); + + expect(email.sendTxn).to.be.calledOnce; + + sandbox.restore(); + }); + + it('does not send email if no feedback is supplied', async () => { + sandbox.spy(email, 'sendTxn'); + + await user.del('/user', { password, }); - let updatedGroup1Members = await otherUser.get(`/groups/${group1._id}/members`); - let updatedGroup2Members = await otherUser.get(`/groups/${group2._id}/members`); - let userInGroup = find(updatedGroup2Members, (member) => { - return member._id === userToDelete._id; + expect(email.sendTxn).to.not.be.called; + + sandbox.restore(); + }); + + it('deletes the user with a legacy sha1 password', async () => { + let textPassword = 'mySecretPassword'; + let salt = sha1MakeSalt(); + let sha1HashedPassword = sha1EncryptPassword(textPassword, salt); + + await user.update({ + 'auth.local.hashed_password': sha1HashedPassword, + 'auth.local.passwordHashMethod': 'sha1', + 'auth.local.salt': salt, }); - expect(updatedGroup1Members).to.be.empty; - expect(updatedGroup2Members).to.not.be.empty; - expect(userInGroup).to.not.exist; + await user.sync(); + + expect(user.auth.local.passwordHashMethod).to.equal('sha1'); + expect(user.auth.local.salt).to.equal(salt); + expect(user.auth.local.hashed_password).to.equal(sha1HashedPassword); + + // delete the user + await user.del('/user', { + password: textPassword, + }); + await expect(checkExistence('users', user._id)).to.eventually.eql(false); + }); + + context('last member of a party', () => { + let party; + + beforeEach(async () => { + party = await generateGroup(user, { + type: 'party', + privacy: 'private', + }); + }); + + it('deletes party when user is the only member', async () => { + await user.del('/user', { + password, + }); + await expect(checkExistence('party', party._id)).to.eventually.eql(false); + }); + }); + + context('last member of a private guild', () => { + let privateGuild; + + beforeEach(async () => { + privateGuild = await generateGroup(user, { + type: 'guild', + privacy: 'private', + }); + }); + + it('deletes guild when user is the only member', async () => { + await user.del('/user', { + password, + }); + await expect(checkExistence('groups', privateGuild._id)).to.eventually.eql(false); + }); + }); + + context('groups user is leader of', () => { + let guild, oldLeader, newLeader; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + guild = group; + newLeader = members[0]; + oldLeader = groupLeader; + }); + + it('chooses new group leader for any group user was the leader of', async () => { + await oldLeader.del('/user', { + password, + }); + + let updatedGuild = await newLeader.get(`/groups/${guild._id}`); + + expect(updatedGuild.leader).to.exist; + expect(updatedGuild.leader._id).to.not.eql(oldLeader._id); + }); + }); + + context('groups user is a part of', () => { + let group1, group2, userToDelete, otherUser; + + beforeEach(async () => { + userToDelete = await generateUser({balance: 10}); + + group1 = await generateGroup(userToDelete, { + type: 'guild', + privacy: 'public', + }); + + let {group, members} = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 3, + }); + + group2 = group; + otherUser = members[0]; + + await userToDelete.post(`/groups/${group2._id}/join`); + }); + + it('removes user from all groups user was a part of', async () => { + await userToDelete.del('/user', { + password, + }); + + let updatedGroup1Members = await otherUser.get(`/groups/${group1._id}/members`); + let updatedGroup2Members = await otherUser.get(`/groups/${group2._id}/members`); + let userInGroup = find(updatedGroup2Members, (member) => { + return member._id === userToDelete._id; + }); + + expect(updatedGroup1Members).to.be.empty; + expect(updatedGroup2Members).to.not.be.empty; + expect(userInGroup).to.not.exist; + }); + }); + }); + + 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'), + }); + }); + + 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({ + auth: { + google: { + id: 'google-id', + }, + }, + }); + }); + + it('deletes a Google user', async () => { + await user.del('/user', { + password: DELETE_CONFIRMATION, + }); + await expect(checkExistence('users', user._id)).to.eventually.eql(false); }); }); }); diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index d038801f3f..bb67363a7c 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -246,6 +246,7 @@ "missingNewPassword": "Missing new password.", "invalidEmailDomain": "You cannot register with emails with the following domains: <%= domains %>", "wrongPassword": "Wrong password.", + "incorrectDeletePhrase": "Please type DELETE in all caps to delete your account.", "notAnEmail": "Invalid email address.", "emailTaken": "Email address is already used in an account.", "newEmailRequired": "Missing new email address.", diff --git a/website/common/locales/en/settings.json b/website/common/locales/en/settings.json index 3fba9d8a70..ced96e29ef 100644 --- a/website/common/locales/en/settings.json +++ b/website/common/locales/en/settings.json @@ -66,6 +66,7 @@ "resetText1": "WARNING! This resets many parts of your account. This is highly discouraged, but some people find it useful in the beginning after playing with the site for a short time.", "resetText2": "You will lose all your levels, gold, and experience points. All your tasks (except those from challenges) will be deleted permanently and you will lose all of their historical data. You will lose all your equipment but you will be able to buy it all back, including all limited edition equipment or subscriber Mystery items that you already own (you will need to be in the correct class to re-buy class-specific gear). You will keep your current class and your pets and mounts. You might prefer to use an Orb of Rebirth instead, which is a much safer option and which will preserve your tasks and equipment.", "deleteLocalAccountText": "Are you sure? This will delete your account forever, and it can never be restored! You will need to register a new account to use Habitica again. Banked or spent Gems will not be refunded. If you're absolutely certain, type your password into the text box below.", + "deleteSocialAccountText": "Are you sure? This will delete your account forever, and it can never be restored! You will need to register a new account to use Habitica again. Banked or spent Gems will not be refunded. If you're absolutely certain, type \"DELETE\" into the text box below.", "API": "API", "APIv3": "API v3", "APIText": "Copy these for use in third party applications. However, think of your API Token like a password, and do not share it publicly. You may occasionally be asked for your User ID, but never post your API Token where others can see it, including on Github.", diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index bbc892ce52..ee61b8a08b 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -22,6 +22,7 @@ import nconf from 'nconf'; import get from 'lodash/get'; const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL'); +const DELETE_CONFIRMATION = 'DELETE'; /** * @apiDefine UserNotFound @@ -303,15 +304,16 @@ api.deleteUser = { let password = req.body.password; if (!password) throw new BadRequest(res.t('missingPassword')); + if (user.auth.local.hashed_password && user.auth.local.email) { + let isValidPassword = await passwordUtils.compare(user, password); + if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword')); + } else if ((user.auth.facebook.id || user.auth.google.id) && password !== DELETE_CONFIRMATION) { + throw new NotAuthorized(res.t('incorrectDeletePhrase')); + } + let feedback = req.body.feedback; if (feedback && feedback.length > 10000) throw new BadRequest(`Account deletion feedback is limited to 10,000 characters. For lengthy feedback, email ${TECH_ASSISTANCE_EMAIL}.`); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let isValidPassword = await passwordUtils.compare(user, password); - if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword')); - if (plan && plan.customerId && !plan.dateTerminated) { throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); } diff --git a/website/views/options/settings/settings.jade b/website/views/options/settings/settings.jade index 1f5a1b7e81..e3560ef2d5 100644 --- a/website/views/options/settings/settings.jade +++ b/website/views/options/settings/settings.jade @@ -183,4 +183,5 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') span=env.t('dangerZone') .panel-body a.btn.btn-danger(ng-click='openModal("reset", {controller:"SettingsCtrl"})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('resetAccPop'))= env.t('resetAccount') - a.btn.btn-danger(ng-click='openModal("delete", {controller:"SettingsCtrl"})', popover-trigger='mouseenter', popover=env.t('deleteAccPop'))= env.t('deleteAccount') + a.btn.btn-danger(ng-if='user.auth.local.email' ng-click='openModal("deletelocal", {controller:"SettingsCtrl"})', popover-trigger='mouseenter', popover=env.t('deleteAccPop'))= env.t('deleteAccount') + a.btn.btn-danger(ng-if='!user.auth.local.email', ng-click='openModal("deletesocial", {controller:"SettingsCtrl"})', popover-trigger='mouseenter', popover=env.t('deleteAccPop'))= env.t('deleteAccount') \ No newline at end of file diff --git a/website/views/shared/modals/settings.jade b/website/views/shared/modals/settings.jade index d100c8a36e..07dcf65b56 100644 --- a/website/views/shared/modals/settings.jade +++ b/website/views/shared/modals/settings.jade @@ -57,7 +57,7 @@ script(type='text/ng-template', id='modals/restore.html') button.btn.btn-default(ng-click='$close()')=env.t('discardChanges') button.btn.btn-primary(ng-click='restore()')=env.t('saveAndClose') -script(type='text/ng-template', id='modals/delete.html') +script(type='text/ng-template', id='modals/deletelocal.html') .modal-header h4=env.t('deleteAccount') .modal-body @@ -74,3 +74,16 @@ script(type='text/ng-template', id='modals/delete.html') .modal-footer button.btn.btn-default(ng-click='$close()')=env.t('neverMind') button.btn.btn-danger(ng-disabled='!_deleteAccount', ng-click='$close(); delete(_deleteAccount, feedback)')=env.t('deleteDo') + +script(type='text/ng-template', id='modals/deletesocial.html') + .modal-header + h4=env.t('deleteAccount') + .modal-body + p!=env.t('deleteSocialAccountText') + br + .row + .col-md-6 + input.form-control(type='text', ng-model='_deleteAccount') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('neverMind') + button.btn.btn-danger(ng-disabled='!_deleteAccount', ng-click='$close(); delete(_deleteAccount, feedback)')=env.t('deleteDo')