Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invalidate sessions after email change #2693

Merged
merged 1 commit into from
Sep 19, 2016
Merged

Invalidate sessions after email change #2693

merged 1 commit into from
Sep 19, 2016

Conversation

loay
Copy link
Contributor

@loay loay commented Aug 30, 2016

connect to https://github.com/strongloop-internal/scrum-loopback/issues/924

  • Delete all tokens of the current user after an email was changed.
  • The currently active token should not be deleted.

@loay loay added the #review label Aug 30, 2016
if (ctx.instance) {
var oldEmail = ctx.instance.email;
}
UserModel.observe('after save', function(ctx, next) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will add a new observer after whenever a save operation is called. That won't work.

The correct solution is to store oldEmail in ctx.hookState instead of creating a local closure.

// in before save
ctx.hookState.oldEmail = ctx.instance.email

// in after save
var oldEmail = ctx.hookState.oldEmail

Having said that, I am afraid we don't have any easy way how to detect when a field was changed.

The only reliable solution is to query the database for the old version and compare it with the data being persisted.

@bajtos
Copy link
Member

bajtos commented Sep 1, 2016

Please test also that sessions are not invalidated when email was not changed.

Both test cases should be run for all operations changing data (create, updateAttributes, updateOrCreate, replaceAttributes, replaceOrCreate).

@bajtos
Copy link
Member

bajtos commented Sep 1, 2016

Most of the concerns from #2665 (comment) apply here too:

  • Please add a unit-test to verify that access tokens of other users are not changed
  • Token removal should be configurable, i.e. the user should be able to decide whether they want to log out all other sessions or not
  • The currently active token should not be deleted.

@loay
Copy link
Contributor Author

loay commented Sep 5, 2016

@bajtos PTAL. Thanks.

@@ -668,6 +668,45 @@ module.exports = function(User) {
next();
});

// Delete old sessions once email is updated
var oldEmail, newEmail, oldId, newId;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variables are shared among all invocations of your hook. What will happen when there are two "save" operations in progress at the same time?

@bajtos
Copy link
Member

bajtos commented Sep 5, 2016

@loay the implementation looks better now. I think your tests are not covering two important cases: User.create and User.updateOrCreate when a new user is created.

Also the part The currently active token should not be deleted. is not implemented yet, but let's leave that as the second step in this pull request, do it only after the rest is already done and reviewed.

function secondLoginWithOriginalEmail(next) {
User.login({
email: currentEmailCredentials.email,
password: currentEmailCredentials.password },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@loay
Copy link
Contributor Author

loay commented Sep 14, 2016

@bajtos @rmg
About keeping the currently active token, I am not sure if I agree about that. Some apps usually give the option for passwords change, but for email, which is the username as well, I think all sessions including the current active one should be deleted. It is not too annoying to be done, and the user will psychologically feel safer when they get to log back in.

@loay
Copy link
Contributor Author

loay commented Sep 15, 2016

@bajtos PTAL. Thanks
Actually I removed extend function refactoring, as it was failing on several modules, so removed it but did other refactoring.

@loay
Copy link
Contributor Author

loay commented Sep 15, 2016

@slnode test please

@bajtos
Copy link
Member

bajtos commented Sep 16, 2016

About keeping the currently active token, I am not sure if I agree about that. Some apps usually give the option for passwords change, but for email, which is the username as well, I think all sessions including the current active one should be deleted. It is not too annoying to be done, and the user will psychologically feel safer when they get to log back in.

Fair enough. We can always implement that later, if/when there is user demand.

});
} else if (ctx.instance) {
ctx.hookState.id = ctx.instance.id;
var oldId = ctx.hookState.id;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, ctx.hookState.id is not used anywhere else, please remove.

Also the name oldId is a bit misleading. The id is (almost) never changed, therefore there is no such thing as "old" and "new" id.

I am proposing to use id or userId instead.

return { id: u.id, email: u.email };
});
}
next();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again. This next is misplaced, you need to call it from inside Model.find callback.

Considering that both branches use the same callback, I am suggesting the following implementation:

