mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Merge branch 'expire-tokens' into devel
This commit is contained in:
@@ -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
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 || []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}});
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user