Invalidate AccessTokens on password change

Invalidate all existing sessions (delete all access tokens)
after user's password was changed.
This commit is contained in:
Miroslav Bajtoš 2016-12-09 15:36:54 +01:00
parent 66e4e5be4a
commit 4ee086dcd0
2 changed files with 343 additions and 306 deletions

View File

@ -645,6 +645,19 @@ module.exports = function(User) {
err.statusCode = 422; err.statusCode = 422;
throw err; throw err;
}; };
User._invalidateAccessTokensOfUsers = function(userIds, cb) {
if (!Array.isArray(userIds) || !userIds.length)
return process.nextTick(cb);
var accessTokenRelation = this.relations.accessTokens;
if (!accessTokenRelation)
return process.nextTick(cb);
var AccessToken = accessTokenRelation.modelTo;
AccessToken.deleteAll({userId: {inq: userIds}}, cb);
};
/*! /*!
* Setup an extended user model. * Setup an extended user model.
*/ */
@ -809,8 +822,20 @@ module.exports = function(User) {
var emailChanged; var emailChanged;
if (ctx.isNewInstance) return next(); if (ctx.isNewInstance) return next();
if (!ctx.where && !ctx.instance) return next(); if (!ctx.where && !ctx.instance) return next();
var where = ctx.where || { id: ctx.instance.id };
ctx.Model.find({ where: where }, function(err, userInstances) { var isPartialUpdateChangingPassword = ctx.data && 'password' in ctx.data;
// Full replace of User instance => assume password change.
// HashPassword returns a different value for each invocation,
// therefore we cannot tell whether ctx.instance.password is the same
// or not.
var isFullReplaceChangingPassword = !!ctx.instance;
ctx.hookState.isPasswordChange = isPartialUpdateChangingPassword ||
isFullReplaceChangingPassword;
var where = ctx.where || {id: ctx.instance.id};
ctx.Model.find({where: where}, function(err, userInstances) {
if (err) return next(err); if (err) return next(err);
ctx.hookState.originalUserData = userInstances.map(function(u) { ctx.hookState.originalUserData = userInstances.map(function(u) {
return { id: u.id, email: u.email }; return { id: u.id, email: u.email };
@ -828,24 +853,26 @@ module.exports = function(User) {
ctx.data.emailVerified = false; ctx.data.emailVerified = false;
} }
} }
next(); next();
}); });
}); });
User.observe('after save', function afterEmailUpdate(ctx, next) { User.observe('after save', function afterEmailUpdate(ctx, next) {
if (!ctx.Model.relations.accessTokens) return next();
var AccessToken = ctx.Model.relations.accessTokens.modelTo;
if (!ctx.instance && !ctx.data) return next(); if (!ctx.instance && !ctx.data) return next();
var newEmail = (ctx.instance || ctx.data).email;
if (!newEmail) return next();
if (!ctx.hookState.originalUserData) return next(); if (!ctx.hookState.originalUserData) return next();
var idsToExpire = ctx.hookState.originalUserData.filter(function(u) {
return u.email !== newEmail; var newEmail = (ctx.instance || ctx.data).email;
var isPasswordChange = ctx.hookState.isPasswordChange;
if (!newEmail && !isPasswordChange) return next();
var userIdsToExpire = ctx.hookState.originalUserData.filter(function(u) {
return (newEmail && u.email !== newEmail) || isPasswordChange;
}).map(function(u) { }).map(function(u) {
return u.id; return u.id;
}); });
if (!idsToExpire.length) return next(); ctx.Model._invalidateAccessTokensOfUsers(userIdsToExpire, next);
AccessToken.deleteAll({ userId: { inq: idsToExpire }}, next);
}); });
}; };

View File

@ -1951,12 +1951,11 @@ describe('User', function() {
}); });
}); });
describe('Email Update', function() { describe('AccessToken (session) invalidation', function() {
describe('User changing email property', function() {
var user, originalUserToken1, originalUserToken2, newUserCreated; var user, originalUserToken1, originalUserToken2, newUserCreated;
var currentEmailCredentials = { email: 'original@example.com', password: 'bar' }; var currentEmailCredentials = {email: 'original@example.com', password: 'bar'};
var updatedEmailCredentials = { email: 'updated@example.com', password: 'bar' }; var updatedEmailCredentials = {email: 'updated@example.com', password: 'bar'};
var newUserCred = { email: 'newuser@example.com', password: 'newpass' }; var newUserCred = {email: 'newuser@example.com', password: 'newpass'};
beforeEach('create user then login', function createAndLogin(done) { beforeEach('create user then login', function createAndLogin(done) {
async.series([ async.series([
@ -1988,15 +1987,17 @@ describe('User', function() {
it('invalidates sessions when email is changed using `updateAttributes`', function(done) { it('invalidates sessions when email is changed using `updateAttributes`', function(done) {
user.updateAttributes( user.updateAttributes(
{ email: updatedEmailCredentials.email }, {email: updatedEmailCredentials.email},
function(err, userInstance) { function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertNoAccessTokens(done); assertNoAccessTokens(done);
}); });
}); });
it('invalidates sessions when email is changed using `replaceAttributes`', function(done) { it('invalidates sessions after `replaceAttributes`', function(done) {
user.replaceAttributes(updatedEmailCredentials, function(err, userInstance) { // The way how the invalidation is implemented now, all sessions
// are invalidated on a full replace
user.replaceAttributes(currentEmailCredentials, function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertNoAccessTokens(done); assertNoAccessTokens(done);
}); });
@ -2006,25 +2007,28 @@ describe('User', function() {
User.updateOrCreate({ User.updateOrCreate({
id: user.id, id: user.id,
email: updatedEmailCredentials.email, email: updatedEmailCredentials.email,
password: updatedEmailCredentials.password,
}, function(err, userInstance) { }, function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertNoAccessTokens(done); assertNoAccessTokens(done);
}); });
}); });
it('invalidates sessions when the email is changed using `replaceById`', function(done) { it('invalidates sessions after `replaceById`', function(done) {
User.replaceById(user.id, updatedEmailCredentials, function(err, userInstance) { // The way how the invalidation is implemented now, all sessions
// are invalidated on a full replace
User.replaceById(user.id, currentEmailCredentials, function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertNoAccessTokens(done); assertNoAccessTokens(done);
}); });
}); });
it('invalidates sessions when the email is changed using `replaceOrCreate`', function(done) { it('invalidates sessions after `replaceOrCreate`', function(done) {
// The way how the invalidation is implemented now, all sessions
// are invalidated on a full replace
User.replaceOrCreate({ User.replaceOrCreate({
id: user.id, id: user.id,
email: updatedEmailCredentials.email, email: currentEmailCredentials.email,
password: updatedEmailCredentials.password, password: currentEmailCredentials.password,
}, function(err, userInstance) { }, function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertNoAccessTokens(done); assertNoAccessTokens(done);
@ -2032,18 +2036,7 @@ describe('User', function() {
}); });
it('keeps sessions AS IS if firstName is added using `updateAttributes`', function(done) { it('keeps sessions AS IS if firstName is added using `updateAttributes`', function(done) {
user.updateAttributes({ 'firstName': 'Janny' }, function(err, userInstance) { user.updateAttributes({'firstName': 'Janny'}, function(err, userInstance) {
if (err) return done(err);
assertUntouchedTokens(done);
});
});
it('keeps sessions AS IS if firstName is added using `replaceAttributes`', function(done) {
user.replaceAttributes({
email: currentEmailCredentials.email,
password: currentEmailCredentials.password,
firstName: 'Candy',
}, function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertUntouchedTokens(done); assertUntouchedTokens(done);
}); });
@ -2054,20 +2047,6 @@ describe('User', function() {
id: user.id, id: user.id,
firstName: 'Loay', firstName: 'Loay',
email: currentEmailCredentials.email, email: currentEmailCredentials.email,
password: currentEmailCredentials.password,
}, function(err, userInstance) {
if (err) return done(err);
assertUntouchedTokens(done);
});
});
it('keeps sessions AS IS if firstName is added using `replaceById`', function(done) {
User.replaceById(
user.id,
{
firstName: 'Miroslav',
email: currentEmailCredentials.email,
password: currentEmailCredentials.password,
}, function(err, userInstance) { }, function(err, userInstance) {
if (err) return done(err); if (err) return done(err);
assertUntouchedTokens(done); assertUntouchedTokens(done);
@ -2087,7 +2066,7 @@ describe('User', function() {
User.login(newUserCred, function(err, newAccessToken) { User.login(newUserCred, function(err, newAccessToken) {
if (err) return done(err); if (err) return done(err);
assert(newAccessToken.id); assert(newAccessToken.id);
assertPreservedToken(next); assertPreservedTokens(next);
}); });
}, },
], done); ], done);
@ -2106,7 +2085,7 @@ describe('User', function() {
User.login(newUserCred, function(err, newAccessToken2) { User.login(newUserCred, function(err, newAccessToken2) {
if (err) return done(err); if (err) return done(err);
assert(newAccessToken2.id); assert(newAccessToken2.id);
assertPreservedToken(next); assertPreservedTokens(next);
}); });
}, },
], done); ], done);
@ -2117,7 +2096,7 @@ describe('User', function() {
async.series([ async.series([
function createPartialUser(next) { function createPartialUser(next) {
User.create( User.create(
{ email: 'partial@example.com', password: 'pass1', age: 25 }, {email: 'partial@example.com', password: 'pass1', age: 25},
function(err, partialInstance) { function(err, partialInstance) {
if (err) return next(err); if (err) return next(err);
userPartial = partialInstance; userPartial = partialInstance;
@ -2125,22 +2104,22 @@ describe('User', function() {
}); });
}, },
function loginPartiallUser(next) { function loginPartiallUser(next) {
User.login({ email: 'partial@example.com', password: 'pass1' }, function(err, ats) { User.login({email: 'partial@example.com', password: 'pass1'}, function(err, ats) {
if (err) return next(err); if (err) return next(err);
next(); next();
}); });
}, },
function updatePartialUser(next) { function updatePartialUser(next) {
User.updateAll( User.updateAll(
{ id: userPartial.id }, {id: userPartial.id},
{ age: userPartial.age + 1 }, {age: userPartial.age + 1},
function(err, info) { function(err, info) {
if (err) return next(err); if (err) return next(err);
next(); next();
}); });
}, },
function verifyTokensOfPartialUser(next) { function verifyTokensOfPartialUser(next) {
AccessToken.find({ where: { userId: userPartial.id }}, function(err, tokens1) { AccessToken.find({where: {userId: userPartial.id}}, function(err, tokens1) {
if (err) return next(err); if (err) return next(err);
expect(tokens1.length).to.equal(1); expect(tokens1.length).to.equal(1);
next(); next();
@ -2149,43 +2128,15 @@ describe('User', function() {
], done); ], done);
}); });
function assertPreservedToken(done) {
AccessToken.find({ where: { userId: user.id }}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(2);
expect([tokens[0].id, tokens[1].id]).to.have.members([originalUserToken1,
originalUserToken2]);
done();
});
}
function assertNoAccessTokens(done) {
AccessToken.find({ where: { userId: user.id }}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(0);
done();
});
}
function assertUntouchedTokens(done) {
AccessToken.find({ where: { userId: user.id }}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(2);
done();
});
}
});
describe('User not changing email property', function() {
var user1, user2, user3;
it('preserves other users\' sessions if their email is untouched', function(done) { it('preserves other users\' sessions if their email is untouched', function(done) {
var user1, user2, user3;
async.series([ async.series([
function(next) { function(next) {
User.create({ email: 'user1@example.com', password: 'u1pass' }, function(err, u1) { User.create({email: 'user1@example.com', password: 'u1pass'}, function(err, u1) {
if (err) return done(err); if (err) return done(err);
User.create({ email: 'user2@example.com', password: 'u2pass' }, function(err, u2) { User.create({email: 'user2@example.com', password: 'u2pass'}, function(err, u2) {
if (err) return done(err); if (err) return done(err);
User.create({ email: 'user3@example.com', password: 'u3pass' }, function(err, u3) { User.create({email: 'user3@example.com', password: 'u3pass'}, function(err, u3) {
if (err) return done(err); if (err) return done(err);
user1 = u1; user1 = u1;
user2 = u2; user2 = u2;
@ -2197,14 +2148,14 @@ describe('User', function() {
}, },
function(next) { function(next) {
User.login( User.login(
{ email: 'user1@example.com', password: 'u1pass' }, {email: 'user1@example.com', password: 'u1pass'},
function(err, accessToken1) { function(err, accessToken1) {
if (err) return next(err); if (err) return next(err);
User.login( User.login(
{ email: 'user2@example.com', password: 'u2pass' }, {email: 'user2@example.com', password: 'u2pass'},
function(err, accessToken2) { function(err, accessToken2) {
if (err) return next(err); if (err) return next(err);
User.login({ email: 'user3@example.com', password: 'u3pass' }, User.login({email: 'user3@example.com', password: 'u3pass'},
function(err, accessToken3) { function(err, accessToken3) {
if (err) return next(err); if (err) return next(err);
next(); next();
@ -2220,11 +2171,11 @@ describe('User', function() {
}); });
}, },
function(next) { function(next) {
AccessToken.find({ where: { userId: user1.id }}, function(err, tokens1) { AccessToken.find({where: {userId: user1.id}}, function(err, tokens1) {
if (err) return next(err); if (err) return next(err);
AccessToken.find({ where: { userId: user2.id }}, function(err, tokens2) { AccessToken.find({where: {userId: user2.id}}, function(err, tokens2) {
if (err) return next(err); if (err) return next(err);
AccessToken.find({ where: { userId: user3.id }}, function(err, tokens3) { AccessToken.find({where: {userId: user3.id}}, function(err, tokens3) {
if (err) return next(err); if (err) return next(err);
expect(tokens1.length).to.equal(1); expect(tokens1.length).to.equal(1);
@ -2237,9 +2188,8 @@ describe('User', function() {
}, },
], done); ], done);
}); });
});
it('invalidates sessions after using updateAll', function(done) { it('invalidates correct sessions after changing email using updateAll', function(done) {
var userSpecial, userNormal; var userSpecial, userNormal;
async.series([ async.series([
function createSpecialUser(next) { function createSpecialUser(next) {
@ -2251,27 +2201,12 @@ describe('User', function() {
next(); next();
}); });
}, },
function createNormaluser(next) {
User.create(
{ email: 'normal@example.com', password: 'pass2' },
function(err, normalInstance) {
if (err) return next(err);
userNormal = normalInstance;
next();
});
},
function loginSpecialUser(next) { function loginSpecialUser(next) {
User.login({ email: 'special@example.com', password: 'pass1' }, function(err, ats) { User.login({ email: 'special@example.com', password: 'pass1' }, function(err, ats) {
if (err) return next(err); if (err) return next(err);
next(); next();
}); });
}, },
function loginNormalUser(next) {
User.login({ email: 'normal@example.com', password: 'pass2' }, function(err, atn) {
if (err) return next(err);
next();
});
},
function updateSpecialUser(next) { function updateSpecialUser(next) {
User.updateAll( User.updateAll(
{ name: 'Special' }, { name: 'Special' },
@ -2281,21 +2216,96 @@ describe('User', function() {
}); });
}, },
function verifyTokensOfSpecialUser(next) { function verifyTokensOfSpecialUser(next) {
AccessToken.find({ where: { userId: userSpecial.id }}, function(err, tokens1) { AccessToken.find({where: {userId: userSpecial.id}}, function(err, tokens1) {
if (err) return done(err); if (err) return done(err);
expect(tokens1.length).to.equal(0); expect(tokens1.length, 'tokens - special user tokens').to.equal(0);
next();
});
},
function verifyTokensOfNormalUser(next) {
AccessToken.find({ userId: userNormal.userId }, function(err, tokens2) {
if (err) return done(err);
expect(tokens2.length).to.equal(1);
next(); next();
}); });
}, },
assertPreservedTokens,
], done); ], done);
}); });
it('invalidates session when password is reset', function(done) {
user.updateAttribute('password', 'newPass', function(err, user2) {
if (err) return done(err);
assertNoAccessTokens(done);
});
});
it('preserves other user sessions if their password is untouched', function(done) {
var user1, user2, user1Token;
async.series([
function(next) {
User.create({email: 'user1@example.com', password: 'u1pass'}, function(err, u1) {
if (err) return done(err);
User.create({email: 'user2@example.com', password: 'u2pass'}, function(err, u2) {
if (err) return done(err);
user1 = u1;
user2 = u2;
next();
});
});
},
function(next) {
User.login({email: 'user1@example.com', password: 'u1pass'}, function(err, at1) {
User.login({email: 'user2@example.com', password: 'u2pass'}, function(err, at2) {
assert(at1.userId);
assert(at2.userId);
user1Token = at1.id;
next();
});
});
},
function(next) {
user2.updateAttribute('password', 'newPass', function(err, user2Instance) {
if (err) return next(err);
assert(user2Instance);
next();
});
},
function(next) {
AccessToken.find({where: {userId: user1.id}}, function(err, tokens1) {
if (err) return next(err);
AccessToken.find({where: {userId: user2.id}}, function(err, tokens2) {
if (err) return next(err);
expect(tokens1.length).to.equal(1);
expect(tokens2.length).to.equal(0);
assert.equal(tokens1[0].id, user1Token);
next();
});
});
},
], function(err) {
done();
});
});
function assertPreservedTokens(done) {
AccessToken.find({where: {userId: user.id}}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(2);
expect([tokens[0].id, tokens[1].id]).to.have.members([originalUserToken1,
originalUserToken2]);
done();
});
}
function assertNoAccessTokens(done) {
AccessToken.find({where: {userId: user.id}}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(0);
done();
});
}
function assertUntouchedTokens(done) {
AccessToken.find({where: {userId: user.id}}, function(err, tokens) {
if (err) return done(err);
expect(tokens.length).to.equal(2);
done();
});
}
}); });
describe('Verification after updating email', function() { describe('Verification after updating email', function() {