Add token expiration test and fix bugs so it passes.

Also make all token-expiration-related times configurable via Accounts.config.
This commit is contained in:
Emily Stark
2013-08-29 18:46:58 -07:00
parent 2ebdeb0d95
commit 8621c18bc1
7 changed files with 97 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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