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