var where = ctx.where || { id: ctx.instance.id }; // feel free to add more safety checks
ctx.Model.find({ where: where }, function(err, userInstances) {
  if (err) return next(err);
  ctx.hookState.originalUserData = userInstances.map(function(u) {
    return { id: u.id, email: u.email };
  });
  next();
});

UserModel.observe('after save', function afterEmailUpdate(ctx, next) {
if (!ctx.Model.relations.accessTokens) return next();
var AccessToken = ctx.Model.relations.accessTokens.modelTo;
var newEmail = (ctx.instance || ctx.data).email;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two spaces after var, use one only.

var newEmail = (ctx.instance || ctx.data).email;
if (!ctx.hookState.originalUserData) return next();
var idsToExpire = ctx.hookState.originalUserData.filter(function(u) {
return u.email !== newEmail;
Copy link
Member

@bajtos bajtos Sep 16, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we are making a partial update and email is not changed at all?

user.updateAttributes({ age: user.age+1 });

newEmail is undefined, u.email is the existing email, which means you will incorrectly invalidate all access tokens. What I find surprising is that you already have a test covering this path (here). How comes it's passing? What am I missing?

Implementation wise, I think we should detect this situation early in "before save" hook to avoid unnecessary query.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 4 unit tests of keeps sessions AS IS are testing this case with different ways of update. newEmail is not undefined. It is the current/existing email or ([email protected]). A previous implementation was generating an undefined value but that was fixed.

});
if (!idsToExpire.length) return next();
AccessToken.deleteAll({ userId: { inq: idsToExpire }}, function(err, info) {
if (err) return next(err);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to be so verbose, just pass next as the callback of deleteAll.

@@ -48,6 +48,7 @@ describe('User', function() {
User = app.registry.createModel('TestUser', {}, {
base: 'User',
http: { path: 'test-users' },
forceId: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment explaining why we need forceId:false.

});
});

afterEach('Deleting original user', function deleteOriginalAccounts(done) {
Copy link
Member

@bajtos bajtos Sep 16, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete current user

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I am not sure if it's necessary to delete these test user instances. If you think it is important, then please add another hook to delete any remaining access tokens too.

});
},
], function(err) {
done();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled err, you need done(err). Even better, simply pass done as the callback argument of async.series.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed, PTAL

});
},
], function(err) {
done();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled err, pass done as the callback of async.series.

});
},
], function(err) {
done();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled err, pass done as the callback of async.series.

User.login(newUserCred, function(err, newAccessToken) {
if (err) return done(err);
assert(newAccessToken.id);
assertPreservedToken(next);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check does not make sense to me. Of course the new access token is preserved, we didn't make any changes in User data.

I think you need to verify that access tokens of the first (original) user were not changed by User.create.

  1. Create a new User
  2. Verify that existing access tokens were not deleted

User.login(newUserCred, function(err, newAccessToken2) {
if (err) return done(err);
assert(newAccessToken2.id);
assertPreservedToken(next);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto, this check does not make sense to me.

function(next) {
User.login(
{ email: '[email protected]', password: 'u1pass' },
function(err, accessToken1) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unhandled err

function(err, accessToken1) {
User.login(
{ email: '[email protected]', password: 'u2pass' },
function(err, accessToken2) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

});
});

describe('when where object contains only the name', function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is name so special? I think this describe block is extraneous, just use it('invalidates sessions after using updateAll') without nesting it it another describe block.

next();
});
},
function(next) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please give a name to this function, e.g. verifyTokensOfSpecialUser.

next();
});
},
function(next) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there :)

