Delete Account with Social Auth (#8796)

* feat(accounts): delete social accts

* test(integration): social auth delete
This commit is contained in:
Sabe Jones
2017-07-21 10:55:53 -07:00
committed by GitHub
parent 88fece1422
commit e6dd0d5e82
6 changed files with 312 additions and 232 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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