diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 3dd0c5cd35..9aef4bdc97 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -91,20 +91,34 @@ Accounts.callLoginMethod = function (options) { if (err || !result || !result.token) { Meteor.connection.onReconnect = null; } else { - Meteor.connection.onReconnect = function() { + 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); - }}); + // 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)) { + makeClientLoggedOut(); + } else { + 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); + }}); + } }; } }; @@ -139,7 +153,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 +172,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); }; @@ -174,6 +188,26 @@ Meteor.logout = function (callback) { }); }; +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) { + Meteor.connection.onReconnect = origOnReconnect; + if (! error) + storeLoginToken(userId, result.token, result.tokenExpires); + Meteor.connection.onReconnect(); + callback && callback(error); + }); +}; + /// /// LOGIN SERVICES /// diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 0db0271db9..1de58f1b2d 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -19,10 +19,23 @@ Accounts._options = {}; // client signups. // - forbidClientAccountCreation {Boolean} // Do not allow clients to create accounts directly. +// - _tokenLifetimeSecs {Number} +// Seconds until a login token expires. +// - _tokenExpirationIntervalSecs {Number} +// How often (in seconds) to check for expired tokens +// - _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"]; + var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", + "_tokenLifetimeSecs", "_tokenExpirationIntervalSecs", + "_minTokenLifetimeSecs", "_connectionCloseDelaySecs"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -36,6 +49,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(); } } }); @@ -66,3 +81,20 @@ Accounts.LoginCancelledError.numericError = 0x8acdc2f; Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; +// how long (in seconds) until a login token expires +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_SECS = 3600; // one hour + +Accounts._tokenExpiration = function (when) { + var tokenLifetimeSecs = Accounts._options._tokenLifetimeSecs || + DEFAULT_TOKEN_LIFETIME_SECS; + return new Date(when.getTime() + tokenLifetimeSecs * 1000); +}; + +Accounts._tokenExpiresSoon = function (when) { + 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 cdcd995ace..c97f0e17ab 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 +// 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) { @@ -74,15 +78,47 @@ 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(); + this._setLoginToken(null); + if (token && this.userId) + removeLoginToken(this.userId, token); this.setUserId(null); + }, + + // 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, { + fields: { + "services.resume.loginTokens": true + } + }); + if (user) { + var tokens = user.services.resume.loginTokens; + var newToken = Accounts._generateStampedLoginToken(); + Meteor.users.update(self.userId, { + $set: { + "services.resume.loginTokens": [newToken] + } + }); + // 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) + }; + } else { + throw new Error("You are not logged in."); + } } }); @@ -91,6 +127,9 @@ Meteor.methods({ /// /// support reconnecting using a meteor login token +// how often (in seconds) we check for expired tokens +var DEFAULT_EXPIRE_TOKENS_INTERVAL_SECS = 600; // 10 minutes + // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { if (!options.resume) @@ -100,11 +139,23 @@ 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"); + + 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; + }); + + 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: tokenExpires, id: user._id }; }); @@ -115,7 +166,9 @@ Accounts._generateStampedLoginToken = function () { return {token: Random.id(), when: (new Date)}; }; -removeLoginToken = function (userId, loginToken) { +// 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: { "services.resume.loginTokens": { "token": loginToken } @@ -123,6 +176,43 @@ 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 tokenLifetimeSecs = Accounts._options._tokenLifetimeSecs || + DEFAULT_TOKEN_LIFETIME_SECS; + oldestValidDate = oldestValidDate || + (new Date(new Date() - tokenLifetimeSecs * 1000)); + + Meteor.users.update({ + "services.resume.loginTokens.when": { $lt: oldestValidDate } + }, { + $pull: { + "services.resume.loginTokens": { + when: { $lt: oldestValidDate } + } + } + }, { multi: true }); + // The observe on Meteor.users will take care of closing connections for + // expired tokens. +}; + +Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); + +initExpireTokenInterval = function () { + if (expireTokenInterval) + Meteor.clearInterval(expireTokenInterval); + var expirePeriodSecs = Accounts._options._tokenExpirationIntervalSecs || + DEFAULT_EXPIRE_TOKENS_INTERVAL_SECS; + expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriodSecs * 1000); +}; +initExpireTokenInterval(); /// /// CREATE USER HOOKS @@ -164,6 +254,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); @@ -280,7 +371,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. @@ -426,3 +521,41 @@ 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 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 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; + })); + }, delaySecs * 1000); +}; + +Meteor.users.find().observe({ + changed: function (newUser, oldUser) { + 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) { + 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 91205a6742..24c6e25318 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -27,6 +27,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 +38,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 +53,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 @@ -58,14 +63,23 @@ 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); }; +var unstoreLoginTokenIfExpiresSoon = function () { + var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); + if (tokenExpires && Accounts._tokenExpiresSoon(new Date(tokenExpires))) + 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..1771cb1e91 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._tokenExpiration(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 + }; }}); diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 3cfec9511f..00b525f966 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,3 +1,7 @@ +Accounts.config({ + _connectionCloseDelaySecs: 0 +}); + if (Meteor.isClient) (function () { // XXX note, only one test can do login/logout things at once! for @@ -255,6 +259,7 @@ if (Meteor.isClient) (function () { test.equal(result, null); })); }, + logoutStep, function(test, expect) { var expectLoginError = expect(function (err) { test.isTrue(err); @@ -267,8 +272,96 @@ 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 expectNoError = expect(function (err) { + test.isFalse(err); + }); + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var firstLoginCallback = true; + Meteor.loginWithPassword(username, password2, function (error) { + var token = Accounts._storedLoginToken(); + if (firstLoginCallback) { + test.isTrue(token); + expectNoError(error); + Meteor.call("expireTokens", new Date()); + } else { + test.isFalse(token); + expectLoginError(error); + } + firstLoginCallback = false; + }); + }, + logoutStep, + function (test, expect) { + // 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; + + 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) { + expectNoError(err); + token = Accounts._storedLoginToken(); + test.isTrue(token); + userId = Meteor.userId(); + secondConn.call("login", { resume: token }, expectSecondConnLoggedIn); + } + firstLoginCallback = false; + }); + }, + 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); + // When the user is deleted, our connection will be closed, triggering + // a reconnect, which will trigger a login attempt. + } else { + expectLoginError(err); + } + firstLoginCallback = false; + }); + } ]); }) (); @@ -276,6 +369,15 @@ if (Meteor.isClient) (function () { if (Meteor.isServer) (function () { + Meteor.methods({ + expireTokens: function (oldestValidDate) { + Accounts._expireTokens(oldestValidDate); + }, + removeUser: function (username) { + Meteor.users.remove({ "username": username }); + } + }); + Tinytest.add( 'passwords - setup more than one onCreateUserHook', function (test) { diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index b39b65e491..5ce89ed443 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 () {}; + // 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 // 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 = token; + }, + _getLoginToken: function (token) { + return this._sessionData.loginToken; } }); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 5c396c4a00..fb3a28a0ed 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) { + self._setLoginToken(newToken); + }; + 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,13 @@ _.extend(Session.prototype, { }); }, + _setLoginToken: function (newToken) { + var self = this; + var oldToken = self.sessionData.loginToken; + self.sessionData.loginToken = newToken; + self.server._loginTokenChanged(self, newToken, oldToken); + }, + // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1026,6 +1039,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) { @@ -1099,6 +1118,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); @@ -1269,17 +1293,27 @@ _.extend(Server.prototype, { var setUserId = function() { 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"); + }; var currentInvocation = DDP._CurrentInvocation.get(); if (currentInvocation) { userId = currentInvocation.userId; setUserId = function(userId) { currentInvocation.setUserId(userId); }; + setLoginToken = function (newToken) { + currentInvocation._setLoginToken(newToken); + }; } var invocation = new MethodInvocation({ isSimulation: false, - userId: userId, setUserId: setUserId, + userId: userId, + setUserId: setUserId, + _setLoginToken: setLoginToken, sessionData: self.sessionData }); try { @@ -1304,6 +1338,49 @@ _.extend(Server.prototype, { if (exception) throw exception; return result; + }, + + _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( + self.sessionsByLoginToken[oldToken], + session.id + ); + } + if (! _.has(self.sessionsByLoginToken, newToken)) + self.sessionsByLoginToken[newToken] = []; + self.sessionsByLoginToken[newToken].push(session.id); + }, + + // Close all open sessions associated with any of the tokens in + // `tokens`. + _closeAllForTokens: function (tokens) { + var self = this; + _.each(tokens, function (token) { + if (_.has(self.sessionsByLoginToken, token)) { + var destroyedIds = []; + _.each(self.sessionsByLoginToken[token], function (sessionId) { + // Destroy session and remove from self.sessions. + var session = self.sessions[sessionId]; + if (session) { + 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) { + return _.indexOf(destroyedIds, sessionId) === -1; + } + ); + } + }); } });