@@ -709,7 +698,7 @@ module.exports = function(User) {
});
if (!idsToExpire.length) return next();
AccessToken.deleteAll({ userId: { inq: idsToExpire }}, function(err, info) {
if (err) return next(err);
if (err) return next();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is dropping (ignoring) any deleteAll errors. Is that intentional?

I would personally use the following implementation:

AccessToken.deleteAll({ userId: { inq: idsToExpire }}, next);

User.deleteById(user.id, function(err, user) {
if (err) return done (err);
done();
done(err);
Copy link
Member

@bajtos bajtos Sep 19, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is no need to create a dummy function forwarding err to done. You can write:

], done);

Unless I am missing something?

This applies to many other places below too.

@@ -1807,9 +1809,10 @@ describe('User', function() {

describe('Email Update', function() {
describe('User changing email property', function() {
var user;
var user, originalUserToken1, originalUserToken2, newUserCreated, userSpecial, userNormal;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, userSpecial and userNormal are used only in a single test only. Could you please move the declaration of these two variables down to the test case?

AccessToken.find({ where: { userId: user.id }}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(2);
assert(originalUserToken1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the intention of this assert? The contents of originalUserToken1 should not change between the time it was initialized and the time you run the assert here. Did you perhaps want to assert that tokens contains both originalUserToken1 and originalUserToken2?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes absolutely. I didn't just want to make sure that the 2 tokens are still there, but also the exact same tokens.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am tweaking the code a bit to ensure it has both tokens.

next();
});
});
UserModel.observe('after save', function afterEmailUpdate(ctx, next) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add an empty line between UserModel.observe blocks to visually delimit them.

UserModel.observe('before save', .
});

UserModel.observe('after save', ...
});

});
});


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too many empty lines, keep one empty line only.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, please squash the commits before landing.

@loay loay merged commit 8061d12 into master Sep 19, 2016
@loay loay deleted the sessEmail branch September 19, 2016 17:54
@loay loay removed the #review label Sep 19, 2016
@doublemarked
Copy link

We've discovered this new behavior after bumping Loopback versions. Unfortunately it was not caught by our automated tests (though in the future it will be).

I realize this was in response to a security issue, but I feel like somehow there could be a better process to introducing breaking changes. Maybe at least clear announcements/notifications when things like this are landing? How do we as developers guard against changes like this breaking our software?

@doublemarked
Copy link

doublemarked commented Dec 15, 2016

I guess one more thing... @bajtos you wrote,

Also the part The currently active token should not be deleted. is not implemented yet, but let's leave that as the second step in this pull request, do it only after the rest is already done and reviewed.

This is precisely the part that is breaking existing code, because it requires us to rework our frontend to accommodate tokens that suddenly get invalidated. Will such a fix be anticipated in the future? We're trying to figure out whether we should be reworking our frontend to anticipate/explain why the user suddenly needs to reauthenticate after changing their email address, or whether we can hold out on 2.34.1 until such a fix lands.

@bajtos
Copy link
Member

bajtos commented Dec 16, 2016

Hi @doublemarked, I apologise for the troubles we have caused.

I realize this was in response to a security issue, but I feel like somehow there could be a better process to introducing breaking changes. Maybe at least clear announcements/notifications when things like this are landing? How do we as developers guard against changes like this breaking our software?

I believe we are announcing security-related fixes, @loay @rmg @gunjpan could one of you please provide more information on the channels we are using? Did we announce this patch?

@doublemarked do you have any suggestion/advise on what channel would work best for developers like you? I think it may be best to discuss this with broader community, would you mind starting the discussion in a new github issue?

This is precisely the part that is breaking existing code, because it requires us to rework our frontend to accommodate tokens that suddenly get invalidated. Will such a fix be anticipated in the future? We're trying to figure out whether we should be reworking our frontend to anticipate/explain why the user suddenly needs to reauthenticate after changing their email address, or whether we can hold out on 2.34.1 until such a fix lands.

I agree the current situation, where a change of email or password (see #3021) logs out the user, creates a rather poor experience and we should fix this soon. One of the reasons why this was not made in the original pull requests, is that we don't have a good solution for getting the current access token. The easiest way forward is to wait for #3023 to land, after which we can use ctx.options.accessToken to exclude the current access token from the delete query.

Let's create a new issue so that this is not forgotten: #3034

@doublemarked
Copy link

Thank you for the reply, @bajtos. I do see/understand how #3023 is a barrier for the correct behavior. I need to catch up on the communication there, but I'm happy to see you prioritizing it. We will probably hang on at v2.34.1 for now and monitor how #3023 and #3034 progress.

Regarding communication channels - sure, great idea. I've opened #3042 on this topic.

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants