From afa8afd9a993c742be6bf38f23bb0e7858eea8fb Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 21 Aug 2013 22:16:41 -0700 Subject: [PATCH 01/18] Keep track of open DDP connections by login token. --- packages/accounts-base/accounts_server.js | 10 +++--- packages/livedata/livedata_common.js | 11 ++++++ packages/livedata/livedata_server.js | 43 +++++++++++++++++++++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index cdcd995ace..4868b7c845 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -74,15 +74,17 @@ Meteor.methods({ var result = tryAllLoginHandlers(options); if (result !== null) { this.setUserId(result.id); - this._sessionData.loginToken = result.token; + this._setLoginToken(result.token); } return result; }, logout: function() { - if (this._sessionData.loginToken && this.userId) - removeLoginToken(this.userId, this._sessionData.loginToken); + var token = this._getLoginToken(); + if (token && this.userId) + removeLoginToken(this.userId, token); this.setUserId(null); + this._setLoginToken(null); } }); @@ -115,7 +117,7 @@ Accounts._generateStampedLoginToken = function () { return {token: Random.id(), when: (new Date)}; }; -removeLoginToken = function (userId, loginToken) { +var removeLoginToken = function (userId, loginToken) { Meteor.users.update(userId, { $pull: { "services.resume.loginTokens": { "token": loginToken } diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index b39b65e491..589465ccd9 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -29,6 +29,10 @@ MethodInvocation = function (options) { // reruns subscriptions this._setUserId = options.setUserId || function () {}; + // saves the login token so we can delete it later when the user logs out, is + // deleted, etc. + this._setLoginToken = options._setLoginToken || function () {}; + // Scratch data scoped to this connection (livedata_connection on the // client, livedata_session on the server). This is only used // internally, but we should have real and documented API for this @@ -48,6 +52,13 @@ _.extend(MethodInvocation.prototype, { throw new Error("Can't call setUserId in a method after calling unblock"); self.userId = userId; self._setUserId(userId); + }, + _setLoginToken: function (token) { + this._setLoginToken(token, this._sessionData.loginToken); + this._sessionData.loginToken = token; + }, + _getLoginToken: function (token) { + return this._sessionData.loginToken; } }); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 5c396c4a00..8e8602c9e8 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -594,9 +594,15 @@ _.extend(Session.prototype, { self._setUserId(userId); }; + var setLoginToken = function (newToken, oldToken) { + self._setLoginToken(newToken, oldToken); + }; + var invocation = new MethodInvocation({ isSimulation: false, - userId: self.userId, setUserId: setUserId, + userId: self.userId, + setUserId: setUserId, + _setLoginToken: setLoginToken, unblock: unblock, sessionData: self.sessionData }); @@ -651,6 +657,11 @@ _.extend(Session.prototype, { }); }, + _setLoginToken: function (newToken, oldToken) { + var self = this; + self.server._loginTokenChanged(self, newToken, oldToken); + }, + // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1026,6 +1037,12 @@ Server = function () { self.sessions = {}; // map from id to session + // Keeps track of the open connections associated with particular login + // tokens. Used for logging out all a user's open connections, expiring login + // tokens, etc. + self.sessionsByLoginToken = {}; + + self.stream_server = new StreamServer; self.stream_server.register(function (socket) { @@ -1269,17 +1286,26 @@ _.extend(Server.prototype, { var setUserId = function() { throw new Error("Can't call setUserId on a server initiated method call"); }; + var setLoginToken = function () { + throw new Error("Can't call _setLoginToken on a server " + + "initiated method call"); + }; var currentInvocation = DDP._CurrentInvocation.get(); if (currentInvocation) { userId = currentInvocation.userId; setUserId = function(userId) { currentInvocation.setUserId(userId); }; + setLoginToken = function (newToken, oldToken) { + currentInvocation._setLoginToken(newToken, oldToken); + }; } var invocation = new MethodInvocation({ isSimulation: false, - userId: userId, setUserId: setUserId, + userId: userId, + setUserId: setUserId, + _setLoginToken: setLoginToken, sessionData: self.sessionData }); try { @@ -1304,6 +1330,19 @@ _.extend(Server.prototype, { if (exception) throw exception; return result; + }, + + _loginTokenChanged: function (session, newToken, oldToken) { + if (oldToken) { + // Remove the session from the list of open sessions for the old token. + self.sessionsByLoginToken[oldToken] = _.without( + self.sessionsByLoginToken[oldToken], + session.id + ); + } + if (! _.has(self.sessionsByLoginToken, newToken)) + self.sessionsByLoginToken[newToken] = []; + self.sessionsByLoginToken[newToken].push(session.id); } }); From 7dfff264a325d96b861608588fbc40b9d2f2101a Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 21 Aug 2013 22:50:55 -0700 Subject: [PATCH 02/18] Add method that logs the user out everywhere. Might need some UI work; currently causes confusing error message "Couldn't find login token." --- packages/accounts-base/accounts_server.js | 17 ++++++++++++++++ packages/livedata/livedata_common.js | 3 +++ packages/livedata/livedata_server.js | 24 +++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 4868b7c845..53c209f5f9 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -85,6 +85,23 @@ Meteor.methods({ removeLoginToken(this.userId, token); this.setUserId(null); this._setLoginToken(null); + // XXX should close all connections open with this token? + }, + + // Nuke everything: delete all the user's tokens and close all open + // connections logged in as this user. XXX Should eventually get a fresh new + // token on the connection that called it and not get closed. + logoutAll: function () { + var user = Meteor.users.findOne(this.userId); + if (user) { + var tokens = user.services.resume.loginTokens; + Meteor.users.update(this.userId, { + $set: { "services.resume.loginTokens": [] } + }); + this._closeAllForTokens(_.map(tokens, function (token) { + return token.token; + })); + } } }); diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index 589465ccd9..93db53fd97 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -33,6 +33,9 @@ MethodInvocation = function (options) { // deleted, etc. this._setLoginToken = options._setLoginToken || function () {}; + // Closes all open connections associated with the given tokens. + this._closeAllForTokens = options._closeAllForTokens || function () {}; + // Scratch data scoped to this connection (livedata_connection on the // client, livedata_session on the server). This is only used // internally, but we should have real and documented API for this diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 8e8602c9e8..d4cc7b79db 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -598,11 +598,16 @@ _.extend(Session.prototype, { self._setLoginToken(newToken, oldToken); }; + var closeAll = function (tokens) { + self._closeAllForTokens(tokens); + }; + var invocation = new MethodInvocation({ isSimulation: false, userId: self.userId, setUserId: setUserId, _setLoginToken: setLoginToken, + _closeAllForTokens: closeAll, unblock: unblock, sessionData: self.sessionData }); @@ -662,6 +667,11 @@ _.extend(Session.prototype, { self.server._loginTokenChanged(self, newToken, oldToken); }, + _closeAllForTokens: function (tokens) { + var self = this; + self.server._closeAllForTokens(tokens); + }, + // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1333,6 +1343,7 @@ _.extend(Server.prototype, { }, _loginTokenChanged: function (session, newToken, oldToken) { + var self = this; if (oldToken) { // Remove the session from the list of open sessions for the old token. self.sessionsByLoginToken[oldToken] = _.without( @@ -1343,6 +1354,19 @@ _.extend(Server.prototype, { if (! _.has(self.sessionsByLoginToken, newToken)) self.sessionsByLoginToken[newToken] = []; self.sessionsByLoginToken[newToken].push(session.id); + }, + + _closeAllForTokens: function (tokens) { + var self = this; + _.each(tokens, function (token) { + if (_.has(self.sessionsByLoginToken, token)) { + _.each(self.sessionsByLoginToken[token], function (sessionId) { + self.sessions[sessionId].cleanup(); + self.sessions[sessionId].destroy(); + delete self.sessions[sessionId]; + }); + } + }); } }); From 3403b31c425802d21392755cece6dcbbacb01f96 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 22 Aug 2013 22:36:34 -0700 Subject: [PATCH 03/18] Preserve the connection that called logoutAll. --- packages/accounts-base/accounts_client.js | 12 ++++++++++++ packages/accounts-base/accounts_server.js | 14 +++++++++++--- packages/livedata/livedata_server.js | 16 ++++++++++------ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 3dd0c5cd35..ceb9affd89 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -174,6 +174,18 @@ Meteor.logout = function (callback) { }); }; +Meteor._logoutAll = function (callback) { + Meteor.apply('_logoutAll', [], {wait: true}, function (error, result) { + if (error) { + callback && callback(error); + } else { + // The method should return a new valid token that we should start using. + makeClientLoggedIn(Meteor.userId(), result.token); + callback && callback(); + } + }); +}; + /// /// LOGIN SERVICES /// diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 53c209f5f9..e69e8dead9 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -89,9 +89,8 @@ Meteor.methods({ }, // Nuke everything: delete all the user's tokens and close all open - // connections logged in as this user. XXX Should eventually get a fresh new - // token on the connection that called it and not get closed. - logoutAll: function () { + // connections logged in as this user. + _logoutAll: function () { var user = Meteor.users.findOne(this.userId); if (user) { var tokens = user.services.resume.loginTokens; @@ -101,6 +100,15 @@ Meteor.methods({ this._closeAllForTokens(_.map(tokens, function (token) { return token.token; })); + + var newToken = Accounts._generateStampedLoginToken(); + Meteor.users.update(this.userId, { + $push: { + "services.resume.loginTokens": newToken + } + }); + this._setLoginToken(newToken); + return newToken; } } }); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index d4cc7b79db..33e30b1a9b 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -599,7 +599,7 @@ _.extend(Session.prototype, { }; var closeAll = function (tokens) { - self._closeAllForTokens(tokens); + self._closeAllForTokens(tokens, [self.id]); }; var invocation = new MethodInvocation({ @@ -669,7 +669,7 @@ _.extend(Session.prototype, { _closeAllForTokens: function (tokens) { var self = this; - self.server._closeAllForTokens(tokens); + self.server._closeAllForTokens(tokens, [self.id]); }, // Sets the current user id in all appropriate contexts and reruns @@ -1356,14 +1356,18 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[newToken].push(session.id); }, - _closeAllForTokens: function (tokens) { + // Close all open sessions associated with any of the tokens in `tokens`, + // except for sessions with ids in `excludeSessions`. + _closeAllForTokens: function (tokens, excludeSessions) { var self = this; _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { _.each(self.sessionsByLoginToken[token], function (sessionId) { - self.sessions[sessionId].cleanup(); - self.sessions[sessionId].destroy(); - delete self.sessions[sessionId]; + if (_.indexOf(excludeSessions, sessionId) === -1) { + self.sessions[sessionId].cleanup(); + self.sessions[sessionId].destroy(); + delete self.sessions[sessionId]; + } }); } }); From 12c0d8ef5e3ab64908d52c8fe32f48f1e96133d5 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 23 Aug 2013 17:46:03 -0700 Subject: [PATCH 04/18] Rename to Meteor._logoutAllOthers --- packages/accounts-base/accounts_client.js | 4 ++-- packages/accounts-base/accounts_server.js | 5 +++-- packages/livedata/livedata_server.js | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index ceb9affd89..f538d16388 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -174,8 +174,8 @@ Meteor.logout = function (callback) { }); }; -Meteor._logoutAll = function (callback) { - Meteor.apply('_logoutAll', [], {wait: true}, function (error, result) { +Meteor._logoutAllOthers = function (callback) { + Meteor.apply('_logoutAllOthers', [], {wait: true}, function (error, result) { if (error) { callback && callback(error); } else { diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index e69e8dead9..a66f795947 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -89,8 +89,9 @@ Meteor.methods({ }, // Nuke everything: delete all the user's tokens and close all open - // connections logged in as this user. - _logoutAll: function () { + // connections logged in as this user, except this connection. Returns a fresh + // new login token that this client can use. + _logoutAllOthers: function () { var user = Meteor.users.findOne(this.userId); if (user) { var tokens = user.services.resume.loginTokens; diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 33e30b1a9b..5c4a99ea4c 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -598,6 +598,7 @@ _.extend(Session.prototype, { self._setLoginToken(newToken, oldToken); }; + // Closes all sessions associated with these tokens except this one. var closeAll = function (tokens) { self._closeAllForTokens(tokens, [self.id]); }; From 4b7f052ce06e9fac45ca23c14642fd9d925d6619 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 23 Aug 2013 18:05:23 -0700 Subject: [PATCH 05/18] Wait 10 seconds before closing other logged-in sessions. Gives other tabs sharing a token with the caller time to find the new token in localStorage. This is sort of a hack for now; one possibility for making it less hacky is to add a DDP disconnect message to allow the server to tell clients that they are being disconnected but can reconnect with the provided token. --- packages/accounts-base/accounts_server.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index a66f795947..a50b335fa7 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -92,23 +92,29 @@ Meteor.methods({ // connections logged in as this user, except this connection. Returns a fresh // new login token that this client can use. _logoutAllOthers: function () { - var user = Meteor.users.findOne(this.userId); + var self = this; + var user = Meteor.users.findOne(self.userId); if (user) { var tokens = user.services.resume.loginTokens; - Meteor.users.update(this.userId, { + Meteor.users.update(self.userId, { $set: { "services.resume.loginTokens": [] } }); - this._closeAllForTokens(_.map(tokens, function (token) { - return token.token; - })); + // Wait 10 seconds before closing the connections. This is to give other + // clients using our token a chance to find a new token in localStorage so + // that when they get disconnected they reconnect with a valid token. + Meteor.setTimeout(function () { + self._closeAllForTokens(_.map(tokens, function (token) { + return token.token; + })); + }, 10*1000); var newToken = Accounts._generateStampedLoginToken(); - Meteor.users.update(this.userId, { + Meteor.users.update(self.userId, { $push: { "services.resume.loginTokens": newToken } }); - this._setLoginToken(newToken); + self._setLoginToken(newToken); return newToken; } } From 1887d6960b14b90e4f0b47c8b332f3d050c67444 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 12:25:56 -0700 Subject: [PATCH 06/18] Expire login tokens periodically. When a login token expires, all open connections associated with that token will be closed. It will be up to client code to avoid trying to connect with a login token that is set to expire soon. --- packages/accounts-base/accounts_server.js | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index a50b335fa7..6671ec61bc 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -125,6 +125,11 @@ Meteor.methods({ /// /// support reconnecting using a meteor login token +// how often (in seconds) we check for expired tokens +var EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes +// how long (in seconds) until a login token expires +var TOKEN_LIFETIME = 604800; // one week + // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { if (!options.resume) @@ -134,6 +139,7 @@ Accounts.registerLoginHandler(function(options) { var user = Meteor.users.findOne({ "services.resume.loginTokens.token": ""+options.resume }); + if (!user) throw new Meteor.Error(403, "Couldn't find login token"); @@ -157,6 +163,38 @@ var removeLoginToken = function (userId, loginToken) { }); }; +// Deletes expired tokens from the database and closes all open connections +// associated with these tokens. +expireTokens = function () { + var oldestValidDate = new Date(new Date() - TOKEN_LIFETIME * 1000); + var usersWithExpiredTokens = Meteor.users.find({ + "services.resume.loginTokens.when": { $lt: oldestValidDate } + }); + + var oldTokens = []; + usersWithExpiredTokens.forEach(function (user) { + _.each(user.services.resume.loginTokens, function (token) { + if (token.when < oldestValidDate) + oldTokens.push(token.token); + }); + }); + + Meteor.users.update({ + "services.resume.loginTokens.when": { $lt: oldestValidDate } + }, { + $pull: { + "services.resume.loginTokens": { + when: { $lt: oldestValidDate } + } + } + }); + + Meteor.server._closeAllForTokens(oldTokens); +}; + +Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); +Meteor.setInterval(expireTokens, EXPIRE_TOKENS_INTERVAL); + /// /// CREATE USER HOOKS From d39726d737f5c4583c88005fc5f6e7189ad8e756 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 13:23:17 -0700 Subject: [PATCH 07/18] Store login token on livedata session. Allows us to remove sessions from sessionsByLoginToken when they are destroyed. --- packages/livedata/livedata_common.js | 2 +- packages/livedata/livedata_server.js | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index 93db53fd97..dc2510c353 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -57,7 +57,7 @@ _.extend(MethodInvocation.prototype, { self._setUserId(userId); }, _setLoginToken: function (token) { - this._setLoginToken(token, this._sessionData.loginToken); + this._setLoginToken(token); this._sessionData.loginToken = token; }, _getLoginToken: function (token) { diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 5c4a99ea4c..bbceeaf7eb 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -238,6 +238,7 @@ var Session = function (server, version) { self._universalSubs = []; self.userId = null; + self.loginToken = null; // Per-connection scratch area. This is only used internally, but we // should have real and documented API for this sort of thing someday. @@ -594,8 +595,8 @@ _.extend(Session.prototype, { self._setUserId(userId); }; - var setLoginToken = function (newToken, oldToken) { - self._setLoginToken(newToken, oldToken); + var setLoginToken = function (newToken) { + self._setLoginToken(newToken); }; // Closes all sessions associated with these tokens except this one. @@ -663,8 +664,10 @@ _.extend(Session.prototype, { }); }, - _setLoginToken: function (newToken, oldToken) { + _setLoginToken: function (newToken) { var self = this; + var oldToken = self.loginToken; + self.loginToken = newToken; self.server._loginTokenChanged(self, newToken, oldToken); }, @@ -1127,6 +1130,11 @@ Server = function () { } }); _.each(destroyedIds, function (id) { + var session = self.sessions[id]; + self.sessionsByLoginToken[session.loginToken] = _.without( + self.sessionsByLoginToken[session.loginToken], + id + ); delete self.sessions[id]; }); }, 1 * 60 * 1000); @@ -1307,8 +1315,8 @@ _.extend(Server.prototype, { setUserId = function(userId) { currentInvocation.setUserId(userId); }; - setLoginToken = function (newToken, oldToken) { - currentInvocation._setLoginToken(newToken, oldToken); + setLoginToken = function (newToken) { + currentInvocation._setLoginToken(newToken); }; } From d84334a34b154f391a158f9709adda63c0f2fe5b Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 15:44:59 -0700 Subject: [PATCH 08/18] Make client aware of token expiration times. accounts-password will not try to auto-login with (and will clear) a login token that is going to expire within an hour, to try to avoid abrupt disconnects from an expiring token. Login handlers return a new tokenExpires field to help the client do this. Made tokenExpires a separate field on the login handlers' result object instead of just returning the whole token (token + when fields) in the result to avoid breaking code that assumes that login handlers return a string as the token field. The tokenExpires field is optional, so other login handlers that don't set it aren't broken. --- packages/accounts-base/accounts_client.js | 8 +++--- packages/accounts-base/accounts_common.js | 6 +++++ packages/accounts-base/accounts_server.js | 25 +++++++++++++++---- packages/accounts-base/localstorage_token.js | 17 ++++++++++++- packages/accounts-password/password_server.js | 25 ++++++++++++++++--- 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index f538d16388..2b3c2fad8c 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -139,7 +139,7 @@ Accounts.callLoginMethod = function (options) { } // Make the client logged in. (The user data should already be loaded!) - makeClientLoggedIn(result.id, result.token); + makeClientLoggedIn(result.id, result.token, result.tokenExpires); options.userCallback(); }; @@ -158,8 +158,8 @@ makeClientLoggedOut = function() { Meteor.connection.onReconnect = null; }; -makeClientLoggedIn = function(userId, token) { - storeLoginToken(userId, token); +makeClientLoggedIn = function(userId, token, tokenExpires { + storeLoginToken(userId, token, tokenExpires); Meteor.connection.setUserId(userId); }; @@ -180,7 +180,7 @@ Meteor._logoutAllOthers = function (callback) { callback && callback(error); } else { // The method should return a new valid token that we should start using. - makeClientLoggedIn(Meteor.userId(), result.token); + makeClientLoggedIn(Meteor.userId(), result.token, result.tokenExpires); callback && callback(); } }); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 0db0271db9..b6ab826002 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -66,3 +66,9 @@ Accounts.LoginCancelledError.numericError = 0x8acdc2f; Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; +// how long (in seconds) until a login token expires +// XXX maybe should be configurable +TOKEN_LIFETIME = 604800; // one week +Accounts._tokenExpiration = function (when) { + return new Date(when.getTime() + TOKEN_LIFETIME * 1000); +}; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 6671ec61bc..77044a5183 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -34,7 +34,11 @@ Meteor.user = function () { // @param handler {Function} A function that receives an options object // (as passed as an argument to the `login` method) and returns one of: // - `undefined`, meaning don't handle; -// - {id: userId, token: *}, if the user logged in successfully. +// - {id: userId, token: *, tokenExpires: *}, if the user logged in +// successfully. tokenExpires is optional and intends to provide a hint to the +// client as to when the token will expire. If not provided, the client +// will assume the token expires TOKEN_LIFETIME seconds from when it receives +// it. // - throw an error, if the user failed to log in. // Accounts.registerLoginHandler = function(handler) { @@ -115,7 +119,10 @@ Meteor.methods({ } }); self._setLoginToken(newToken); - return newToken; + return { + token: newToken.token, + tokenExpires: Accounts._tokenExpiration(newToken.when) + }; } } }); @@ -127,8 +134,6 @@ Meteor.methods({ // how often (in seconds) we check for expired tokens var EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes -// how long (in seconds) until a login token expires -var TOKEN_LIFETIME = 604800; // one week // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { @@ -143,8 +148,13 @@ Accounts.registerLoginHandler(function(options) { if (!user) throw new Meteor.Error(403, "Couldn't find login token"); + var token = _.find(user.services.resume.loginTokens, function (token) { + return token.token === options.resume; + }); + return { token: options.resume, + tokenExpires: Accounts._tokenExpiration(token.when), id: user._id }; }); @@ -236,6 +246,7 @@ Accounts.insertUserDoc = function (options, user) { if (options.generateLoginToken) { var stampedToken = Accounts._generateStampedLoginToken(); result.token = stampedToken.token; + result.tokenExpires = Accounts._tokenExpiration(stampedToken.when); Meteor._ensure(user, 'services', 'resume'); if (_.has(user.services.resume, 'loginTokens')) user.services.resume.loginTokens.push(stampedToken); @@ -352,7 +363,11 @@ Accounts.updateOrCreateUserFromExternalService = function( user._id, {$set: setAttrs, $push: {'services.resume.loginTokens': stampedToken}}); - return {token: stampedToken.token, id: user._id}; + return { + token: stampedToken.token, + id: user._id, + tokenExpires: Accounts._tokenExpiration(stampedToken.when) + }; } else { // Create a new user with the service data. Pass other options through to // insertUserDoc. diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 91205a6742..46bea22549 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -4,6 +4,9 @@ // browser. var lastLoginTokenWhenPolled; +// We don't try to auto-login with a token that is going to expire within +// MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. +var MIN_TOKEN_LIFETIME = 3600; // one hour // Login with a Meteor access token. This is the only public function // here. @@ -27,6 +30,7 @@ Accounts._enableAutoLogin = function () { // Key names to use in localStorage var loginTokenKey = "Meteor.loginToken"; +var loginTokenExpiresKey = "Meteor.loginTokenExpires"; var userIdKey = "Meteor.userId"; // Call this from the top level of the test file for any test that does @@ -37,9 +41,12 @@ Accounts._isolateLoginTokenForTest = function () { userIdKey = userIdKey + Random.id(); }; -storeLoginToken = function(userId, token) { +storeLoginToken = function(userId, token, tokenExpires) { Meteor._localStorage.setItem(userIdKey, userId); Meteor._localStorage.setItem(loginTokenKey, token); + if (! tokenExpires) + tokenExpires = Accounts._tokenExpiration(new Date()); + Meteor._localStorage.setItem(loginTokenExpiresKey, tokenExpires); // to ensure that the localstorage poller doesn't end up trying to // connect a second time @@ -49,6 +56,7 @@ storeLoginToken = function(userId, token) { unstoreLoginToken = function() { Meteor._localStorage.removeItem(userIdKey); Meteor._localStorage.removeItem(loginTokenKey); + Meteor._localStorage.removeItem(loginTokenExpiresKey); // to ensure that the localstorage poller doesn't end up trying to // connect a second time @@ -66,6 +74,12 @@ var storedUserId = function() { return Meteor._localStorage.getItem(userIdKey); }; +var unstoreLoginTokenIfExpiresSoon = function () { + var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); + if (tokenExpires && + new Date() > (new Date(tokenExpires) - MIN_TOKEN_LIFETIME * 1000)) + unstoreLoginToken(); +}; /// /// AUTO-LOGIN @@ -74,6 +88,7 @@ var storedUserId = function() { if (autoLoginEnabled) { // Immediately try to log in via local storage, so that any DDP // messages are sent after we have established our user account + unstoreLoginTokenIfExpiresSoon(); var token = storedLoginToken(); if (token) { // On startup, optimistically present us as logged in while the diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 58782a7c6b..1031e2fbf4 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -98,7 +98,12 @@ Accounts.registerLoginHandler(function (options) { Meteor.users.update( userId, {$push: {'services.resume.loginTokens': stampedLoginToken}}); - return {token: stampedLoginToken.token, id: userId, HAMK: serialized.HAMK}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), + id: userId, + HAMK: serialized.HAMK + }; }); // Handler to login with plaintext password. @@ -137,7 +142,11 @@ Accounts.registerLoginHandler(function (options) { Meteor.users.update( user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}}); - return {token: stampedLoginToken.token, id: user._id}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpires(stampedLoginToken.when), + id: user._id + }; }); @@ -321,7 +330,11 @@ Meteor.methods({resetPassword: function (token, newVerifier) { }); this.setUserId(user._id); - return {token: stampedLoginToken.token, id: user._id}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), + id: user._id + }; }}); /// @@ -408,7 +421,11 @@ Meteor.methods({verifyEmail: function (token) { $push: {'services.resume.loginTokens': stampedLoginToken}}); this.setUserId(user._id); - return {token: stampedLoginToken.token, id: user._id}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), + id: user._id + }; }}); From 2ebdeb0d957a6e5b1b150061d7c83fd2ac682aab Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 16:08:53 -0700 Subject: [PATCH 09/18] Make token expiration times configurable via Accounts.config. --- packages/accounts-base/accounts_client.js | 2 +- packages/accounts-base/accounts_common.js | 13 ++++++++++--- packages/accounts-base/accounts_server.js | 18 +++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 2b3c2fad8c..a153c7d9ee 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -158,7 +158,7 @@ makeClientLoggedOut = function() { Meteor.connection.onReconnect = null; }; -makeClientLoggedIn = function(userId, token, tokenExpires { +makeClientLoggedIn = function(userId, token, tokenExpires) { storeLoginToken(userId, token, tokenExpires); Meteor.connection.setUserId(userId); }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index b6ab826002..c63adbd18a 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -19,10 +19,15 @@ Accounts._options = {}; // client signups. // - forbidClientAccountCreation {Boolean} // Do not allow clients to create accounts directly. +// - _tokenLifetime {Number} +// Seconds until a login token expires. +// - _tokenExpirationInterval {Number} +// How often (in seconds) to check for expired tokens // Accounts.config = function(options) { // validate option keys - var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation"]; + var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", + "_tokenLifetime", "_tokenExpirationInterval"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -68,7 +73,9 @@ Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; // how long (in seconds) until a login token expires // XXX maybe should be configurable -TOKEN_LIFETIME = 604800; // one week +DEFAULT_TOKEN_LIFETIME = 604800; // one week Accounts._tokenExpiration = function (when) { - return new Date(when.getTime() + TOKEN_LIFETIME * 1000); + var tokenLifetime = Accounts._options._tokenLifetime || + DEFAULT_TOKEN_LIFETIME; + return new Date(when.getTime() + tokenLifetime * 1000); }; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 77044a5183..94123db9f3 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -36,9 +36,9 @@ Meteor.user = function () { // - `undefined`, meaning don't handle; // - {id: userId, token: *, tokenExpires: *}, if the user logged in // successfully. tokenExpires is optional and intends to provide a hint to the -// client as to when the token will expire. If not provided, the client -// will assume the token expires TOKEN_LIFETIME seconds from when it receives -// it. +// client as to when the token will expire. If not provided, the client will +// call Accounts._tokenExpiration, passing it the date that it received the +// token. // - throw an error, if the user failed to log in. // Accounts.registerLoginHandler = function(handler) { @@ -133,7 +133,7 @@ Meteor.methods({ /// support reconnecting using a meteor login token // how often (in seconds) we check for expired tokens -var EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes +var DEFAULT_EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { @@ -175,8 +175,10 @@ var removeLoginToken = function (userId, loginToken) { // Deletes expired tokens from the database and closes all open connections // associated with these tokens. -expireTokens = function () { - var oldestValidDate = new Date(new Date() - TOKEN_LIFETIME * 1000); +// Exported for tests. +var expireTokens = Accounts._expireTokens = function () { + var tokenLifetime = Accounts._options.tokenLifetime || DEFAULT_TOKEN_LIFETIME; + var oldestValidDate = new Date(new Date() - tokenLifetime * 1000); var usersWithExpiredTokens = Meteor.users.find({ "services.resume.loginTokens.when": { $lt: oldestValidDate } }); @@ -203,7 +205,9 @@ expireTokens = function () { }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); -Meteor.setInterval(expireTokens, EXPIRE_TOKENS_INTERVAL); +var expireInterval = Accounts._options.tokenExpirationInterval || + DEFAULT_EXPIRE_TOKENS_INTERVAL; +Meteor.setInterval(expireTokens, expireInterval * 1000); /// From 8621c18bc1f47723e40826322bb327ae2ae3aba2 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 18:46:58 -0700 Subject: [PATCH 10/18] Add token expiration test and fix bugs so it passes. Also make all token-expiration-related times configurable via Accounts.config. --- packages/accounts-base/accounts_client.js | 30 +++++++++------ packages/accounts-base/accounts_common.js | 19 +++++++++- packages/accounts-base/accounts_server.js | 22 +++++++---- packages/accounts-base/localstorage_token.js | 6 +-- packages/accounts-password/password_server.js | 2 +- packages/accounts-password/password_tests.js | 38 ++++++++++++++++++- packages/livedata/livedata_server.js | 13 +++++-- 7 files changed, 97 insertions(+), 33 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index a153c7d9ee..3c48a6c9a4 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -93,18 +93,24 @@ Accounts.callLoginMethod = function (options) { } else { Meteor.connection.onReconnect = function() { reconnected = true; - Accounts.callLoginMethod({ - methodArguments: [{resume: result.token}], - // Reconnect quiescence ensures that the user doesn't see an - // intermediate state before the login method finishes. So we don't - // need to show a logging-in animation. - _suppressLoggingIn: true, - userCallback: function (error) { - if (error) { - makeClientLoggedOut(); - } - options.userCallback(error); - }}); + // XXX A DDP disconnect message would be helpful here, to know if our + // connection got closed because of an expired token. + if (! result.tokenExpires) + result.tokenExpires = Accounts._tokenExpiration(new Date()); + if (! Accounts._tokenExpiresSoon(result.tokenExpires)) { + Accounts.callLoginMethod({ + methodArguments: [{resume: result.token}], + // Reconnect quiescence ensures that the user doesn't see an + // intermediate state before the login method finishes. So we don't + // need to show a logging-in animation. + _suppressLoggingIn: true, + userCallback: function (error) { + if (error) { + makeClientLoggedOut(); + } + options.userCallback(error); + }}); + } }; } }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index c63adbd18a..ffbd10ad57 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -23,11 +23,15 @@ Accounts._options = {}; // Seconds until a login token expires. // - _tokenExpirationInterval {Number} // How often (in seconds) to check for expired tokens +// - _minTokenLifetime {Number} +// The minimum number of seconds until a token expires in order for the +// client to be willing to connect with that token. // Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", - "_tokenLifetime", "_tokenExpirationInterval"]; + "_tokenLifetime", "_tokenExpirationInterval", + "_minTokenLifetime"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -41,6 +45,8 @@ Accounts.config = function(options) { throw new Error("Can't set `" + key + "` more than once"); } else { Accounts._options[key] = options[key]; + if (key === "_tokenExpirationInterval" && Meteor.isServer) + initExpireTokenInterval(); } } }); @@ -72,10 +78,19 @@ Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; // how long (in seconds) until a login token expires -// XXX maybe should be configurable DEFAULT_TOKEN_LIFETIME = 604800; // one week +// We don't try to auto-login with a token that is going to expire within +// MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. +var DEFAULT_MIN_TOKEN_LIFETIME = 3600; // one hour + Accounts._tokenExpiration = function (when) { var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; return new Date(when.getTime() + tokenLifetime * 1000); }; + +Accounts._tokenExpiresSoon = function (when) { + var minLifetime = Accounts._options._minTokenLifetime || + DEFAULT_MIN_TOKEN_LIFETIME; + return new Date() > (new Date(when) - minLifetime * 1000); +}; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 94123db9f3..5d47e83cda 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -175,9 +175,8 @@ var removeLoginToken = function (userId, loginToken) { // Deletes expired tokens from the database and closes all open connections // associated with these tokens. -// Exported for tests. -var expireTokens = Accounts._expireTokens = function () { - var tokenLifetime = Accounts._options.tokenLifetime || DEFAULT_TOKEN_LIFETIME; +var expireTokens = function () { + var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; var oldestValidDate = new Date(new Date() - tokenLifetime * 1000); var usersWithExpiredTokens = Meteor.users.find({ "services.resume.loginTokens.when": { $lt: oldestValidDate } @@ -186,6 +185,8 @@ var expireTokens = Accounts._expireTokens = function () { var oldTokens = []; usersWithExpiredTokens.forEach(function (user) { _.each(user.services.resume.loginTokens, function (token) { + if (typeof token.when === "number") + token.when = new Date(token.when); if (token.when < oldestValidDate) oldTokens.push(token.token); }); @@ -199,16 +200,21 @@ var expireTokens = Accounts._expireTokens = function () { when: { $lt: oldestValidDate } } } - }); + }, { multi: true }); Meteor.server._closeAllForTokens(oldTokens); }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); -var expireInterval = Accounts._options.tokenExpirationInterval || - DEFAULT_EXPIRE_TOKENS_INTERVAL; -Meteor.setInterval(expireTokens, expireInterval * 1000); - +var expireTokenInterval; +initExpireTokenInterval = function () { + if (expireTokenInterval) + Meteor.clearInterval(expireTokenInterval); + var expirePeriod = Accounts._options._tokenExpirationInterval || + DEFAULT_EXPIRE_TOKENS_INTERVAL; + expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriod * 1000); +}; +initExpireTokenInterval(); /// /// CREATE USER HOOKS diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 46bea22549..023913cbfa 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -4,9 +4,6 @@ // browser. var lastLoginTokenWhenPolled; -// We don't try to auto-login with a token that is going to expire within -// MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. -var MIN_TOKEN_LIFETIME = 3600; // one hour // Login with a Meteor access token. This is the only public function // here. @@ -76,8 +73,7 @@ var storedUserId = function() { var unstoreLoginTokenIfExpiresSoon = function () { var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); - if (tokenExpires && - new Date() > (new Date(tokenExpires) - MIN_TOKEN_LIFETIME * 1000)) + if (tokenExpires && Accounts._tokenExpiresSoon(tokenExpires)) unstoreLoginToken(); }; diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 1031e2fbf4..1771cb1e91 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -144,7 +144,7 @@ Accounts.registerLoginHandler(function (options) { return { token: stampedLoginToken.token, - tokenExpires: Accounts._tokenExpires(stampedLoginToken.when), + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), id: user._id }; }); diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 3cfec9511f..0195ee1b18 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,3 +1,9 @@ +Accounts.config({ + _tokenLifetime: 5, + _tokenExpirationInterval: 5, + _minTokenLifetime: 1 +}); + if (Meteor.isClient) (function () { // XXX note, only one test can do login/logout things at once! for @@ -255,6 +261,7 @@ if (Meteor.isClient) (function () { test.equal(result, null); })); }, + logoutStep, function(test, expect) { var expectLoginError = expect(function (err) { test.isTrue(err); @@ -267,8 +274,35 @@ if (Meteor.isClient) (function () { Meteor.loginWithToken(token, expectLoginError); }); }); - } - + }, + logoutStep, + function(test, expect) { + // Test that login tokens get expired. We should get logged out when a + // token expires, and not be able to log in again with the same token. + var expectLoggedOut = expect(function () { + test.equal(Meteor.user(), undefined); + }); + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var expectNoError = expect(function (err) { + test.equal(err, undefined); + }); + var expectToken = expect(function (token) { + test.isTrue(token); + }); + var token; + Meteor.loginWithPassword(username, password2, function (error) { + expectNoError(error); + token = Accounts._storedLoginToken(); + }); + Meteor.setTimeout(function () { + expectToken(token); + expectLoggedOut(); + Meteor.loginWithToken(token, expectLoginError); + }, 10*1000); + }, + logoutStep ]); }) (); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index bbceeaf7eb..2942736a22 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -238,7 +238,6 @@ var Session = function (server, version) { self._universalSubs = []; self.userId = null; - self.loginToken = null; // Per-connection scratch area. This is only used internally, but we // should have real and documented API for this sort of thing someday. @@ -666,8 +665,8 @@ _.extend(Session.prototype, { _setLoginToken: function (newToken) { var self = this; - var oldToken = self.loginToken; - self.loginToken = newToken; + var oldToken = self.sessionData.loginToken; + self.sessionData.loginToken = newToken; self.server._loginTokenChanged(self, newToken, oldToken); }, @@ -1371,13 +1370,21 @@ _.extend(Server.prototype, { var self = this; _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { + var destroyedIds = []; _.each(self.sessionsByLoginToken[token], function (sessionId) { if (_.indexOf(excludeSessions, sessionId) === -1) { self.sessions[sessionId].cleanup(); self.sessions[sessionId].destroy(); delete self.sessions[sessionId]; + destroyedIds.push(sessionId); } }); + self.sessionsByLoginToken[token] = _.filter( + self.sessionsByLoginToken[token], + function (sessionId) { + return _.indexOf(destroyedIds, sessionId) === -1; + } + ); } }); } From 10d49451d949919437dcccbfe4db5a81ccb27a49 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 30 Aug 2013 18:06:49 -0700 Subject: [PATCH 11/18] Add experimental "disconnected" DDP message. Client uses this to unset user id when a forced logout happens. --- packages/livedata/DDP.md | 12 ++++++ packages/livedata/livedata_connection.js | 11 ++++++ packages/livedata/livedata_server.js | 48 +++++++++++++++++------- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/livedata/DDP.md b/packages/livedata/DDP.md index dc59ece7c9..2ef93578e3 100644 --- a/packages/livedata/DDP.md +++ b/packages/livedata/DDP.md @@ -222,6 +222,18 @@ The error message contains the following fields: * `offendingMessage`: if the original message parsed properly, it is included here +## Disconnection + +### Messages: + + * `disconnected` (server->client) + - `reason`: string ("logged_out" or "token_expired") + +### Procedure: + + * Before the server disconnects a client, it can optionally send a disconnected + message to indicate the cause of the disconnect. + ## Appendix: EJSON EJSON is a way of embedding more than the built-in JSON types in JSON. It diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 04fb380f90..68ea20ced0 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -209,6 +209,8 @@ var Connection = function (url, options) { self._livedata_nosub(msg); else if (msg.msg === 'result') self._livedata_result(msg); + else if (msg.msg === 'disconnected') + self._livedata_disconnected(msg); else if (msg.msg === 'error') self._livedata_error(msg); else @@ -1319,6 +1321,15 @@ _.extend(Connection.prototype, { }); }, + _livedata_disconnected: function (msg) { + var self = this; + var reason = msg.reason; + if (reason === "logged_out" || reason === "token_expired") { + self.setUserId(null); + self.onReconnect = null; + } + }, + _livedata_error: function (msg) { Meteor._debug("Received error from server: ", msg.reason); if (msg.offendingMessage) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 2942736a22..c4bf1b06cf 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -452,6 +452,12 @@ _.extend(Session.prototype, { self.send(msg); }, + // Send a DDP disconnected message. + sendDisconnected: function (reason) { + var self = this; + self.send({ msg: "disconnected", reason: reason }); + }, + // Process 'msg' as an incoming message. (But as a guard against // race conditions during reconnection, ignore the message if // 'socket' is not the currently connected socket.) @@ -599,8 +605,8 @@ _.extend(Session.prototype, { }; // Closes all sessions associated with these tokens except this one. - var closeAll = function (tokens) { - self._closeAllForTokens(tokens, [self.id]); + var closeAll = function (tokens, reason) { + self._closeAllForTokens(tokens, reason); }; var invocation = new MethodInvocation({ @@ -670,9 +676,9 @@ _.extend(Session.prototype, { self.server._loginTokenChanged(self, newToken, oldToken); }, - _closeAllForTokens: function (tokens) { + _closeAllForTokens: function (tokens, reason) { var self = this; - self.server._closeAllForTokens(tokens, [self.id]); + self.server._closeAllForTokens(tokens, reason, [self.id]); }, // Sets the current user id in all appropriate contexts and reruns @@ -1364,21 +1370,37 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[newToken].push(session.id); }, - // Close all open sessions associated with any of the tokens in `tokens`, - // except for sessions with ids in `excludeSessions`. - _closeAllForTokens: function (tokens, excludeSessions) { + // Close all open sessions associated with any of the tokens in `tokens`. If + // `reason` is provided, sends each session a disconnected message before + // closing it. `excludeSessions` is an optional array of strings (session ids) + // to not close, even if they match a token in `tokens`. + _closeAllForTokens: function (tokens, reason, excludeSessions) { var self = this; + + if (! excludeSessions && typeof reason === "object") { + excludeSessions = reason; + reason = undefined; + } + + if (tokens.length) _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { var destroyedIds = []; _.each(self.sessionsByLoginToken[token], function (sessionId) { - if (_.indexOf(excludeSessions, sessionId) === -1) { - self.sessions[sessionId].cleanup(); - self.sessions[sessionId].destroy(); - delete self.sessions[sessionId]; - destroyedIds.push(sessionId); - } + if (_.indexOf(excludeSessions, sessionId) !== -1) + return; + + // Destroy session and remove from self.sessions. + var session = self.sessions[sessionId]; + if (reason) + session.sendDisconnected(reason); + session.cleanup(); + session.destroy(); + delete self.sessions[sessionId]; + destroyedIds.push(sessionId); }); + + // Remove destroyed sessions from self.sessionsByLoginToken. self.sessionsByLoginToken[token] = _.filter( self.sessionsByLoginToken[token], function (sessionId) { From e84f0adb7886d45d68cb4f195d967d4596ffdc75 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 30 Aug 2013 18:09:02 -0700 Subject: [PATCH 12/18] Add test for Meteor._logoutAllOthers. Fix bugs to make it work. --- packages/accounts-base/accounts_client.js | 30 +++++++---- packages/accounts-base/accounts_server.js | 12 +++-- packages/accounts-base/localstorage_token.js | 2 +- packages/accounts-password/password_tests.js | 52 ++++++++++++++++++-- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 3c48a6c9a4..1b87252ccc 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -180,16 +180,26 @@ Meteor.logout = function (callback) { }); }; -Meteor._logoutAllOthers = function (callback) { - Meteor.apply('_logoutAllOthers', [], {wait: true}, function (error, result) { - if (error) { - callback && callback(error); - } else { - // The method should return a new valid token that we should start using. - makeClientLoggedIn(Meteor.userId(), result.token, result.tokenExpires); - callback && callback(); - } - }); +// Set opts._noDelay to close other open connections without any delay, rather +// than the 10 second default delay. Used by test. +Meteor._logoutAllOthers = function (opts, callback) { + if (! callback && typeof opts === "Function") { + callback = opts; + opts = {}; + } + Meteor.apply('_logoutAllOthers', [opts], { wait: true }, + function (error, result) { + console.log("logged out others"); + if (error) { + callback && callback(error); + } else { + // The method should return a new valid token that we should + // start using. + makeClientLoggedIn(Meteor.userId(), result.token, + result.tokenExpires); + callback && callback(); + } + }); }; /// diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 5d47e83cda..7f07d4bc84 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -95,8 +95,9 @@ Meteor.methods({ // Nuke everything: delete all the user's tokens and close all open // connections logged in as this user, except this connection. Returns a fresh // new login token that this client can use. - _logoutAllOthers: function () { + _logoutAllOthers: function (opts) { var self = this; + opts = opts || {}; var user = Meteor.users.findOne(self.userId); if (user) { var tokens = user.services.resume.loginTokens; @@ -106,11 +107,12 @@ Meteor.methods({ // Wait 10 seconds before closing the connections. This is to give other // clients using our token a chance to find a new token in localStorage so // that when they get disconnected they reconnect with a valid token. + var delay = opts._noDelay ? 0 : 10; Meteor.setTimeout(function () { self._closeAllForTokens(_.map(tokens, function (token) { return token.token; - })); - }, 10*1000); + }), "logged_out"); + }, delay * 1000); var newToken = Accounts._generateStampedLoginToken(); Meteor.users.update(self.userId, { @@ -118,7 +120,7 @@ Meteor.methods({ "services.resume.loginTokens": newToken } }); - self._setLoginToken(newToken); + self._setLoginToken(newToken.token); return { token: newToken.token, tokenExpires: Accounts._tokenExpiration(newToken.when) @@ -202,7 +204,7 @@ var expireTokens = function () { } }, { multi: true }); - Meteor.server._closeAllForTokens(oldTokens); + Meteor.server._closeAllForTokens(oldTokens, "token_expired"); }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 023913cbfa..8e1fb21086 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -73,7 +73,7 @@ var storedUserId = function() { var unstoreLoginTokenIfExpiresSoon = function () { var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); - if (tokenExpires && Accounts._tokenExpiresSoon(tokenExpires)) + if (tokenExpires && Accounts._tokenExpiresSoon(new Date(tokenExpires))) unstoreLoginToken(); }; diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 0195ee1b18..3ae24348b5 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -277,24 +277,26 @@ if (Meteor.isClient) (function () { }, logoutStep, function(test, expect) { + // XXX would be nice to write this test in a way that avoids the + // possibly-flaky timing stuff (e.g. have a test hook for expiring tokens + // on demand rather than waiting for them to expire) + // Test that login tokens get expired. We should get logged out when a // token expires, and not be able to log in again with the same token. var expectLoggedOut = expect(function () { - test.equal(Meteor.user(), undefined); + test.equal(Meteor.user(), null); }); var expectLoginError = expect(function (err) { test.isTrue(err); }); - var expectNoError = expect(function (err) { - test.equal(err, undefined); - }); var expectToken = expect(function (token) { test.isTrue(token); }); var token; Meteor.loginWithPassword(username, password2, function (error) { - expectNoError(error); + test.isFalse(error); token = Accounts._storedLoginToken(); + expectToken(token); }); Meteor.setTimeout(function () { expectToken(token); @@ -302,6 +304,46 @@ if (Meteor.isClient) (function () { Meteor.loginWithToken(token, expectLoginError); }, 10*1000); }, + logoutStep, + function (test, expect) { + // Test that Meteor._logoutAllOthers logs out a second authenticated + // connection. + var expectLoggedIn = expect(function (err, result) { + test.isTrue(Meteor.user()); + }); + var expectLoginErr = expect(function (err) { + test.isTrue(err); + }); + + var token; + + // copied from livedata/client_convenience.js + var ddpUrl = '/'; + if (typeof __meteor_runtime_config__ !== "undefined") { + if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) + ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; + } + var secondConn = DDP.connect(ddpUrl); + + Meteor.loginWithPassword(username, password2, function (err, result) { + expectLoggedIn(err, result); + token = Accounts._storedLoginToken(); + secondConn.call("login", { + resume: token + }, function (err, result) { + test.isFalse(err); + Meteor._logoutAllOthers({ _noDelay: true }, function () { + // secondConn should be logged out and subsequently fail resume + // login, but Meteor.connection should stay logged in. + Meteor.setTimeout(function () { + test.isTrue(Meteor.user()); + test.isFalse(secondConn.userId()); + secondConn.call("login", { resume: token }, expectLoginErr); + }, 50); + }); + }); + }); + }, logoutStep ]); From 21dd57c95dfd61eb9e2539ebdae08e86f393012f Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Mon, 2 Sep 2013 19:04:01 -0700 Subject: [PATCH 13/18] Allow DDP client to register onDisconnect callback. accounts-base uses this to handle disconnects due to users being intentionally logged out. --- packages/accounts-base/accounts_client.js | 9 ++++++++- packages/livedata/livedata_connection.js | 5 +---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 1b87252ccc..e60c417bf4 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -112,6 +112,13 @@ Accounts.callLoginMethod = function (options) { }}); } }; + + Meteor.connection._onDisconnect = function (reason) { + var loggedOutReasons = ["logged_out", "token_expired", + "user_deleted", "token_deleted"]; + if (_.indexOf(loggedOutReasons, reason) !== -1) + makeClientLoggedOut(); + }; } }; @@ -162,6 +169,7 @@ makeClientLoggedOut = function() { unstoreLoginToken(); Meteor.connection.setUserId(null); Meteor.connection.onReconnect = null; + Meteor.connection._onDisconnect = null; }; makeClientLoggedIn = function(userId, token, tokenExpires) { @@ -189,7 +197,6 @@ Meteor._logoutAllOthers = function (opts, callback) { } Meteor.apply('_logoutAllOthers', [opts], { wait: true }, function (error, result) { - console.log("logged out others"); if (error) { callback && callback(error); } else { diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 68ea20ced0..aac5b8e3d9 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -1324,10 +1324,7 @@ _.extend(Connection.prototype, { _livedata_disconnected: function (msg) { var self = this; var reason = msg.reason; - if (reason === "logged_out" || reason === "token_expired") { - self.setUserId(null); - self.onReconnect = null; - } + self._onDisconnect && self._onDisconnect(reason); }, _livedata_error: function (msg) { From 09ba59c50b49e97f7d2d03097ec1277ba3b6d7a9 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Mon, 2 Sep 2013 19:05:00 -0700 Subject: [PATCH 14/18] Close users' connections when they or their tokens are deleted. --- packages/accounts-base/accounts_server.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 7f07d4bc84..203c0f6373 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -525,3 +525,24 @@ Meteor.users._ensureIndex('username', {unique: 1, sparse: 1}); Meteor.users._ensureIndex('emails.address', {unique: 1, sparse: 1}); Meteor.users._ensureIndex('services.resume.loginTokens.token', {unique: 1, sparse: 1}); + +/// +/// LOGGING OUT DELETED USERS +/// + +var closeTokensForUser = function (userTokens, reason) { + Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { + return token.token; + }), reason); +}; + +Meteor.users.find().observe({ + changed: function (newUser, oldUser) { + var removedTokens = _.difference(oldUser.services.resume.loginTokens, + newUser.services.resume.loginTokens); + closeTokensForUser(removedTokens, "token_deleted"); + }, + removed: function (oldUser) { + closeTokensForUser(oldUser.services.resume.loginTokens, "user_deleted"); + } +}); From dab6df0eb919d279617f047787e90ed729845a84 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 3 Sep 2013 20:41:55 -0700 Subject: [PATCH 15/18] Remove DDP disconnected message. Instead, using a friendlier error message for bad login tokens, and preferring localStorage tokens to the result of login method in onReconnect (since onReconnect doesn't get cleared because we don't have a disconnected message to know when to clean it). Also tried to make tests a little cleaner. --- packages/accounts-base/accounts_client.js | 56 ++++++----- packages/accounts-base/accounts_common.js | 2 +- packages/accounts-base/accounts_server.js | 75 ++++++++------ packages/accounts-base/localstorage_token.js | 6 +- packages/accounts-password/password_tests.js | 100 ++++++++++++------- packages/livedata/DDP.md | 12 --- packages/livedata/livedata_common.js | 3 - packages/livedata/livedata_connection.js | 6 -- packages/livedata/livedata_server.js | 39 ++------ 9 files changed, 149 insertions(+), 150 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index e60c417bf4..f61cf24e5e 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -91,13 +91,21 @@ Accounts.callLoginMethod = function (options) { if (err || !result || !result.token) { Meteor.connection.onReconnect = null; } else { - Meteor.connection.onReconnect = function() { + Meteor.connection.onReconnect = function () { reconnected = true; - // XXX A DDP disconnect message would be helpful here, to know if our - // connection got closed because of an expired token. + // If our token was updated in storage, use the latest one. + var storedToken = storedLoginToken(); + if (storedToken) { + result = { + token: storedToken, + tokenExpires: storedLoginTokenExpires() + }; + } if (! result.tokenExpires) result.tokenExpires = Accounts._tokenExpiration(new Date()); - if (! Accounts._tokenExpiresSoon(result.tokenExpires)) { + if (Accounts._tokenExpiresSoon(result.tokenExpires)) { + makeClientLoggedOut(); + } else { Accounts.callLoginMethod({ methodArguments: [{resume: result.token}], // Reconnect quiescence ensures that the user doesn't see an @@ -112,13 +120,6 @@ Accounts.callLoginMethod = function (options) { }}); } }; - - Meteor.connection._onDisconnect = function (reason) { - var loggedOutReasons = ["logged_out", "token_expired", - "user_deleted", "token_deleted"]; - if (_.indexOf(loggedOutReasons, reason) !== -1) - makeClientLoggedOut(); - }; } }; @@ -169,7 +170,6 @@ makeClientLoggedOut = function() { unstoreLoginToken(); Meteor.connection.setUserId(null); Meteor.connection.onReconnect = null; - Meteor.connection._onDisconnect = null; }; makeClientLoggedIn = function(userId, token, tokenExpires) { @@ -188,24 +188,26 @@ Meteor.logout = function (callback) { }); }; -// Set opts._noDelay to close other open connections without any delay, rather -// than the 10 second default delay. Used by test. -Meteor._logoutAllOthers = function (opts, callback) { - if (! callback && typeof opts === "Function") { - callback = opts; - opts = {}; - } - Meteor.apply('_logoutAllOthers', [opts], { wait: true }, +Meteor._logoutAllOthers = function (callback) { + // Our connection is going to be closed, but we don't want to call the + // onReconnect handler until the result comes back for this method, because + // the token will have been deleted on the server. Instead, wait until we get + // a new token and call the reconnect handler with that. + // XXX this is messy. + // XXX what if login gets called before the callback runs? + var origOnReconnect = Meteor.connection.onReconnect; + var userId = Meteor.userId(); + Meteor.connection.onReconnect = null; + Meteor.apply('_logoutAllOthers', [], { wait: true }, function (error, result) { if (error) { - callback && callback(error); - } else { - // The method should return a new valid token that we should - // start using. - makeClientLoggedIn(Meteor.userId(), result.token, - result.tokenExpires); - callback && callback(); + if (! Meteor.user()) + makeClientLoggedOut(); } + Meteor.connection.onReconnect = origOnReconnect; + storeLoginToken(userId, result.token, result.tokenExpires); + Meteor.connection.onReconnect(); + callback && callback(error); }); }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index ffbd10ad57..4b2cc95d70 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -31,7 +31,7 @@ Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", "_tokenLifetime", "_tokenExpirationInterval", - "_minTokenLifetime"]; + "_minTokenLifetime", "_connectionCloseDelay"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 203c0f6373..4374ad2589 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -95,36 +95,27 @@ Meteor.methods({ // Nuke everything: delete all the user's tokens and close all open // connections logged in as this user, except this connection. Returns a fresh // new login token that this client can use. - _logoutAllOthers: function (opts) { + _logoutAllOthers: function () { var self = this; - opts = opts || {}; var user = Meteor.users.findOne(self.userId); if (user) { var tokens = user.services.resume.loginTokens; - Meteor.users.update(self.userId, { - $set: { "services.resume.loginTokens": [] } - }); - // Wait 10 seconds before closing the connections. This is to give other - // clients using our token a chance to find a new token in localStorage so - // that when they get disconnected they reconnect with a valid token. - var delay = opts._noDelay ? 0 : 10; - Meteor.setTimeout(function () { - self._closeAllForTokens(_.map(tokens, function (token) { - return token.token; - }), "logged_out"); - }, delay * 1000); - var newToken = Accounts._generateStampedLoginToken(); Meteor.users.update(self.userId, { - $push: { - "services.resume.loginTokens": newToken + $set: { + "services.resume.loginTokens": [newToken] } }); - self._setLoginToken(newToken.token); + // We do not set the login token on this connection, to force the client + // to close this connection and open a new one with the new token. + // The observe on Meteor.users() will take care of closing connections + // with the right delay. return { token: newToken.token, tokenExpires: Accounts._tokenExpiration(newToken.when) }; + } else { + throw new Error("You are not logged in."); } } }); @@ -147,8 +138,10 @@ Accounts.registerLoginHandler(function(options) { "services.resume.loginTokens.token": ""+options.resume }); - if (!user) - throw new Meteor.Error(403, "Couldn't find login token"); + if (!user) { + throw new Meteor.Error(403, "You've been logged out by the server. " + + "Please login again."); + } var token = _.find(user.services.resume.loginTokens, function (token) { return token.token === options.resume; @@ -176,10 +169,11 @@ var removeLoginToken = function (userId, loginToken) { }; // Deletes expired tokens from the database and closes all open connections -// associated with these tokens. -var expireTokens = function () { +// associated with these tokens. Exported for tests. +var expireTokens = Accounts._expireTokens = function (oldestValidDate) { var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; - var oldestValidDate = new Date(new Date() - tokenLifetime * 1000); + oldestValidDate = oldestValidDate || + (new Date(new Date() - tokenLifetime * 1000)); var usersWithExpiredTokens = Meteor.users.find({ "services.resume.loginTokens.when": { $lt: oldestValidDate } }); @@ -203,8 +197,8 @@ var expireTokens = function () { } } }, { multi: true }); - - Meteor.server._closeAllForTokens(oldTokens, "token_expired"); + // The observe on Meteor.users will take care of closing connections for + // expired tokens. }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); @@ -530,19 +524,34 @@ Meteor.users._ensureIndex('services.resume.loginTokens.token', /// LOGGING OUT DELETED USERS /// -var closeTokensForUser = function (userTokens, reason) { - Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { - return token.token; - }), reason); +// By default, connections are closed with a 10 second delay, to give other +// clients a chance to find a new token in localStorage before +// reconnecting. Delay can be configured with Accounts.config. +var closeTokensForUser = function (userTokens) { + var delay = 10; + if (_.has(Accounts._options, "_connectionCloseDelay")) + delay = Accounts._options._connectionCloseDelay; + Meteor.setTimeout(function () { + Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { + return token.token; + })); + }, delay * 1000); }; Meteor.users.find().observe({ changed: function (newUser, oldUser) { - var removedTokens = _.difference(oldUser.services.resume.loginTokens, - newUser.services.resume.loginTokens); - closeTokensForUser(removedTokens, "token_deleted"); + var removedTokens = []; + if (newUser.services && newUser.services.resume && + oldUser.services && oldUser.services.resume) { + removedTokens = _.difference(oldUser.services.resume.loginTokens || [], + newUser.services.resume.loginTokens || []); + } else if (oldUser.services && oldUser.services.resume) { + removedTokens = oldUser.services.resume.loginTokens || []; + } + closeTokensForUser(removedTokens); }, removed: function (oldUser) { - closeTokensForUser(oldUser.services.resume.loginTokens, "user_deleted"); + if (oldUser.services && oldUser.services.resume) + closeTokensForUser(oldUser.services.resume.loginTokens || []); } }); diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 8e1fb21086..24c6e25318 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -63,10 +63,14 @@ unstoreLoginToken = function() { // This is private, but it is exported for now because it is used by a // test in accounts-password. // -var storedLoginToken = Accounts._storedLoginToken = function() { +storedLoginToken = Accounts._storedLoginToken = function() { return Meteor._localStorage.getItem(loginTokenKey); }; +storedLoginTokenExpires = function () { + return Meteor._localStorage.getItem(loginTokenExpiresKey); +}; + var storedUserId = function() { return Meteor._localStorage.getItem(userIdKey); }; diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 3ae24348b5..91d9141d36 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,7 +1,5 @@ Accounts.config({ - _tokenLifetime: 5, - _tokenExpirationInterval: 5, - _minTokenLifetime: 1 + _connectionCloseDelay: 0 }); if (Meteor.isClient) (function () { @@ -277,10 +275,6 @@ if (Meteor.isClient) (function () { }, logoutStep, function(test, expect) { - // XXX would be nice to write this test in a way that avoids the - // possibly-flaky timing stuff (e.g. have a test hook for expiring tokens - // on demand rather than waiting for them to expire) - // Test that login tokens get expired. We should get logged out when a // token expires, and not be able to log in again with the same token. var expectLoggedOut = expect(function () { @@ -289,29 +283,43 @@ if (Meteor.isClient) (function () { var expectLoginError = expect(function (err) { test.isTrue(err); }); - var expectToken = expect(function (token) { - test.isTrue(token); + var expectNoError = expect(function (err) { + test.isFalse(err); }); var token; + var firstLoginCallback = true; Meteor.loginWithPassword(username, password2, function (error) { - test.isFalse(error); - token = Accounts._storedLoginToken(); - expectToken(token); + // callback will be called again on reconnect after our token gets + // expired. + if (firstLoginCallback) { + token = Accounts._storedLoginToken(); + test.isTrue(token); + test.isFalse(error); + Meteor.call("expireTokens", new Date(), function (error, result) { + expectNoError(error); + }); + } else { + expectLoggedOut(); + Meteor.loginWithToken(token, function (err) { + test.isFalse(Meteor.userId()); + expectLoginError(err); + }); + } + firstLoginCallback = false; }); - Meteor.setTimeout(function () { - expectToken(token); - expectLoggedOut(); - Meteor.loginWithToken(token, expectLoginError); - }, 10*1000); }, logoutStep, function (test, expect) { // Test that Meteor._logoutAllOthers logs out a second authenticated // connection. - var expectLoggedIn = expect(function (err, result) { - test.isTrue(Meteor.user()); + + var expectNoError = expect(function (err) { + test.isFalse(err); }); - var expectLoginErr = expect(function (err) { + var expectSecondConnLoggedOut = expect(function () { + test.isFalse(secondConn.userId()); + }); + var expectLoginError = expect(function (err) { test.isTrue(err); }); @@ -325,23 +333,37 @@ if (Meteor.isClient) (function () { } var secondConn = DDP.connect(ddpUrl); - Meteor.loginWithPassword(username, password2, function (err, result) { - expectLoggedIn(err, result); - token = Accounts._storedLoginToken(); - secondConn.call("login", { - resume: token - }, function (err, result) { - test.isFalse(err); - Meteor._logoutAllOthers({ _noDelay: true }, function () { - // secondConn should be logged out and subsequently fail resume - // login, but Meteor.connection should stay logged in. - Meteor.setTimeout(function () { - test.isTrue(Meteor.user()); - test.isFalse(secondConn.userId()); - secondConn.call("login", { resume: token }, expectLoginErr); - }, 50); - }); + var firstLoginCallback = true; + + secondConn.onReconnect = function () { + expectSecondConnLoggedOut(); + secondConn.call("login", { resume: token }, function (err, result) { + test.isFalse(secondConn.userId()); + expectLoginError(err); }); + }; + + Meteor.loginWithPassword(username, password2, function (err, result) { + test.isFalse(err); + if (firstLoginCallback) { + test.isTrue(Meteor.user()); + token = Accounts._storedLoginToken(); + secondConn.call("login", { + resume: token + }, function (err, result) { + test.isFalse(err); + Meteor._logoutAllOthers(function (err) { + expectNoError(err); + }); + }); + } else { + // Callback fires again after reconnect. We should still be logged in, + // but secondConn should be logged out and subsequently fail resume + // login. + test.isFalse(err); + test.isTrue(Meteor.user()); + } + firstLoginCallback = false; }); }, logoutStep @@ -352,6 +374,12 @@ if (Meteor.isClient) (function () { if (Meteor.isServer) (function () { + Meteor.methods({ + expireTokens: function (oldestValidDate) { + Accounts._expireTokens(oldestValidDate); + } + }); + Tinytest.add( 'passwords - setup more than one onCreateUserHook', function (test) { diff --git a/packages/livedata/DDP.md b/packages/livedata/DDP.md index 2ef93578e3..dc59ece7c9 100644 --- a/packages/livedata/DDP.md +++ b/packages/livedata/DDP.md @@ -222,18 +222,6 @@ The error message contains the following fields: * `offendingMessage`: if the original message parsed properly, it is included here -## Disconnection - -### Messages: - - * `disconnected` (server->client) - - `reason`: string ("logged_out" or "token_expired") - -### Procedure: - - * Before the server disconnects a client, it can optionally send a disconnected - message to indicate the cause of the disconnect. - ## Appendix: EJSON EJSON is a way of embedding more than the built-in JSON types in JSON. It diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index dc2510c353..8c45863c85 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -33,9 +33,6 @@ MethodInvocation = function (options) { // deleted, etc. this._setLoginToken = options._setLoginToken || function () {}; - // Closes all open connections associated with the given tokens. - this._closeAllForTokens = options._closeAllForTokens || function () {}; - // Scratch data scoped to this connection (livedata_connection on the // client, livedata_session on the server). This is only used // internally, but we should have real and documented API for this diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index aac5b8e3d9..ef70ecb6ea 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -1321,12 +1321,6 @@ _.extend(Connection.prototype, { }); }, - _livedata_disconnected: function (msg) { - var self = this; - var reason = msg.reason; - self._onDisconnect && self._onDisconnect(reason); - }, - _livedata_error: function (msg) { Meteor._debug("Received error from server: ", msg.reason); if (msg.offendingMessage) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index c4bf1b06cf..aeee0f0204 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -452,12 +452,6 @@ _.extend(Session.prototype, { self.send(msg); }, - // Send a DDP disconnected message. - sendDisconnected: function (reason) { - var self = this; - self.send({ msg: "disconnected", reason: reason }); - }, - // Process 'msg' as an incoming message. (But as a guard against // race conditions during reconnection, ignore the message if // 'socket' is not the currently connected socket.) @@ -604,17 +598,11 @@ _.extend(Session.prototype, { self._setLoginToken(newToken); }; - // Closes all sessions associated with these tokens except this one. - var closeAll = function (tokens, reason) { - self._closeAllForTokens(tokens, reason); - }; - var invocation = new MethodInvocation({ isSimulation: false, userId: self.userId, setUserId: setUserId, _setLoginToken: setLoginToken, - _closeAllForTokens: closeAll, unblock: unblock, sessionData: self.sessionData }); @@ -676,11 +664,6 @@ _.extend(Session.prototype, { self.server._loginTokenChanged(self, newToken, oldToken); }, - _closeAllForTokens: function (tokens, reason) { - var self = this; - self.server._closeAllForTokens(tokens, reason, [self.id]); - }, - // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1370,18 +1353,12 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[newToken].push(session.id); }, - // Close all open sessions associated with any of the tokens in `tokens`. If - // `reason` is provided, sends each session a disconnected message before - // closing it. `excludeSessions` is an optional array of strings (session ids) + // Close all open sessions associated with any of the tokens in + // `tokens`. `excludeSessions` is an optional array of strings (session ids) // to not close, even if they match a token in `tokens`. - _closeAllForTokens: function (tokens, reason, excludeSessions) { + _closeAllForTokens: function (tokens, excludeSessions) { var self = this; - if (! excludeSessions && typeof reason === "object") { - excludeSessions = reason; - reason = undefined; - } - if (tokens.length) _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { @@ -1392,11 +1369,11 @@ _.extend(Server.prototype, { // Destroy session and remove from self.sessions. var session = self.sessions[sessionId]; - if (reason) - session.sendDisconnected(reason); - session.cleanup(); - session.destroy(); - delete self.sessions[sessionId]; + if (session) { + session.cleanup(); + session.destroy(); + delete self.sessions[sessionId]; + } destroyedIds.push(sessionId); }); From 4ebdbd9f77f5870441a72bb7c2c0b57f8897712c Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 4 Sep 2013 10:43:47 -0700 Subject: [PATCH 16/18] On logout, disassociate connection from token before deleting token. --- packages/accounts-base/accounts_server.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 4374ad2589..66ba568f0b 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -85,11 +85,10 @@ Meteor.methods({ logout: function() { var token = this._getLoginToken(); + this._setLoginToken(null); if (token && this.userId) removeLoginToken(this.userId, token); this.setUserId(null); - this._setLoginToken(null); - // XXX should close all connections open with this token? }, // Nuke everything: delete all the user's tokens and close all open @@ -160,6 +159,8 @@ Accounts._generateStampedLoginToken = function () { return {token: Random.id(), when: (new Date)}; }; +// Deletes the given loginToken from the database. This will cause all +// connections associated with the token to be closed. var removeLoginToken = function (userId, loginToken) { Meteor.users.update(userId, { $pull: { From d9093d6cf8933f26c47260968cb2846af78fe646 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 4 Sep 2013 10:53:46 -0700 Subject: [PATCH 17/18] Test for logging out a user's connections when deleted. Tests are flaky, not sure why yet. --- packages/accounts-password/password_tests.js | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 91d9141d36..56f1650bb1 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -366,7 +366,23 @@ if (Meteor.isClient) (function () { firstLoginCallback = false; }); }, - logoutStep + logoutStep, + function (test, expect) { + // Test that deleting a user logs out that user's connections. + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var firstLoginCallback = true; + Meteor.loginWithPassword(username, password2, function (err) { + if (firstLoginCallback) { + test.isFalse(err); + Meteor.call("removeUser", username); + } else { + expectLoginError(err); + } + firstLoginCallback = false; + }); + } ]); }) (); @@ -377,6 +393,9 @@ if (Meteor.isServer) (function () { Meteor.methods({ expireTokens: function (oldestValidDate) { Accounts._expireTokens(oldestValidDate); + }, + removeUser: function (username) { + Meteor.users.remove({ "username": username }); } }); From 0bc4bc9c26299085b0b5521dd1a1b7b1d67cad51 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 5 Sep 2013 17:11:27 -0700 Subject: [PATCH 18/18] Clean up token deletion and tests. --- packages/accounts-base/accounts_client.js | 7 +- packages/accounts-base/accounts_common.js | 30 +++--- packages/accounts-base/accounts_server.js | 69 +++++++------- packages/accounts-password/password_tests.js | 97 ++++++++------------ packages/livedata/livedata_common.js | 4 +- packages/livedata/livedata_connection.js | 2 - packages/livedata/livedata_server.js | 11 +-- 7 files changed, 98 insertions(+), 122 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index f61cf24e5e..9aef4bdc97 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -200,12 +200,9 @@ Meteor._logoutAllOthers = function (callback) { Meteor.connection.onReconnect = null; Meteor.apply('_logoutAllOthers', [], { wait: true }, function (error, result) { - if (error) { - if (! Meteor.user()) - makeClientLoggedOut(); - } Meteor.connection.onReconnect = origOnReconnect; - storeLoginToken(userId, result.token, result.tokenExpires); + if (! error) + storeLoginToken(userId, result.token, result.tokenExpires); Meteor.connection.onReconnect(); callback && callback(error); }); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 4b2cc95d70..1de58f1b2d 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -19,19 +19,23 @@ Accounts._options = {}; // client signups. // - forbidClientAccountCreation {Boolean} // Do not allow clients to create accounts directly. -// - _tokenLifetime {Number} +// - _tokenLifetimeSecs {Number} // Seconds until a login token expires. -// - _tokenExpirationInterval {Number} +// - _tokenExpirationIntervalSecs {Number} // How often (in seconds) to check for expired tokens -// - _minTokenLifetime {Number} +// - _minTokenLifetimeSecs {Number} // The minimum number of seconds until a token expires in order for the // client to be willing to connect with that token. +// - _connectionCloseDelaySecs {Number} +// The number of seconds to wait before closing connections that when a user +// is logged out by the server. Defaults to 10, to allow clients to store a +// fresh token in localStorage when calling _logoutAllOthers. // Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", - "_tokenLifetime", "_tokenExpirationInterval", - "_minTokenLifetime", "_connectionCloseDelay"]; + "_tokenLifetimeSecs", "_tokenExpirationIntervalSecs", + "_minTokenLifetimeSecs", "_connectionCloseDelaySecs"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -78,19 +82,19 @@ Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; // how long (in seconds) until a login token expires -DEFAULT_TOKEN_LIFETIME = 604800; // one week +DEFAULT_TOKEN_LIFETIME_SECS = 604800; // one week // We don't try to auto-login with a token that is going to expire within // MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. -var DEFAULT_MIN_TOKEN_LIFETIME = 3600; // one hour +var DEFAULT_MIN_TOKEN_LIFETIME_SECS = 3600; // one hour Accounts._tokenExpiration = function (when) { - var tokenLifetime = Accounts._options._tokenLifetime || - DEFAULT_TOKEN_LIFETIME; - return new Date(when.getTime() + tokenLifetime * 1000); + var tokenLifetimeSecs = Accounts._options._tokenLifetimeSecs || + DEFAULT_TOKEN_LIFETIME_SECS; + return new Date(when.getTime() + tokenLifetimeSecs * 1000); }; Accounts._tokenExpiresSoon = function (when) { - var minLifetime = Accounts._options._minTokenLifetime || - DEFAULT_MIN_TOKEN_LIFETIME; - return new Date() > (new Date(when) - minLifetime * 1000); + var minLifetimeSecs = Accounts._options._minTokenLifetimeSecs || + DEFAULT_MIN_TOKEN_LIFETIME_SECS; + return new Date() > (new Date(when) - minLifetimeSecs * 1000); }; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 66ba568f0b..c97f0e17ab 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -91,12 +91,16 @@ Meteor.methods({ this.setUserId(null); }, - // Nuke everything: delete all the user's tokens and close all open - // connections logged in as this user, except this connection. Returns a fresh - // new login token that this client can use. + // Nuke everything: delete all the current user's tokens and close all open + // connections logged in as this user. Returns a fresh new login token that + // this client can use. _logoutAllOthers: function () { var self = this; - var user = Meteor.users.findOne(self.userId); + var user = Meteor.users.findOne(self.userId, { + fields: { + "services.resume.loginTokens": true + } + }); if (user) { var tokens = user.services.resume.loginTokens; var newToken = Accounts._generateStampedLoginToken(); @@ -105,10 +109,9 @@ Meteor.methods({ "services.resume.loginTokens": [newToken] } }); - // We do not set the login token on this connection, to force the client - // to close this connection and open a new one with the new token. - // The observe on Meteor.users() will take care of closing connections - // with the right delay. + // We do not set the login token on this connection, but instead the + // observe closes the connection and the client will reconnect with the + // new token. return { token: newToken.token, tokenExpires: Accounts._tokenExpiration(newToken.when) @@ -125,7 +128,7 @@ Meteor.methods({ /// support reconnecting using a meteor login token // how often (in seconds) we check for expired tokens -var DEFAULT_EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes +var DEFAULT_EXPIRE_TOKENS_INTERVAL_SECS = 600; // 10 minutes // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { @@ -146,9 +149,13 @@ Accounts.registerLoginHandler(function(options) { return token.token === options.resume; }); + var tokenExpires = Accounts._tokenExpiration(token.when); + if (new Date() >= tokenExpires) + throw new Meteor.Error(403, "Your session has expired. Please login again."); + return { token: options.resume, - tokenExpires: Accounts._tokenExpiration(token.when), + tokenExpires: tokenExpires, id: user._id }; }); @@ -169,25 +176,19 @@ var removeLoginToken = function (userId, loginToken) { }); }; +/// +/// TOKEN EXPIRATION +/// + +var expireTokenInterval; + // Deletes expired tokens from the database and closes all open connections // associated with these tokens. Exported for tests. var expireTokens = Accounts._expireTokens = function (oldestValidDate) { - var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; + var tokenLifetimeSecs = Accounts._options._tokenLifetimeSecs || + DEFAULT_TOKEN_LIFETIME_SECS; oldestValidDate = oldestValidDate || - (new Date(new Date() - tokenLifetime * 1000)); - var usersWithExpiredTokens = Meteor.users.find({ - "services.resume.loginTokens.when": { $lt: oldestValidDate } - }); - - var oldTokens = []; - usersWithExpiredTokens.forEach(function (user) { - _.each(user.services.resume.loginTokens, function (token) { - if (typeof token.when === "number") - token.when = new Date(token.when); - if (token.when < oldestValidDate) - oldTokens.push(token.token); - }); - }); + (new Date(new Date() - tokenLifetimeSecs * 1000)); Meteor.users.update({ "services.resume.loginTokens.when": { $lt: oldestValidDate } @@ -203,13 +204,13 @@ var expireTokens = Accounts._expireTokens = function (oldestValidDate) { }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); -var expireTokenInterval; + initExpireTokenInterval = function () { if (expireTokenInterval) Meteor.clearInterval(expireTokenInterval); - var expirePeriod = Accounts._options._tokenExpirationInterval || - DEFAULT_EXPIRE_TOKENS_INTERVAL; - expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriod * 1000); + var expirePeriodSecs = Accounts._options._tokenExpirationIntervalSecs || + DEFAULT_EXPIRE_TOKENS_INTERVAL_SECS; + expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriodSecs * 1000); }; initExpireTokenInterval(); @@ -525,18 +526,20 @@ Meteor.users._ensureIndex('services.resume.loginTokens.token', /// LOGGING OUT DELETED USERS /// +var DEFAULT_CONNECTION_CLOSE_DELAY_SECS = 10; + // By default, connections are closed with a 10 second delay, to give other // clients a chance to find a new token in localStorage before // reconnecting. Delay can be configured with Accounts.config. var closeTokensForUser = function (userTokens) { - var delay = 10; - if (_.has(Accounts._options, "_connectionCloseDelay")) - delay = Accounts._options._connectionCloseDelay; + var delaySecs = DEFAULT_CONNECTION_CLOSE_DELAY_SECS; + if (_.has(Accounts._options, "_connectionCloseDelaySecs")) + delaySecs = Accounts._options._connectionCloseDelaySecs; Meteor.setTimeout(function () { Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { return token.token; })); - }, delay * 1000); + }, delaySecs * 1000); }; Meteor.users.find().observe({ diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 56f1650bb1..00b525f966 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,5 +1,5 @@ Accounts.config({ - _connectionCloseDelay: 0 + _connectionCloseDelaySecs: 0 }); if (Meteor.isClient) (function () { @@ -277,91 +277,68 @@ if (Meteor.isClient) (function () { function(test, expect) { // Test that login tokens get expired. We should get logged out when a // token expires, and not be able to log in again with the same token. - var expectLoggedOut = expect(function () { - test.equal(Meteor.user(), null); + var expectNoError = expect(function (err) { + test.isFalse(err); }); var expectLoginError = expect(function (err) { test.isTrue(err); }); - var expectNoError = expect(function (err) { - test.isFalse(err); - }); - var token; var firstLoginCallback = true; + Meteor.loginWithPassword(username, password2, function (error) { - // callback will be called again on reconnect after our token gets - // expired. + var token = Accounts._storedLoginToken(); if (firstLoginCallback) { - token = Accounts._storedLoginToken(); test.isTrue(token); - test.isFalse(error); - Meteor.call("expireTokens", new Date(), function (error, result) { - expectNoError(error); - }); + expectNoError(error); + Meteor.call("expireTokens", new Date()); } else { - expectLoggedOut(); - Meteor.loginWithToken(token, function (err) { - test.isFalse(Meteor.userId()); - expectLoginError(err); - }); + test.isFalse(token); + expectLoginError(error); } firstLoginCallback = false; }); }, logoutStep, function (test, expect) { - // Test that Meteor._logoutAllOthers logs out a second authenticated - // connection. - - var expectNoError = expect(function (err) { - test.isFalse(err); - }); - var expectSecondConnLoggedOut = expect(function () { - test.isFalse(secondConn.userId()); - }); - var expectLoginError = expect(function (err) { - test.isTrue(err); - }); - - var token; - // copied from livedata/client_convenience.js var ddpUrl = '/'; if (typeof __meteor_runtime_config__ !== "undefined") { if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; } + + // Test that Meteor._logoutAllOthers logs out a second authenticated + // connection while leaving Meteor.connection logged in. + var token; var secondConn = DDP.connect(ddpUrl); - var firstLoginCallback = true; + var userId; - secondConn.onReconnect = function () { - expectSecondConnLoggedOut(); - secondConn.call("login", { resume: token }, function (err, result) { - test.isFalse(secondConn.userId()); - expectLoginError(err); - }); - }; - - Meteor.loginWithPassword(username, password2, function (err, result) { + var expectNoError = expect(function (err) { test.isFalse(err); + }); + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var expectLoggedIn = expect(function () { + test.equal(userId, Meteor.userId()); + }); + var expectSecondConnLoggedIn = expect(function (err, result) { + test.equal(result.token, token); + test.isFalse(err); + secondConn.onReconnect = function () { + secondConn.call("login", { resume: token }, expectLoginError); + }; + Meteor.call("_logoutAllOthers", expectLoggedIn); + }); + + Meteor.loginWithPassword(username, password2, function (err) { if (firstLoginCallback) { - test.isTrue(Meteor.user()); + expectNoError(err); token = Accounts._storedLoginToken(); - secondConn.call("login", { - resume: token - }, function (err, result) { - test.isFalse(err); - Meteor._logoutAllOthers(function (err) { - expectNoError(err); - }); - }); - } else { - // Callback fires again after reconnect. We should still be logged in, - // but secondConn should be logged out and subsequently fail resume - // login. - test.isFalse(err); - test.isTrue(Meteor.user()); + test.isTrue(token); + userId = Meteor.userId(); + secondConn.call("login", { resume: token }, expectSecondConnLoggedIn); } firstLoginCallback = false; }); @@ -377,6 +354,8 @@ if (Meteor.isClient) (function () { if (firstLoginCallback) { test.isFalse(err); Meteor.call("removeUser", username); + // When the user is deleted, our connection will be closed, triggering + // a reconnect, which will trigger a login attempt. } else { expectLoginError(err); } diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index 8c45863c85..5ce89ed443 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -29,8 +29,8 @@ MethodInvocation = function (options) { // reruns subscriptions this._setUserId = options.setUserId || function () {}; - // saves the login token so we can delete it later when the user logs out, is - // deleted, etc. + // used for associating the connection with a login token so that the + // connection can be closed if the token is no longer valid this._setLoginToken = options._setLoginToken || function () {}; // Scratch data scoped to this connection (livedata_connection on the diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index ef70ecb6ea..04fb380f90 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -209,8 +209,6 @@ var Connection = function (url, options) { self._livedata_nosub(msg); else if (msg.msg === 'result') self._livedata_result(msg); - else if (msg.msg === 'disconnected') - self._livedata_disconnected(msg); else if (msg.msg === 'error') self._livedata_error(msg); else diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index aeee0f0204..fb3a28a0ed 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -1294,6 +1294,7 @@ _.extend(Server.prototype, { throw new Error("Can't call setUserId on a server initiated method call"); }; var setLoginToken = function () { + // XXX is this correct? throw new Error("Can't call _setLoginToken on a server " + "initiated method call"); }; @@ -1354,19 +1355,13 @@ _.extend(Server.prototype, { }, // Close all open sessions associated with any of the tokens in - // `tokens`. `excludeSessions` is an optional array of strings (session ids) - // to not close, even if they match a token in `tokens`. - _closeAllForTokens: function (tokens, excludeSessions) { + // `tokens`. + _closeAllForTokens: function (tokens) { var self = this; - - if (tokens.length) _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { var destroyedIds = []; _.each(self.sessionsByLoginToken[token], function (sessionId) { - if (_.indexOf(excludeSessions, sessionId) !== -1) - return; - // Destroy session and remove from self.sessions. var session = self.sessions[sessionId]; if (session) {