-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Conversation
if (ctx.instance) { | ||
var oldEmail = ctx.instance.email; | ||
} | ||
UserModel.observe('after save', function(ctx, next) { |
There was a problem hiding this comment.
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.
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). |
Most of the concerns from #2665 (comment) apply here too:
|
@bajtos PTAL. Thanks. |
@@ -668,6 +668,45 @@ module.exports = function(User) { | |||
next(); | |||
}); | |||
|
|||
// Delete old sessions once email is updated | |||
var oldEmail, newEmail, oldId, newId; |
There was a problem hiding this comment.
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?
@loay the implementation looks better now. I think your tests are not covering two important cases: Also the part |
function secondLoginWithOriginalEmail(next) { | ||
User.login({ | ||
email: currentEmailCredentials.email, | ||
password: currentEmailCredentials.password }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
coding style: one arg per line. see https://github.com/strongloop/loopback-contributor-docs/blob/master/style-guide.md#one-arg-per-line
@bajtos @rmg |
@bajtos PTAL. Thanks |
@slnode test please |
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; |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
delete current user
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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
.
There was a problem hiding this 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(); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
.
- Create a new User
- Verify that existing access tokens were not deleted
User.login(newUserCred, function(err, newAccessToken2) { | ||
if (err) return done(err); | ||
assert(newAccessToken2.id); | ||
assertPreservedToken(next); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto
There was a problem hiding this 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(); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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', ...
});
}); | ||
}); | ||
|
||
|
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
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? |
I guess one more thing... @bajtos you wrote,
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. |
Hi @doublemarked, I apologise for the troubles we have caused.
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?
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 Let's create a new issue so that this is not forgotten: #3034 |
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! |
connect to https://github.com/strongloop-internal/scrum-loopback/issues/924
The currently active token should not be deleted.