Merge branch 'expire-tokens' into devel

This commit is contained in:
Emily Stark
2013-09-11 13:44:58 -07:00
8 changed files with 455 additions and 34 deletions

View File

@@ -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
///

View File

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

View File

@@ -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 || []);
}
});

View File

@@ -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

View File

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

View File

@@ -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) {

View File

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

View File

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