diff --git a/.gitignore b/.gitignore index 46396713f1..0bac695bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ /dev_bundle /dev_bundle*.tar.gz /dist -\#*# +\#*\# +.\#* .idea diff --git a/app/meteor/skel/.meteor/packages b/app/meteor/skel/.meteor/packages index 6c923cd368..2ca3c152a4 100644 --- a/app/meteor/skel/.meteor/packages +++ b/app/meteor/skel/.meteor/packages @@ -4,4 +4,5 @@ # but you can also edit it by hand. autopublish +insecure preserve-inputs diff --git a/docs/client/api.html b/docs/client/api.html index 3aaefe2aec..d940b2c750 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -337,13 +337,28 @@ To call methods on another Meteor application or subscribe to its data sets, call `Meteor.connect` with the URL of the application. `Meteor.connect` returns an object which provides: -* `subscribe` -* `methods` (to define stubs) -* `call` -* `apply` -* `status` -* `reconnect` +* `subscribe` - + Subscribe to a record set. See + Meteor.subscribe. +* `call` - + Invoke a method. See Meteor.call. +* `apply` - + Invoke a method with an argument array. See + Meteor.apply. +* `methods` - + Define client-only stubs for methods defined on the remote server. See + Meteor.methods. +* `status` - + Get the current connection status. See + Meteor.status. +* `reconnect` - + See Meteor.reconnect. +* `onReconnect` - Set this to a function to be called as the first step of + reconnecting. This function can call methods which will be executed before + any other outstanding methods. For example, this can be used to re-establish + the appropriate authentication context on the new connection. +By default, clients open a connection to the server from which they're loaded. When you call `Meteor.subscribe`, `Meteor.status`, `Meteor.call`, and `Meteor.apply`, you are using a connection back to that default server. @@ -1237,7 +1252,7 @@ Matches a particular type of event, such as 'click'. {{#dtdd "eventtype selector"}} Matches a particular type of event, but only when it appears on -an element that matches a certain CSS selector. +an element that matches a certain CSS selector. {{/dtdd}} {{#dtdd "event1, event2"}} diff --git a/docs/client/api.js b/docs/client/api.js index bc904124c7..283ff58082 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -239,7 +239,7 @@ Template.api.meteor_call = { Template.api.meteor_apply = { id: "meteor_apply", - name: "Meteor.apply(name, params [, asyncCallback])", + name: "Meteor.apply(name, params [, options] [, asyncCallback])", locus: "Anywhere", descr: ["Invoke a method passing an array of arguments."], args: [ @@ -252,6 +252,12 @@ Template.api.meteor_apply = { {name: "asyncCallback", type: "Function", descr: "Optional callback. If passed, the method runs asynchronously, instead of synchronously, and calls asyncCallback passing either the error or the result."} + ], + options: [ + {name: "wait", + type: "Boolean", + descr: "(Client only) If true, don't send any subsequent method calls until this one is completed. " + + "Only run the callback for this method once all previous method calls have completed."} ] }; @@ -288,18 +294,19 @@ Template.api.connect = { Template.api.meteor_collection = { id: "meteor_collection", - name: "new Meteor.Collection(name, manager)", // driver undocumented + name: "new Meteor.Collection(name, [options])", locus: "Anywhere", descr: ["Constructor for a Collection"], args: [ {name: "name", type: "String", - descr: "The name of the collection. If null, creates an unmanaged (unsynchronized) local collection."}, + descr: "The name of the collection. If null, creates an unmanaged (unsynchronized) local collection."} + ], + options: [ {name: "manager", type: "Object", descr: "The Meteor connection that will manage this collection, defaults to `Meteor` if null. Unmanaged (`name` is null) collections cannot specify a manager." } - // driver ] }; @@ -760,7 +767,7 @@ Template.api.equals = { Template.api.httpcall = { id: "meteor_http_call", - name: "Meteor.http.call(method, url, [options], [asyncCallback])", + name: "Meteor.http.call(method, url [, options] [, asyncCallback])", locus: "Anywhere", descr: ["Perform an outbound HTTP request."], args: [ diff --git a/examples/leaderboard/.meteor/packages b/examples/leaderboard/.meteor/packages index 6c923cd368..2ca3c152a4 100644 --- a/examples/leaderboard/.meteor/packages +++ b/examples/leaderboard/.meteor/packages @@ -4,4 +4,5 @@ # but you can also edit it by hand. autopublish +insecure preserve-inputs diff --git a/examples/todos/.meteor/packages b/examples/todos/.meteor/packages index d5213759ba..fb21b5e7f2 100644 --- a/examples/todos/.meteor/packages +++ b/examples/todos/.meteor/packages @@ -6,5 +6,12 @@ underscore backbone spiderable +accounts-ui +accounts-weibo +accounts-google +accounts-facebook +accounts-password +accounts-twitter jquery preserve-inputs +accounts-github diff --git a/examples/todos/client/todos.css b/examples/todos/client/todos.css index c21e7c8436..405d35d494 100644 --- a/examples/todos/client/todos.css +++ b/examples/todos/client/todos.css @@ -44,6 +44,9 @@ h3 { right: 0; top: 0; bottom: 0; +} + +#tag-filter, #main-pane, #side-pane, #bottom-pane { overflow: hidden; } @@ -55,6 +58,10 @@ h3 { border-bottom: 1px solid #999; } +#tag-filter { + height: 44px; /* same as in #top-tag-filter */ +} + #help { padding: 8px; } @@ -261,3 +268,19 @@ h3 { width: 80px; } +.toggle-privacy-wrapper { + float: right; + width: 110px; +} + +.toggle-privacy { + margin-top: 15px; + float: right; + cursor: pointer; +} + +.login-bar { + float: right; + /* center login buttons vertically in our top bar */ + padding: 10px 10px 0px 0px; +} diff --git a/examples/todos/client/todos.html b/examples/todos/client/todos.html index 5cf0c45b8c..b02fac3a10 100644 --- a/examples/todos/client/todos.html +++ b/examples/todos/client/todos.html @@ -4,6 +4,9 @@
+
+ {{> loginButtons}} +
{{> tag_filter}}
@@ -68,6 +71,21 @@
{{text}}
{{/if}} + {{#if currentUser}} +
+
+ {{#if privateTo}} + + make public + + {{else}} + + make private + + {{/if}} +
+
+ {{/if}}
{{#each tag_objs}}
@@ -98,5 +116,3 @@ {{/each}}
- - diff --git a/examples/todos/client/todos.js b/examples/todos/client/todos.js index ee8e394d29..7df2acb073 100644 --- a/examples/todos/client/todos.js +++ b/examples/todos/client/todos.js @@ -19,7 +19,6 @@ Session.set('editing_listname', null); // When editing todo text, ID of the todo Session.set('editing_itemname', null); - // Subscribe to 'lists' collection on startup. // Select a list once data has arrived. Meteor.subscribe('lists', function () { @@ -217,11 +216,22 @@ Template.todo_item.events({ evt.target.parentNode.style.opacity = 0; // wait for CSS animation to finish Meteor.setTimeout(function () { - Todos.update({_id: id}, {$pull: {tags: tag}}); + Todos.update(id, {$pull: {tags: tag}}); }, 300); + }, + + 'click .make-public': function () { + Todos.update(this._id, {$set: {privateTo: null}}); + }, + + 'click .make-private': function () { + Todos.update(this._id, {$set: { + privateTo: Meteor.user()._id + }}); } }); + Template.todo_item.events(okCancelEvents( '#todo-input', { diff --git a/examples/todos/server/access_control.js b/examples/todos/server/access_control.js new file mode 100644 index 0000000000..418a323aa6 --- /dev/null +++ b/examples/todos/server/access_control.js @@ -0,0 +1,19 @@ +Meteor.startup(function() { + var canModify = function(userId, tasks) { + return _.all(tasks, function(task) { + return !task.privateTo || task.privateTo === userId; + }); + }; + + Todos.allow({ + insert: function () { return true; }, + update: canModify, + remove: canModify, + fetch: ['privateTo'] + }); + + Lists.allow({ + insert: function () { return true; } + // can't update or remove + }); +}); diff --git a/examples/todos/server/publish.js b/examples/todos/server/publish.js index 76c3630ee2..5144612e7a 100644 --- a/examples/todos/server/publish.js +++ b/examples/todos/server/publish.js @@ -14,8 +14,13 @@ Meteor.publish('lists', function () { // timestamp: Number} Todos = new Meteor.Collection("todos"); -// Publish all items for requested list_id. +// Publish visible items for requested list_id. Meteor.publish('todos', function (list_id) { - return Todos.find({list_id: list_id}); + return Todos.find({ + list_id: list_id, + privateTo: { + $in: [null, this.userId] + } + }); }); diff --git a/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js b/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js index 25edfd7f49..29825f1ae3 100644 --- a/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js +++ b/examples/unfinished/leaderboard-remote/client/leaderboard-remote.js @@ -1,7 +1,7 @@ Leaderboard = Meteor.connect("http://leader2.meteor.com/sockjs"); // XXX I'd rather this be Leaderboard.Players.. can this API be easier? -Players = new Meteor.Collection("players", Leaderboard); +Players = new Meteor.Collection("players", {manager: Leaderboard}); Template.main.events = { 'keydown': function () { diff --git a/examples/unfinished/python-ddp-client/test_input b/examples/unfinished/python-ddp-client/test_input index bdbe747ce8..07da1e3b20 100644 --- a/examples/unfinished/python-ddp-client/test_input +++ b/examples/unfinished/python-ddp-client/test_input @@ -1,15 +1,15 @@ sub -sub xcxc -sub xcxc yzyz -sub xcxc {} +sub undefinedSub +sub undefinedSub someArg +sub undefinedSub {} sub allApps sub myApp "foo.bar" sub myApp ["foo.meteor.com"] call -call xcxc -call xcxc yzyz -call xcxc {} +call undefinedMethod +call undefinedMethod yzyz +call undefinedMethod {} call vote call vote [] call vote ["foo.meteor.com"] diff --git a/examples/wordplay/.meteor/packages b/examples/wordplay/.meteor/packages index 87e2506e78..011b739a68 100644 --- a/examples/wordplay/.meteor/packages +++ b/examples/wordplay/.meteor/packages @@ -3,5 +3,6 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. +insecure jquery preserve-inputs diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js new file mode 100644 index 0000000000..0a50eba3d8 --- /dev/null +++ b/packages/accounts-base/accounts_client.js @@ -0,0 +1,109 @@ +(function () { + + Meteor.userId = function () { + return Meteor.default_connection.userId(); + }; + + var userLoadedListeners = new Meteor.deps._ContextSet; + var currentUserSubscriptionData; + + Meteor.userLoaded = function () { + userLoadedListeners.addCurrentContext(); + return currentUserSubscriptionData && currentUserSubscriptionData.loaded; + }; + + // This calls userId and userLoaded, both of which are reactive. + Meteor.user = function () { + var userId = Meteor.userId(); + if (!userId) + return null; + if (Meteor.userLoaded()) + return Meteor.users.findOne(userId); + // Not yet loaded: return a minimal object. + return {_id: userId}; + }; + + Accounts._makeClientLoggedOut = function() { + Accounts._unstoreLoginToken(); + Meteor.default_connection.setUserId(null); + Meteor.default_connection.onReconnect = null; + userLoadedListeners.invalidateAll(); + if (currentUserSubscriptionData) { + currentUserSubscriptionData.handle.stop(); + currentUserSubscriptionData = null; + } + }; + + Accounts._makeClientLoggedIn = function(userId, token) { + Accounts._storeLoginToken(userId, token); + Meteor.default_connection.setUserId(userId); + Meteor.default_connection.onReconnect = function() { + Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) { + if (error) { + Accounts._makeClientLoggedOut(); + throw error; + } else { + // nothing to do + } + }); + }; + userLoadedListeners.invalidateAll(); + if (currentUserSubscriptionData) { + currentUserSubscriptionData.handle.stop(); + } + var data = currentUserSubscriptionData = {loaded: false}; + data.handle = Meteor.subscribe( + "meteor.currentUser", function () { + // Important! We use "data" here, not "currentUserSubscriptionData", so + // that if we log out and in again before this subscription is ready, we + // don't make currentUserSubscriptionData look ready just because this + // older iteration of subscribing is ready. + data.loaded = true; + userLoadedListeners.invalidateAll(); + }); + }; + + Meteor.logout = function (callback) { + Meteor.apply('logout', [], {wait: true}, function(error, result) { + if (error) { + callback && callback(error); + } else { + Accounts._makeClientLoggedOut(); + callback && callback(); + } + }); + }; + + // If we're using Handlebars, register the {{currentUser}} and + // {{currentUserLoaded}} global helpers. + if (typeof Handlebars !== 'undefined') { + Handlebars.registerHelper('currentUser', function () { + return Meteor.user(); + }); + Handlebars.registerHelper('currentUserLoaded', function () { + return Meteor.userLoaded(); + }); + } + + // XXX this can be simplified if we merge in + // https://github.com/meteor/meteor/pull/273 + var loginServicesConfigured = false; + var loginServicesConfiguredListeners = new Meteor.deps._ContextSet; + Meteor.subscribe("meteor.loginServiceConfiguration", function () { + loginServicesConfigured = true; + loginServicesConfiguredListeners.invalidateAll(); + }); + + // A reactive function returning whether the + // loginServiceConfiguration subscription is ready. Used by + // accounts-ui to hide the login button until we have all the + // configuration loaded + Accounts.loginServicesConfigured = function () { + if (loginServicesConfigured) + return true; + + // not yet complete, save the context for invalidation once we are. + loginServicesConfiguredListeners.addCurrentContext(); + return false; + }; +})(); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js new file mode 100644 index 0000000000..f83aced014 --- /dev/null +++ b/packages/accounts-base/accounts_common.js @@ -0,0 +1,59 @@ +if (typeof Accounts === 'undefined') + Accounts = {}; + +if (!Accounts._options) { + Accounts._options = {}; +} + +// @param options {Object} an object with fields: +// - sendVerificationEmail {Boolean} +// Send email address verification emails to new users created from +// client signups. +// - forbidClientAccountCreation {Boolean} +// Do not allow clients to create accounts directly. +Accounts.config = function(options) { + _.each(["sendVerificationEmail", "forbidClientAccountCreation"], function(key) { + if (key in options) { + if (key in Accounts._options) + throw new Error("Can't set `" + key + "` more than once"); + else + Accounts._options[key] = options[key]; + } + }); +}; + +// Users table. Don't use the normal autopublish, since we want to hide +// some fields. Code to autopublish this is in accounts_server.js. +// XXX Allow users to configure this collection name. +Meteor.users = new Meteor.Collection("users", {_preventAutopublish: true}); +// There is an allow call in accounts_server that restricts this +// collection. + + +// Table containing documents with configuration options for each +// login service +Accounts.loginServiceConfiguration = new Meteor.Collection( + "meteor_accounts_loginServiceConfiguration", {_preventAutopublish: true}); +// Leave this collection open in insecure mode. In theory, someone could +// hijack your oauth connect requests to a different endpoint or appId, +// but you did ask for 'insecure'. The advantage is that it is much +// easier to write a configuration wizard that works only in insecure +// mode. + + +// Thrown when trying to use a login service which is not configured +Accounts.ConfigError = function(description) { + this.message = description; +}; +Accounts.ConfigError.prototype = new Error(); +Accounts.ConfigError.prototype.name = 'Accounts.ConfigError'; + +// Thrown when the user cancels the login process (eg, closes an oauth +// popup, declines retina scan, etc) +Accounts.LoginCancelledError = function(description) { + this.message = description; + this.cancelled = true; +}; +Accounts.LoginCancelledError.prototype = new Error(); +Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; + diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js new file mode 100644 index 0000000000..9a4b112466 --- /dev/null +++ b/packages/accounts-base/accounts_server.js @@ -0,0 +1,338 @@ +(function () { + /// + /// LOGIN HANDLERS + /// + + Meteor.methods({ + // @returns {Object|null} + // If successful, returns {token: reconnectToken, id: userId} + // If unsuccessful (for example, if the user closed the oauth login popup), + // returns null + login: function(options) { + var result = tryAllLoginHandlers(options); + if (result !== null) + this.setUserId(result.id); + return result; + }, + + logout: function() { + this.setUserId(null); + } + }); + + Accounts._loginHandlers = []; + + // Try all of the registered login handlers until one of them + // doesn't return `undefined` (NOT null), meaning it handled this + // call to `login`. Return that return value. + var tryAllLoginHandlers = function (options) { + var result = undefined; + + _.find(Accounts._loginHandlers, function(handler) { + + var maybeResult = handler(options); + if (maybeResult !== undefined) { + result = maybeResult; + return true; + } else { + return false; + } + }); + + if (result === undefined) { + throw new Meteor.Error(400, "Unrecognized options for login request"); + } else { + return result; + } + }; + + // @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; + // - `null`, meaning the user didn't actually log in; + // - {id: userId, accessToken: *}, if the user logged in successfully. + Accounts.registerLoginHandler = function(handler) { + Accounts._loginHandlers.push(handler); + }; + + // support reconnecting using a meteor login token + Accounts._generateStampedLoginToken = function () { + return {token: Meteor.uuid(), when: +(new Date)}; + }; + + Accounts.registerLoginHandler(function(options) { + if (options.resume) { + var user = Meteor.users.findOne( + {"services.resume.loginTokens.token": options.resume}); + if (!user) + throw new Meteor.Error(403, "Couldn't find login token"); + + return { + token: options.resume, + id: user._id + }; + } else { + return undefined; + } + }); + + + /// + /// CURRENT USER + /// + Meteor.userId = function () { + // This function only works if called inside a method. In theory, it + // could also be called from publish statements, since they also + // have a userId associated with them. However, given that publish + // functions aren't reactive, using any of the infomation from + // Meteor.user() in a publish function will always use the value + // from when the function first runs. This is likely not what the + // user expects. The way to make this work in a publish is to do + // Meteor.find(this.userId()).observe and recompute when the user + // record changes. + var currentInvocation = Meteor._CurrentInvocation.get(); + if (!currentInvocation) + throw new Error("Meteor.userId can only be invoked in method calls. Use this.userId in publish functions."); + return currentInvocation.userId; + }; + + Meteor.user = function () { + var userId = Meteor.userId(); + if (!userId) + return null; + return Meteor.users.findOne(userId); + }; + + /// + /// CREATE USER HOOKS + /// + var onCreateUserHook = null; + Accounts.onCreateUser = function (func) { + if (onCreateUserHook) + throw new Error("Can only call onCreateUser once"); + else + onCreateUserHook = func; + }; + + // XXX see comment on Accounts.createUser in passwords_server about adding a + // third "server options" argument. + var defaultCreateUserHook = function (options, extra, user) { + // This hook gets 'extra' directly from the createUser method, so make sure + // we don't allow users to set any fields at creation time that they won't + // later be able to set according to the default Meteor.users.allow. Set + // your own onCreateUser if you want users to be able to specify other + // fields at creation time. + if (_.any(extra, function(value, key) {return key != 'profile';})) { + console.log(JSON.stringify(extra)); + throw new Meteor.Error(400, "Disallowed fields in extra"); + } + + return _.extend(user, extra); + }; + Accounts.insertUserDoc = function (options, extra, user) { + // add created at timestamp (and protect passed in user object from + // modification) + user = _.extend({createdAt: +(new Date)}, user); + + var fullUser; + + if (onCreateUserHook) { + fullUser = onCreateUserHook(options, extra, user); + + // This is *not* part of the API. We need this because we can't isolate + // the global server environment between tests, meaning we can't test + // both having a create user hook set and not having one set. + if (fullUser === 'TEST DEFAULT HOOK') + fullUser = defaultCreateUserHook(options, extra, user); + } else { + fullUser = defaultCreateUserHook(options, extra, user); + } + + _.each(validateNewUserHooks, function (hook) { + if (!hook(fullUser)) + throw new Meteor.Error(403, "User validation failed"); + }); + + var result = {}; + if (options.generateLoginToken) { + var stampedToken = Accounts._generateStampedLoginToken(); + result.token = stampedToken.token; + Meteor._ensure(fullUser, 'services', 'resume'); + if (_.has(fullUser.services.resume, 'loginTokens')) + fullUser.services.resume.loginTokens.push(stampedToken); + else + fullUser.services.resume.loginTokens = [stampedToken]; + } + + try { + result.id = Meteor.users.insert(fullUser); + } catch (e) { + // XXX string parsing sucks, maybe + // https://jira.mongodb.org/browse/SERVER-3069 will get fixed one day + if (e.name !== 'MongoError') throw e; + var match = e.err.match(/^E11000 duplicate key error index: ([^ ]+)/); + if (!match) throw e; + if (match[1].indexOf('$emails.address') !== -1) + throw new Meteor.Error(403, "Email already exists."); + if (match[1].indexOf('username') !== -1) + throw new Meteor.Error(403, "Username already exists."); + // XXX better error reporting for services.facebook.id duplicate, etc + throw e; + } + + return result; + }; + + var validateNewUserHooks = []; + Accounts.validateNewUser = function (func) { + validateNewUserHooks.push(func); + }; + + + /// + /// MANAGING USER OBJECTS + /// + + // Updates or creates a user after we authenticate with a 3rd party. + // + // @param serviceName {String} Service name (eg, twitter). + // @param serviceData {Object} Data to store in the user's record + // under services[serviceName]. Must include an "id" field + // which is a unique identifier for the user in the service. + // @param extra {Object, optional} Any additional fields to place on the user + // object + // @returns {Object} Object with token and id keys, like the result + // of the "login" method. + Accounts.updateOrCreateUserFromExternalService = function( + serviceName, serviceData, extra) { + extra = extra || {}; + + if (serviceName === "password" || serviceName === "resume") + throw new Error( + "Can't use updateOrCreateUserFromExternalService with internal service " + + serviceName); + if (!_.has(serviceData, 'id')) + throw new Error( + "Service data for service " + serviceName + " must include id"); + + // Look for a user with the appropriate service user id. + var selector = {}; + selector["services." + serviceName + ".id"] = serviceData.id; + var user = Meteor.users.findOne(selector); + + if (user) { + // don't overwrite existing fields + // XXX subobjects (aka 'profile', 'services')? + var newKeys = _.difference(_.keys(extra), _.keys(user)); + var newAttrs = _.pick(extra, newKeys); + var stampedToken = Accounts._generateStampedLoginToken(); + var result = {token: stampedToken.token}; + Meteor.users.update( + user._id, + {$set: newAttrs, $push: {'services.resume.loginTokens': stampedToken}}); + result.id = user._id; + return result; + } else { + // Create a new user. + var servicesClause = {}; + servicesClause[serviceName] = serviceData; + var insertOptions = {services: servicesClause, generateLoginToken: true}; + // Build a user doc; clone to make sure sure mutating + // insertOptions.services doesn't affect user.services or vice versa. + user = {services: JSON.parse(JSON.stringify(servicesClause))}; + return Accounts.insertUserDoc(insertOptions, extra, user); + } + }; + + + /// + /// PUBLISHING DATA + /// + + // Publish the current user's record to the client. + // XXX This should just be a universal subscription, but we want to know when + // we've gotten the data after a 'login' method, which currently requires + // us to unsub, sub, and wait for onComplete. This is wasteful because + // we're actually guaranteed to have the data by the time that 'login' + // returns. But we don't expose a callback to Meteor.apply which lets us + // know when the data has been processed (ie, quiescence, or at least + // partial quiescence). + Meteor.publish("meteor.currentUser", function() { + if (this.userId) + return Meteor.users.find( + {_id: this.userId}, + {fields: {profile: 1, username: 1, + // We do let the UI know if emails are verified but we don't + // want to publish the verificationTokens field! + 'emails.address': 1, 'emails.verified': 1}}); + else { + this.complete(); + return null; + } + }, {is_auto: true}); + + // If autopublish is on, also publish everyone else's user record. + Meteor.default_server.onAutopublish(function () { + var handler = function () { + return Meteor.users.find( + {}, {fields: {profile: 1, username: 1}}); + }; + Meteor.default_server.publish(null, handler, {is_auto: true}); + }); + + // Publish all login service configuration fields other than secret. + Meteor.publish("meteor.loginServiceConfiguration", function () { + return Accounts.loginServiceConfiguration.find({}, {fields: {secret: 0}}); + }, {is_auto: true}); // not techincally autopublish, but stops the warning. + + // Allow a one-time configuration for a login service. Modifications + // to this collection are also allowed in insecure mode. + Meteor.methods({ + "configureLoginService": function(options) { + // Don't let random users configure a service we haven't added yet (so + // that when we do later add it, it's set up with their configuration + // instead of ours). + if (!Accounts[options.service]) + throw new Meteor.Error(403, "Service unknown"); + if (Accounts.loginServiceConfiguration.findOne({service: options.service})) + throw new Meteor.Error(403, "Service " + options.service + " already configured"); + Accounts.loginServiceConfiguration.insert(options); + } + }); + + + /// + /// RESTRICTING WRITES TO USER OBJECTS + /// + + Meteor.users.allow({ + // clients can modify the profile field of their own document, and + // nothing else. + update: function (userId, docs, fields, modifier) { + // if there is more than one doc, at least one of them isn't our + // user record. + if (docs.length !== 1) + return false; + // make sure it is our record + var user = docs[0]; + if (user._id !== userId) + return false; + + // user can only modify the 'profile' field. sets to multiple + // sub-keys (eg profile.foo and profile.bar) are merged into entry + // in the fields list. + if (fields.length !== 1 || fields[0] !== 'profile') + return false; + + return true; + }, + fields: ['_id'] // we only look at _id. + }); + + /// DEFAULT INDEXES ON USERS + 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}); +}) (); + diff --git a/packages/accounts-base/accounts_tests.js b/packages/accounts-base/accounts_tests.js new file mode 100644 index 0000000000..d350d811d6 --- /dev/null +++ b/packages/accounts-base/accounts_tests.js @@ -0,0 +1,130 @@ +Tinytest.add('accounts - updateOrCreateUserFromExternalService', function (test) { + var facebookId = Meteor.uuid(); + var weiboId1 = Meteor.uuid(); + var weiboId2 = Meteor.uuid(); + + + // create an account with facebook + var uid1 = Accounts.updateOrCreateUserFromExternalService( + 'facebook', {id: facebookId}, {profile: {foo: 1}}).id; + test.equal(Meteor.users.find({"services.facebook.id": facebookId}).count(), 1); + test.equal(Meteor.users.findOne({"services.facebook.id": facebookId}).profile.foo, 1); + + // create again with the same id, see that we get the same user. profile + // doesn't get overwritten in this implementation (though we should do + // something better with merging later). + var uid2 = Accounts.updateOrCreateUserFromExternalService( + 'facebook', {id: facebookId}, {profile: {foo: 1000, bar: 2}}).id; + test.equal(uid1, uid2); + test.equal(Meteor.users.find({"services.facebook.id": facebookId}).count(), 1); + test.equal(Meteor.users.findOne(uid1).profile.foo, 1); + test.equal(Meteor.users.findOne(uid1).profile.bar, undefined); + + // cleanup + Meteor.users.remove(uid1); + + + // users that have different service ids get different users + uid1 = Accounts.updateOrCreateUserFromExternalService( + 'weibo', {id: weiboId1}, {profile: {foo: 1}}).id; + uid2 = Accounts.updateOrCreateUserFromExternalService( + 'weibo', {id: weiboId2}, {profile: {bar: 2}}).id; + test.equal(Meteor.users.find({"services.weibo.id": {$in: [weiboId1, weiboId2]}}).count(), 2); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId1}).profile.foo, 1); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId1}).emails, undefined); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId2}).profile.bar, 2); + test.equal(Meteor.users.findOne({"services.weibo.id": weiboId2}).emails, undefined); + + // cleanup + Meteor.users.remove(uid1); + Meteor.users.remove(uid2); + +}); + +Tinytest.add('accounts - insertUserDoc username', function (test) { + var userIn = { + username: Meteor.uuid() + }; + + // user does not already exist. create a user object with fields set. + var result = Accounts.insertUserDoc( + userIn, + {profile: {name: 'Foo Bar'}}, + userIn + ); + var userOut = Meteor.users.findOne(result.id); + + test.equal(typeof userOut.createdAt, 'number'); + test.equal(userOut.profile.name, 'Foo Bar'); + test.equal(userOut.username, userIn.username); + + // run the hook again. now the user exists, so it throws an error. + test.throws(function () { + Accounts.insertUserDoc( + userIn, + {profile: {name: 'Foo Bar'}}, + userIn + ); + }); + + // cleanup + Meteor.users.remove(result.id); + +}); + +Tinytest.add('accounts - insertUserDoc email', function (test) { + var email1 = Meteor.uuid(); + var email2 = Meteor.uuid(); + var email3 = Meteor.uuid(); + var userIn = { + emails: [{address: email1, verified: false}, + {address: email2, verified: true}] + }; + + // user does not already exist. create a user object with fields set. + var result = Accounts.insertUserDoc( + userIn, + {profile: {name: 'Foo Bar'}}, + userIn + ); + var userOut = Meteor.users.findOne(result.id); + + test.equal(typeof userOut.createdAt, 'number'); + test.equal(userOut.profile.name, 'Foo Bar'); + test.equal(userOut.emails, userIn.emails); + + // run the hook again with the exact same emails. + // run the hook again. now the user exists, so it throws an error. + test.throws(function () { + Accounts.insertUserDoc( + userIn, + {profile: {name: 'Foo Bar'}}, + userIn + ); + }); + + // now with only one of them. + test.throws(function () { + Accounts.insertUserDoc( + {}, {}, {emails: [{address: email1}]} + ); + }); + + test.throws(function () { + Accounts.insertUserDoc( + {}, {}, {emails: [{address: email2}]} + ); + }); + + + // a third email works. + var result3 = Accounts.insertUserDoc( + {}, {}, {emails: [{address: email3}]} + ); + var user3 = Meteor.users.findOne(result3.id); + test.equal(typeof user3.createdAt, 'number'); + + // cleanup + Meteor.users.remove(result.id); + Meteor.users.remove(result3.id); +}); diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js new file mode 100644 index 0000000000..b5f5c11e59 --- /dev/null +++ b/packages/accounts-base/localstorage_token.js @@ -0,0 +1,97 @@ +(function() { + // To be used as the local storage key + var loginTokenKey = "Meteor.loginToken"; + var userIdKey = "Meteor.userId"; + + // Call this from the top level of the test file for any test that does + // logging in and out, to protect multiple tabs running the same tests + // simultaneously from interfering with each others' localStorage. + Accounts._isolateLoginTokenForTest = function () { + loginTokenKey = loginTokenKey + Meteor.uuid(); + userIdKey = userIdKey + Meteor.uuid(); + }; + + Accounts._storeLoginToken = function(userId, token) { + localStorage.setItem(userIdKey, userId); + localStorage.setItem(loginTokenKey, token); + + // to ensure that the localstorage poller doesn't end up trying to + // connect a second time + Accounts._lastLoginTokenWhenPolled = token; + }; + + Accounts._unstoreLoginToken = function() { + localStorage.removeItem(userIdKey); + localStorage.removeItem(loginTokenKey); + + // to ensure that the localstorage poller doesn't end up trying to + // connect a second time + Accounts._lastLoginTokenWhenPolled = null; + }; + + Accounts._storedLoginToken = function() { + return localStorage.getItem(loginTokenKey); + }; + + Accounts._storedUserId = function() { + return localStorage.getItem(userIdKey); + }; +})(); + +// Login with a Meteor access token +// +// XXX having errorCallback only here is weird since other login +// methods will have different callbacks. Standardize this. +Meteor.loginWithToken = function (token, errorCallback) { + Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) { + if (error) { + errorCallback(); + throw error; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + }); +}; + +if (!Accounts._preventAutoLogin) { + // Immediately try to log in via local storage, so that any DDP + // messages are sent after we have established our user account + var token = Accounts._storedLoginToken(); + if (token) { + // On startup, optimistically present us as logged in while the + // request is in flight. This reduces page flicker on startup. + var userId = Accounts._storedUserId(); + userId && Meteor.default_connection.setUserId(userId); + Meteor.loginWithToken(token, function () { + Accounts._makeClientLoggedOut(); + }); + } +} + +// Poll local storage every 3 seconds to login if someone logged in in +// another tab +Accounts._lastLoginTokenWhenPolled = token; +Accounts._pollStoredLoginToken = function() { + if (Accounts._preventAutoLogin) + return; + + var currentLoginToken = Accounts._storedLoginToken(); + + // != instead of !== just to make sure undefined and null are treated the same + if (Accounts._lastLoginTokenWhenPolled != currentLoginToken) { + if (currentLoginToken) + Meteor.loginWithToken(currentLoginToken); // XXX should we pass a callback here? + else + Meteor.logout(); + } + Accounts._lastLoginTokenWhenPolled = currentLoginToken; +}; + +// Semi-internal API. Call this function to re-enable auto login after +// if it was disabled at startup. +Accounts._enableAutoLogin = function () { + Accounts._preventAutoLogin = false; + Accounts._pollStoredLoginToken(); +}; + +setInterval(Accounts._pollStoredLoginToken, 3000); diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js new file mode 100644 index 0000000000..e3e35c31b9 --- /dev/null +++ b/packages/accounts-base/package.js @@ -0,0 +1,25 @@ +Package.describe({ + summary: "A user account system" +}); + +Package.on_use(function (api) { + api.use('underscore', 'server'); + api.use('localstorage-polyfill', 'client'); + api.use('accounts-urls', 'client'); + + // need this because of the Meteor.users collection but in the future + // we'd probably want to abstract this away + api.use('mongo-livedata', ['client', 'server']); + + api.add_files('accounts_common.js', ['client', 'server']); + api.add_files('accounts_server.js', 'server'); + + api.add_files('localstorage_token.js', 'client'); + api.add_files('accounts_client.js', 'client'); +}); + +Package.on_test(function (api) { + api.use('accounts-base'); + api.use('tinytest'); + api.add_files('accounts_tests.js', 'server'); +}); diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js new file mode 100644 index 0000000000..42fa7c630a --- /dev/null +++ b/packages/accounts-facebook/facebook_client.js @@ -0,0 +1,35 @@ +(function () { + Meteor.loginWithFacebook = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + var mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); + var display = mobile ? 'touch' : 'popup'; + + var scope = "email"; + if (options && options.requestPermissions) + scope = options.requestPermissions.join(','); + + var loginUrl = + 'https://www.facebook.com/dialog/oauth?client_id=' + config.appId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/facebook?close') + + '&display=' + display + '&scope=' + scope + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback); + }; + +})(); + + + + diff --git a/packages/accounts-facebook/facebook_common.js b/packages/accounts-facebook/facebook_common.js new file mode 100644 index 0000000000..171ca036f6 --- /dev/null +++ b/packages/accounts-facebook/facebook_common.js @@ -0,0 +1,3 @@ +if (!Accounts.facebook) { + Accounts.facebook = {}; +} diff --git a/packages/accounts-facebook/facebook_configure.html b/packages/accounts-facebook/facebook_configure.html new file mode 100644 index 0000000000..aa0344a8c9 --- /dev/null +++ b/packages/accounts-facebook/facebook_configure.html @@ -0,0 +1,19 @@ + diff --git a/packages/accounts-facebook/facebook_configure.js b/packages/accounts-facebook/facebook_configure.js new file mode 100644 index 0000000000..d0e8798f81 --- /dev/null +++ b/packages/accounts-facebook/facebook_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServiceDialogForFacebook.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServiceDialogForFacebook.fields = function () { + return [ + {property: 'appId', label: 'App ID'}, + {property: 'secret', label: 'App Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-facebook/facebook_server.js b/packages/accounts-facebook/facebook_server.js new file mode 100644 index 0000000000..2184bb1f36 --- /dev/null +++ b/packages/accounts-facebook/facebook_server.js @@ -0,0 +1,76 @@ +(function () { + + Accounts.oauth.registerService('facebook', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email + }, + extra: {profile: {name: identity.name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + // Request an access token + var result = Meteor.http.get( + "https://graph.facebook.com/oauth/access_token", { + params: { + client_id: config.appId, + redirect_uri: Meteor.absoluteUrl("_oauth/facebook?close"), + client_secret: config.secret, + code: query.code + } + }); + + if (result.error) + throw result.error; + var response = result.content; + + // Errors come back as JSON but success looks like a query encoded + // in a url + var error_response; + try { + // Just try to parse so that we know if we failed or not, + // while storing the parsed results + error_response = JSON.parse(response); + } catch (e) { + error_response = null; + } + + if (error_response) { + throw new Meteor.Error(500, "Error trying to get access token from Facebook", error_response); + } else { + // Success! Extract the facebook access token from the + // response + var fbAccessToken; + _.each(response.split('&'), function(kvString) { + var kvArray = kvString.split('='); + if (kvArray[0] === 'access_token') + fbAccessToken = kvArray[1]; + // XXX also parse the "expires" argument? + }); + + if (!fbAccessToken) + throw new Meteor.Error(500, "Couldn't find access token in HTTP response."); + return fbAccessToken; + } + }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get("https://graph.facebook.com/me", { + params: {access_token: accessToken}}); + + if (result.error) + throw result.error; + return result.data; + }; +}) (); diff --git a/packages/accounts-facebook/package.js b/packages/accounts-facebook/package.js new file mode 100644 index 0000000000..9b63589120 --- /dev/null +++ b/packages/accounts-facebook/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Facebook accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['facebook_configure.html', 'facebook_configure.js'], + 'client'); + + api.add_files('facebook_common.js', ['client', 'server']); + api.add_files('facebook_server.js', 'server'); + api.add_files('facebook_client.js', 'client'); +}); diff --git a/packages/accounts-github/github_client.js b/packages/accounts-github/github_client.js new file mode 100644 index 0000000000..047123e3a3 --- /dev/null +++ b/packages/accounts-github/github_client.js @@ -0,0 +1,29 @@ +(function () { + Meteor.loginWithGithub = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'github'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + var state = Meteor.uuid(); + + var required_scope = ['user']; + var scope = _.union((options && options.requestPermissions) || [], required_scope); + var flat_scope = _.map(scope, encodeURIComponent).join('+'); + + var loginUrl = + 'https://github.com/login/oauth/authorize' + + '?client_id=' + config.clientId + + '&scope=' + flat_scope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/github?close') + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback, {width: 900, height: 450}); + }; +}) (); diff --git a/packages/accounts-github/github_common.js b/packages/accounts-github/github_common.js new file mode 100644 index 0000000000..0e9b508596 --- /dev/null +++ b/packages/accounts-github/github_common.js @@ -0,0 +1,3 @@ +if (!Accounts.github) { + Accounts.github = {}; +} diff --git a/packages/accounts-github/github_configure.html b/packages/accounts-github/github_configure.html new file mode 100644 index 0000000000..53c26394b3 --- /dev/null +++ b/packages/accounts-github/github_configure.html @@ -0,0 +1,16 @@ + diff --git a/packages/accounts-github/github_configure.js b/packages/accounts-github/github_configure.js new file mode 100644 index 0000000000..cffe1233c4 --- /dev/null +++ b/packages/accounts-github/github_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServiceDialogForGithub.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServiceDialogForGithub.fields = function () { + return [ + {property: 'clientId', label: 'Client ID'}, + {property: 'secret', label: 'Client Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-github/github_server.js b/packages/accounts-github/github_server.js new file mode 100644 index 0000000000..ae695e3230 --- /dev/null +++ b/packages/accounts-github/github_server.js @@ -0,0 +1,46 @@ +(function () { + Accounts.oauth.registerService('github', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email, + username: identity.login + }, + extra: {profile: {name: identity.name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'github'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + var result = Meteor.http.post( + "https://github.com/login/oauth/access_token", {headers: {Accept: 'application/json'}, params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/github?close"), + state: query.state + }}); + if (result.error) // if the http response was an error + throw result.error; + if (result.data.error) // if the http response was a json object with an error attribute + throw result.data; + return result.data.access_token; + }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get( + "https://api.github.com/user", + {params: {access_token: accessToken}}); + if (result.error) + throw result.error; + return result.data; + }; +}) (); \ No newline at end of file diff --git a/packages/accounts-github/package.js b/packages/accounts-github/package.js new file mode 100644 index 0000000000..99187fb043 --- /dev/null +++ b/packages/accounts-github/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Github accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['github_configure.html', 'github_configure.js'], + 'client'); + + api.add_files('github_common.js', ['client', 'server']); + api.add_files('github_server.js', 'server'); + api.add_files('github_client.js', 'client'); +}); diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js new file mode 100644 index 0000000000..75d1a3da2f --- /dev/null +++ b/packages/accounts-google/google_client.js @@ -0,0 +1,39 @@ +(function () { + Meteor.loginWithGoogle = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'google'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + + // always need this to get user id from google. + var required_scope = ['https://www.googleapis.com/auth/userinfo.profile']; + var scope = ['https://www.googleapis.com/auth/userinfo.email']; + if (options && options.requestPermissions) + scope = options.requestPermissions; + scope = _.union(scope, required_scope); + var flat_scope = _.map(scope, encodeURIComponent).join('+'); + + // Might be good to have a way to set access_type=offline. Need to + // both set it here and store the refresh token on the server. + + var loginUrl = + 'https://accounts.google.com/o/oauth2/auth' + + '?response_type=code' + + '&client_id=' + config.clientId + + '&scope=' + flat_scope + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/google?close') + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback); + }; + +}) (); diff --git a/packages/accounts-google/google_common.js b/packages/accounts-google/google_common.js new file mode 100644 index 0000000000..f3945c2c23 --- /dev/null +++ b/packages/accounts-google/google_common.js @@ -0,0 +1,3 @@ +if (!Accounts.google) { + Accounts.google = {}; +} diff --git a/packages/accounts-google/google_configure.html b/packages/accounts-google/google_configure.html new file mode 100644 index 0000000000..84e8926584 --- /dev/null +++ b/packages/accounts-google/google_configure.html @@ -0,0 +1,28 @@ + diff --git a/packages/accounts-google/google_configure.js b/packages/accounts-google/google_configure.js new file mode 100644 index 0000000000..c5740c6c9f --- /dev/null +++ b/packages/accounts-google/google_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServiceDialogForGoogle.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServiceDialogForGoogle.fields = function () { + return [ + {property: 'clientId', label: 'Client ID'}, + {property: 'secret', label: 'Client secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-google/google_server.js b/packages/accounts-google/google_server.js new file mode 100644 index 0000000000..d0e8726c71 --- /dev/null +++ b/packages/accounts-google/google_server.js @@ -0,0 +1,48 @@ +(function () { + + Accounts.oauth.registerService('google', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken); + + return { + serviceData: { + id: identity.id, + accessToken: accessToken, + email: identity.email + }, + extra: {profile: {name: identity.name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'google'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + var result = Meteor.http.post( + "https://accounts.google.com/o/oauth2/token", {params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/google?close"), + grant_type: 'authorization_code' + }}); + + if (result.error) // if the http response was an error + throw result.error; + if (result.data.error) // if the http response was a json object with an error attribute + throw result.data; + return result.data.access_token; + }; + + var getIdentity = function (accessToken) { + var result = Meteor.http.get( + "https://www.googleapis.com/oauth2/v1/userinfo", + {params: {access_token: accessToken}}); + + if (result.error) + throw result.error; + return result.data; + }; +})(); diff --git a/packages/accounts-google/package.js b/packages/accounts-google/package.js new file mode 100644 index 0000000000..e6484baadb --- /dev/null +++ b/packages/accounts-google/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Google accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['google_configure.html', 'google_configure.js'], + 'client'); + + api.add_files('google_common.js', ['client', 'server']); + api.add_files('google_server.js', 'server'); + api.add_files('google_client.js', 'client'); +}); diff --git a/packages/accounts-oauth-helper/oauth_client.js b/packages/accounts-oauth-helper/oauth_client.js new file mode 100644 index 0000000000..2e29c898b8 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_client.js @@ -0,0 +1,83 @@ +(function () { + // Open a popup window pointing to a OAuth handshake page + // + // @param state {String} The OAuth state generated by the client + // @param url {String} url to page + // @param callback {Function} Callback function to call on + // completion. Takes one argument, null on success, or Error on + // error. + // @param dimensions {optional Object(width, height)} The dimensions of + // the popup. If not passed defaults to something sane + Accounts.oauth.initiateLogin = function(state, url, callback, dimensions) { + // XXX these dimensions worked well for facebook and google, but + // it's sort of weird to have these here. Maybe an optional + // argument instead? + var popup = openCenteredPopup( + url, + (dimensions && dimensions.width) || 650, + (dimensions && dimensions.height) || 331); + + var checkPopupOpen = setInterval(function() { + // Fix for #328 - added a second test criteria (popup.closed === undefined) + // to humour this Android quirk: + // http://code.google.com/p/android/issues/detail?id=21061 + if (popup.closed || popup.closed === undefined) { + clearInterval(checkPopupOpen); + tryLoginAfterPopupClosed(state, callback); + } + }, 100); + }; + + // Send an OAuth login method to the server. If the user authorized + // access in the popup this should log the user in, otherwise + // nothing should happen. + var tryLoginAfterPopupClosed = function(state, callback) { + Meteor.apply('login', [ + {oauth: {state: state}} + ], {wait: true}, function(error, result) { + if (error) { + // got an error from the server. report it back. + callback && callback(error); + } else if (!result) { + // got an empty response from the server. This means our oauth + // state wasn't recognized, which could be either because the + // popup was closed by the user before completion, or some sort + // of error where the oauth provider didn't talk to our server + // correctly and closed the popup somehow. + // + // we assume it was user canceled, and report it as such. this + // will mask failures where things are misconfigured such that + // the server doesn't see the request but does close the + // window. This seems unlikely. + callback && + callback(new Accounts.LoginCancelledError("Popup closed")); + } else { + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + } + }); + }; + + var openCenteredPopup = function(url, width, height) { + var screenX = typeof window.screenX !== 'undefined' + ? window.screenX : window.screenLeft; + var screenY = typeof window.screenY !== 'undefined' + ? window.screenY : window.screenTop; + var outerWidth = typeof window.outerWidth !== 'undefined' + ? window.outerWidth : document.body.clientWidth; + var outerHeight = typeof window.outerHeight !== 'undefined' + ? window.outerHeight : (document.body.clientHeight - 22); + + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + var left = screenX + (outerWidth - width) / 2; + var top = screenY + (outerHeight - height) / 2; + var features = ('width=' + width + ',height=' + height + + ',left=' + left + ',top=' + top); + + var newwindow = window.open(url, 'Login', features); + if (newwindow.focus) + newwindow.focus(); + return newwindow; + }; +})(); diff --git a/packages/accounts-oauth-helper/oauth_common.js b/packages/accounts-oauth-helper/oauth_common.js new file mode 100644 index 0000000000..d47da20292 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_common.js @@ -0,0 +1 @@ +Accounts.oauth = {}; \ No newline at end of file diff --git a/packages/accounts-oauth-helper/oauth_server.js b/packages/accounts-oauth-helper/oauth_server.js new file mode 100644 index 0000000000..5e49755a54 --- /dev/null +++ b/packages/accounts-oauth-helper/oauth_server.js @@ -0,0 +1,180 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + Accounts.oauth._services = {}; + + // Register a handler for an OAuth service. The handler will be called + // when we get an incoming http request on /_oauth/{serviceName}. This + // handler should use that information to fetch data about the user + // logging in. + // + // @param name {String} e.g. "google", "facebook" + // @param version {Number} OAuth version (1 or 2) + // @param handleOauthRequest {Function(oauthBinding|query)} + // - (For OAuth1 only) oauthBinding {OAuth1Binding} bound to the appropriate provider + // - (For OAuth2 only) query {Object} parameters passed in query string + // - return value is: + // - {serviceData, (optional extra)} where serviceData should end + // up in the user's services[name] field + // - `null` if the user declined to give permissions + Accounts.oauth.registerService = function (name, version, handleOauthRequest) { + if (Accounts.oauth._services[name]) + throw new Error("Already registered the " + name + " OAuth service"); + + // Accounts.updateOrCreateUserFromExternalService does a lookup by this id, + // so this should be a unique index. You might want to add indexes for other + // fields returned by your service (eg services.github.login) but you can do + // that in your app. + Meteor.users._ensureIndex('services.' + name + '.id', + {unique: 1, sparse: 1}); + + Accounts.oauth._services[name] = { + serviceName: name, + version: version, + handleOauthRequest: handleOauthRequest + }; + }; + + // When we get an incoming OAuth http request we complete the oauth + // handshake, account and token setup before responding. The + // results are stored in this map which is then read when the login + // method is called. Maps state --> return value of `login` + // + // XXX we should periodically clear old entries + Accounts.oauth._loginResultForState = {}; + + // Listen to calls to `login` with an oauth option set + Accounts.registerLoginHandler(function (options) { + if (!options.oauth) + return undefined; // don't handle + + var result = Accounts.oauth._loginResultForState[options.oauth.state]; + if (result === undefined) // not using `!result` since can be null + // We weren't notified of the user authorizing the login. + return null; + else if (result instanceof Error) + // We tried to login, but there was a fatal error. Report it back + // to the user. + throw result; + else + return result; + }); + + // Listen to incoming OAuth http requests + __meteor_bootstrap__.app + .use(connect.query()) + .use(function(req, res, next) { + // Need to create a Fiber since we're using synchronous http + // calls and nothing else is wrapping this in a fiber + // automatically + Fiber(function () { + Accounts.oauth._middleware(req, res, next); + }).run(); + }); + + Accounts.oauth._middleware = function (req, res, next) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + var serviceName = oauthServiceName(req); + if (!serviceName) { + // not an oauth request. pass to next middleware. + next(); + return; + } + + var service = Accounts.oauth._services[serviceName]; + + // Skip everything if there's no service set by the oauth middleware + if (!service) + throw new Error("Unexpected OAuth service " + serviceName); + + // Make sure we're configured + ensureConfigured(serviceName); + + if (service.version === 1) + Accounts.oauth1._handleRequest(service, req.query, res); + else if (service.version === 2) + Accounts.oauth2._handleRequest(service, req.query, res); + else + throw new Error("Unexpected OAuth version " + service.version); + } catch (err) { + // if we got thrown an error, save it off, it will get passed to + // the approporiate login call (if any) and reported there. + // + // The other option would be to display it in the popup tab that + // is still open at this point, ignoring the 'close' or 'redirect' + // we were passed. But then the developer wouldn't be able to + // style the error or react to it in any way. + if (req.query.state && err instanceof Error) + Accounts.oauth._loginResultForState[req.query.state] = err; + + // also log to the server console, so the developer sees it. + Meteor._debug("Exception in oauth request handler", err); + + // XXX the following is actually wrong. if someone wants to + // redirect rather than close once we are done with the OAuth + // flow, as supported by + // Accounts.oauth_renderOauthResults, this will still + // close the popup instead. Once we fully support the redirect + // flow (by supporting that in places such as + // packages/facebook/facebook_client.js) we should revisit this. + // + // close the popup. because nobody likes them just hanging + // there. when someone sees this multiple times they might + // think to check server logs (we hope?) + closePopup(res); + } + }; + + // Handle /_oauth/* paths and extract the service name + // + // @returns {String|null} e.g. "facebook", or null if this isn't an + // oauth request + var oauthServiceName = function (req) { + + // req.url will be "/_oauth/?" + var barePath = req.url.substring(0, req.url.indexOf('?')); + var splitPath = barePath.split('/'); + + // Any non-oauth request will continue down the default + // middlewares. + if (splitPath[1] !== '_oauth') + return null; + + // Find service based on url + var serviceName = splitPath[2]; + return serviceName; + }; + + // Make sure we're configured + var ensureConfigured = function(serviceName) { + if (!Accounts.loginServiceConfiguration.findOne({service: serviceName})) { + throw new Accounts.ConfigError("Service not configured"); + }; + }; + + Accounts.oauth._renderOauthResults = function(res, query) { + // We support ?close and ?redirect=URL. Any other query should + // just serve a blank page + if ('close' in query) { // check with 'in' because we don't set a value + closePopup(res); + } else if (query.redirect) { + res.writeHead(302, {'Location': query.redirect}); + res.end(); + } else { + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('', 'utf-8'); + } + }; + + var closePopup = function(res) { + res.writeHead(200, {'Content-Type': 'text/html'}); + var content = + ''; + res.end(content, 'utf-8'); + }; + +})(); + + diff --git a/packages/accounts-oauth-helper/package.js b/packages/accounts-oauth-helper/package.js new file mode 100644 index 0000000000..9fe117d37f --- /dev/null +++ b/packages/accounts-oauth-helper/package.js @@ -0,0 +1,12 @@ +Package.describe({ + summary: "Common code for OAuth-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-base', ['client', 'server']); + + api.add_files('oauth_common.js', ['client', 'server']); + api.add_files('oauth_client.js', 'client'); + api.add_files('oauth_server.js', 'server'); +}); diff --git a/packages/accounts-oauth1-helper/oauth1_binding.js b/packages/accounts-oauth1-helper/oauth1_binding.js new file mode 100644 index 0000000000..835ca74cc1 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_binding.js @@ -0,0 +1,137 @@ +var crypto = __meteor_bootstrap__.require("crypto"); +var querystring = __meteor_bootstrap__.require("querystring"); + +// An OAuth1 wrapper around http calls which helps get tokens and +// takes care of HTTP headers +// +// @param consumerKey {String} As supplied by the OAuth1 provider +// @param consumerSecret {String} As supplied by the OAuth1 provider +// @param urls {Object} +// - requestToken (String): url +// - authorize (String): url +// - accessToken (String): url +// - authenticate (String): url +OAuth1Binding = function(consumerKey, consumerSecret, urls) { + this._consumerKey = consumerKey; + this._secret = consumerSecret; + this._urls = urls; +}; + +OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { + var self = this; + + var headers = self._buildHeader({ + oauth_callback: callbackUrl + }); + + var response = self._call('POST', self._urls.requestToken, headers); + var tokens = querystring.parse(response.content); + + // XXX should we also store oauth_token_secret here? + if (!tokens.oauth_callback_confirmed) + throw new Error("oauth_callback_confirmed false when requesting oauth1 token", tokens); + self.requestToken = tokens.oauth_token; +}; + +OAuth1Binding.prototype.prepareAccessToken = function(query) { + var self = this; + + var headers = self._buildHeader({ + oauth_token: query.oauth_token + }); + + var params = { + oauth_verifier: query.oauth_verifier + }; + + var response = self._call('POST', self._urls.accessToken, headers, params); + var tokens = querystring.parse(response.content); + + self.accessToken = tokens.oauth_token; + self.accessTokenSecret = tokens.oauth_token_secret; +}; + +OAuth1Binding.prototype.call = function(method, url) { + var self = this; + + var headers = self._buildHeader({ + oauth_token: self.accessToken + }); + + var response = self._call(method, url, headers); + return response.data; +}; + +OAuth1Binding.prototype.get = function(url) { + return this.call('GET', url); +}; + +OAuth1Binding.prototype._buildHeader = function(headers) { + var self = this; + return _.extend({ + oauth_consumer_key: self._consumerKey, + oauth_nonce: Meteor.uuid().replace(/\W/g, ''), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: (new Date().valueOf()/1000).toFixed().toString(), + oauth_version: '1.0' + }, headers); +}; + +OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, accessTokenSecret) { + var self = this; + var headers = self._encodeHeader(rawHeaders); + + var parameters = _.map(headers, function(val, key) { + return key + '=' + val; + }).sort().join('&'); + + var signatureBase = [ + method, + encodeURIComponent(url), + encodeURIComponent(parameters) + ].join('&'); + + var signingKey = encodeURIComponent(self._secret) + '&'; + if (accessTokenSecret) + signingKey += encodeURIComponent(accessTokenSecret); + + return crypto.createHmac('SHA1', signingKey).update(signatureBase).digest('base64'); +}; + +OAuth1Binding.prototype._call = function(method, url, headers, params) { + var self = this; + + // Get the signature + headers.oauth_signature = self._getSignature(method, url, headers, self.accessTokenSecret); + + // Make a authorization string according to oauth1 spec + var authString = self._getAuthHeaderString(headers); + + // Make signed request + var response = Meteor.http.call(method, url, { + params: params, + headers: { + Authorization: authString + } + }); + + if (response.error) { + Meteor._debug('Error sending OAuth1 HTTP call', response.content, method, url, params, authString); + throw response.error; + } + + return response; +}; + +OAuth1Binding.prototype._encodeHeader = function(header) { + return _.reduce(header, function(memo, val, key) { + memo[encodeURIComponent(key)] = encodeURIComponent(val); + return memo; + }, {}); +}; + +OAuth1Binding.prototype._getAuthHeaderString = function(headers) { + return 'OAuth ' + _.map(headers, function(val, key) { + return encodeURIComponent(key) + '="' + encodeURIComponent(val) + '"'; + }).sort().join(', '); +}; diff --git a/packages/accounts-oauth1-helper/oauth1_common.js b/packages/accounts-oauth1-helper/oauth1_common.js new file mode 100644 index 0000000000..d4ce446298 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_common.js @@ -0,0 +1 @@ +Accounts.oauth1 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth1-helper/oauth1_server.js b/packages/accounts-oauth1-helper/oauth1_server.js new file mode 100644 index 0000000000..9733417d73 --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_server.js @@ -0,0 +1,67 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + // A place to store request tokens pending verification + Accounts.oauth1._requestTokens = {}; + + // connect middleware + Accounts.oauth1._handleRequest = function (service, query, res) { + + var config = Accounts.loginServiceConfiguration.findOne({service: service.serviceName}); + if (!config) { + throw new Accounts.ConfigError("Service " + service.serviceName + " not configured"); + } + + var urls = Accounts[service.serviceName]._urls; + var oauthBinding = new OAuth1Binding( + config.consumerKey, config.secret, urls); + + if (query.requestTokenAndRedirect) { + // step 1 - get and store a request token + + // Get a request token to start auth process + oauthBinding.prepareRequestToken(query.requestTokenAndRedirect); + + // Keep track of request token so we can verify it on the next step + Accounts.oauth1._requestTokens[query.state] = oauthBinding.requestToken; + + // redirect to provider login, which will redirect back to "step 2" below + var redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; + res.writeHead(302, {'Location': redirectUrl}); + res.end(); + + } else { + // step 2, redirected from provider login - complete the login + // process: if the user authorized permissions, get an access + // token and access token secret and log in as user + + // Get the user's request token so we can verify it and clear it + var requestToken = Accounts.oauth1._requestTokens[query.state]; + delete Accounts.oauth1._requestTokens[query.state]; + + // Verify user authorized access and the oauth_token matches + // the requestToken from previous step + if (query.oauth_token && query.oauth_token === requestToken) { + + // Prepare the login results before returning. This way the + // subsequent call to the `login` method will be immediate. + + // Get the access token for signing requests + oauthBinding.prepareAccessToken(query); + + // Run service-specific handler. + var oauthResult = service.handleOauthRequest(oauthBinding); + + // Get or create user doc and login token for reconnect. + Accounts.oauth._loginResultForState[query.state] = + Accounts.updateOrCreateUserFromExternalService( + service.serviceName, oauthResult.serviceData, oauthResult.extra); + } + } + + // Either close the window, redirect, or render nothing + // if all else fails + Accounts.oauth._renderOauthResults(res, query); + }; + +})(); diff --git a/packages/accounts-oauth1-helper/oauth1_tests.js b/packages/accounts-oauth1-helper/oauth1_tests.js new file mode 100644 index 0000000000..b6610e892a --- /dev/null +++ b/packages/accounts-oauth1-helper/oauth1_tests.js @@ -0,0 +1,134 @@ + +Tinytest.add("oauth1 - loginResultForState is stored", function (test) { + var http = __meteor_bootstrap__.require('http'); + var twitterfooId = Meteor.uuid(); + var twitterfooName = 'nickname' + Meteor.uuid(); + var twitterfooAccessToken = Meteor.uuid(); + var twitterfooAccessTokenSecret = Meteor.uuid(); + + OAuth1Binding.prototype.prepareRequestToken = function() {}; + OAuth1Binding.prototype.prepareAccessToken = function() { + this.accessToken = twitterfooAccessToken; + this.accessTokenSecret = twitterfooAccessTokenSecret; + }; + + // XXX XXX test isolation fail! Avital: but actually -- why would + // we run server tests more than once? or even more so in parallel? + Accounts.oauth._loginResultForState = {}; + Accounts.oauth._services = {}; + + if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfoo'})) + Accounts.loginServiceConfiguration.insert({service: 'twitterfoo'}); + Accounts.twitterfoo = {}; + + // register a fake login service - twitterfoo + Accounts.oauth.registerService("twitterfoo", 1, function (query) { + return { + serviceData: { + id: twitterfooId, + screenName: twitterfooName, + accessToken: twitterfooAccessToken, + accessTokenSecret: twitterfooAccessTokenSecret + } + }; + }); + + // simulate logging in using twitterfoo + Accounts.oauth1._requestTokens['STATE'] = twitterfooAccessToken; + + var req = { + method: "POST", + url: "/_oauth/twitterfoo?close", + query: { + state: "STATE", + oauth_token: twitterfooAccessToken + } + }; + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is created + var user = Meteor.users.findOne( + {"services.twitterfoo.screenName": twitterfooName}); + test.notEqual(user, undefined); + test.equal(user.services.twitterfoo.accessToken, + twitterfooAccessToken); + test.equal(user.services.twitterfoo.accessTokenSecret, + twitterfooAccessTokenSecret); + + // and that that user has a login token + test.equal(user.services.resume.loginTokens.length, 1); + var token = user.services.resume.loginTokens[0].token; + test.notEqual(token, undefined); + + // and that the login result for that user is prepared + test.equal( + Accounts.oauth._loginResultForState['STATE'].id, user._id); + test.equal( + Accounts.oauth._loginResultForState['STATE'].token, token); +}); + + +Tinytest.add("oauth1 - error in user creation", function (test) { + var http = __meteor_bootstrap__.require('http'); + var state = Meteor.uuid(); + var twitterfailId = Meteor.uuid(); + var twitterfailName = 'nickname' + Meteor.uuid(); + var twitterfailAccessToken = Meteor.uuid(); + var twitterfailAccessTokenSecret = Meteor.uuid(); + + if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfail'})) + Accounts.loginServiceConfiguration.insert({service: 'twitterfail'}); + Accounts.twitterfail = {}; + + // Wire up access token so that verification passes + Accounts.oauth1._requestTokens[state] = twitterfailAccessToken; + + // register a failing login service + Accounts.oauth.registerService("twitterfail", 1, function (query) { + return { + serviceData: { + id: twitterfailId, + screenName: twitterfailName, + accessToken: twitterfailAccessToken, + accessTokenSecret: twitterfailAccessTokenSecret + }, + extra: { + profile: {invalid: true} + } + }; + }); + + // a way to fail new users. duplicated from passwords_tests, but + // shouldn't hurt. + Accounts.validateNewUser(function (user) { + return !(user.profile && user.profile.invalid); + }); + + // simulate logging in with failure + Meteor._suppress_log(1); + var req = { + method: "POST", + url: "/_oauth/twitterfail?close", + query: { + state: state, + oauth_token: twitterfailAccessToken + } + }; + + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is not created + var user = Meteor.users.findOne({"services.twitter.screenName": twitterfailName}); + test.equal(user, undefined); + + // verify an error is stored in login state + test.equal(Accounts.oauth._loginResultForState[state].error, 403); + + // verify error is handed back to login method. + test.throws(function () { + Meteor.apply('login', [{oauth: {version: 1, state: state}}]); + }); + +}); + + diff --git a/packages/accounts-oauth1-helper/package.js b/packages/accounts-oauth1-helper/package.js new file mode 100644 index 0000000000..0e15ba5f4f --- /dev/null +++ b/packages/accounts-oauth1-helper/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Common code for OAuth1-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-oauth-helper', 'client'); + api.use('accounts-base', ['client', 'server']); + + api.add_files('oauth1_binding.js', 'server'); + api.add_files('oauth1_common.js', ['client', 'server']); + api.add_files('oauth1_server.js', 'server'); +}); + +Package.on_test(function (api) { + api.use('accounts-oauth1-helper', 'server'); + api.add_files("oauth1_tests.js", 'server'); +}); diff --git a/packages/accounts-oauth2-helper/oauth2_common.js b/packages/accounts-oauth2-helper/oauth2_common.js new file mode 100644 index 0000000000..0012a34cee --- /dev/null +++ b/packages/accounts-oauth2-helper/oauth2_common.js @@ -0,0 +1 @@ +Accounts.oauth2 = {}; \ No newline at end of file diff --git a/packages/accounts-oauth2-helper/oauth2_server.js b/packages/accounts-oauth2-helper/oauth2_server.js new file mode 100644 index 0000000000..f0104418ed --- /dev/null +++ b/packages/accounts-oauth2-helper/oauth2_server.js @@ -0,0 +1,25 @@ +(function () { + var connect = __meteor_bootstrap__.require("connect"); + + // connect middleware + Accounts.oauth2._handleRequest = function (service, query, res) { + // check if user authorized access + if (!query.error) { + // Prepare the login results before returning. This way the + // subsequent call to the `login` method will be immediate. + + // Run service-specific handler. + var oauthResult = service.handleOauthRequest(query); + + // Get or create user doc and login token for reconnect. + Accounts.oauth._loginResultForState[query.state] = + Accounts.updateOrCreateUserFromExternalService( + service.serviceName, oauthResult.serviceData, oauthResult.extra); + } + + // Either close the window, redirect, or render nothing + // if all else fails + Accounts.oauth._renderOauthResults(res, query); + }; + +})(); diff --git a/packages/accounts-oauth2-helper/oauth2_tests.js b/packages/accounts-oauth2-helper/oauth2_tests.js new file mode 100644 index 0000000000..b10b183c9f --- /dev/null +++ b/packages/accounts-oauth2-helper/oauth2_tests.js @@ -0,0 +1,91 @@ +Tinytest.add("oauth2 - loginResultForState is stored", function (test) { + var http = __meteor_bootstrap__.require('http'); + var foobookId = Meteor.uuid(); + + // XXX XXX test isolation fail! Avital: but actually -- why would + // we run server tests more than once? or even more so in parallel? + Accounts.oauth._loginResultForState = {}; + Accounts.oauth._services = {}; + + if (!Accounts.loginServiceConfiguration.findOne({service: 'foobook'})) + Accounts.loginServiceConfiguration.insert({service: 'foobook'}); + Accounts.foobook = {}; + + // register a fake login service - foobook + Accounts.oauth.registerService("foobook", 2, function (query) { + return {serviceData: {id: foobookId}}; + }); + + // simulate logging in using foobook + var req = {method: "POST", + url: "/_oauth/foobook?close", + query: {state: "STATE"}}; + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is created + var user = Meteor.users.findOne({"services.foobook.id": foobookId}); + test.notEqual(user, undefined); + test.equal(user.services.foobook.id, foobookId); + + // and that that user has a login token + test.equal(user.services.resume.loginTokens.length, 1); + var token = user.services.resume.loginTokens[0].token; + test.notEqual(token, undefined); + + // and that the login result for that user is prepared + test.equal( + Accounts.oauth._loginResultForState['STATE'].id, user._id); + test.equal( + Accounts.oauth._loginResultForState['STATE'].token, token); +}); + + +Tinytest.add("oauth2 - error in user creation", function (test) { + var http = __meteor_bootstrap__.require('http'); + var state = Meteor.uuid(); + var failbookId = Meteor.uuid(); + + if (!Accounts.loginServiceConfiguration.findOne({service: 'failbook'})) + Accounts.loginServiceConfiguration.insert({service: 'failbook'}); + Accounts.failbook = {}; + + // register a failing login service + Accounts.oauth.registerService("failbook", 2, function (query) { + return { + serviceData: { + id: failbookId + }, + extra: { + profile: {invalid: true} + } + }; + }); + + // a way to fail new users. duplicated from passwords_tests, but + // shouldn't hurt. + Accounts.validateNewUser(function (user) { + return !(user.profile && user.profile.invalid); + }); + + // simulate logging in with failure + Meteor._suppress_log(1); + var req = {method: "POST", + url: "/_oauth/failbook?close", + query: {state: state}}; + Accounts.oauth._middleware(req, new http.ServerResponse(req)); + + // verify that a user is not created + var user = Meteor.users.findOne({"services.failbook.id": failbookId}); + test.equal(user, undefined); + + // verify an error is stored in login state + test.equal(Accounts.oauth._loginResultForState[state].error, 403); + + // verify error is handed back to login method. + test.throws(function () { + Meteor.apply('login', [{oauth: {version: 2, state: state}}]); + }); + +}); + + diff --git a/packages/accounts-oauth2-helper/package.js b/packages/accounts-oauth2-helper/package.js new file mode 100644 index 0000000000..8acf29c0be --- /dev/null +++ b/packages/accounts-oauth2-helper/package.js @@ -0,0 +1,17 @@ +Package.describe({ + summary: "Common code for OAuth2-based login services", + internal: true +}); + +Package.on_use(function (api) { + api.use('accounts-oauth-helper', 'client'); + api.use('accounts-base', ['client', 'server']); + + api.add_files('oauth2_common.js', ['client', 'server']); + api.add_files('oauth2_server.js', 'server'); +}); + +Package.on_test(function (api) { + api.use('accounts-oauth2-helper', 'server'); + api.add_files("oauth2_tests.js", 'server'); +}); diff --git a/packages/accounts-password/email_templates.js b/packages/accounts-password/email_templates.js new file mode 100644 index 0000000000..fbcca8419a --- /dev/null +++ b/packages/accounts-password/email_templates.js @@ -0,0 +1,53 @@ +Accounts.emailTemplates = { + from: "Meteor Accounts ", + siteName: Meteor.absoluteUrl().replace(/^https?:\/\//, '').replace(/\/$/, ''), + + resetPassword: { + subject: function(user) { + return "How to reset your password on " + Accounts.emailTemplates.siteName; + }, + text: function(user, url) { + var greeting = (user.profile && user.profile.name) ? + ("Hello " + user.profile.name + ",") : "Hello,"; + return greeting + "\n" + + "\n" + + "To reset your password, simply click the link below.\n" + + "\n" + + url + "\n" + + "\n" + + "Thanks.\n"; + } + }, + verifyEmail: { + subject: function(user) { + return "How to verify email address on " + Accounts.emailTemplates.siteName; + }, + text: function(user, url) { + var greeting = (user.profile && user.profile.name) ? + ("Hello " + user.profile.name + ",") : "Hello,"; + return greeting + "\n" + + "\n" + + "To verify your account email, simply click the link below.\n" + + "\n" + + url + "\n" + + "\n" + + "Thanks.\n"; + } + }, + enrollAccount: { + subject: function(user) { + return "An account has been created for you on " + Accounts.emailTemplates.siteName; + }, + text: function(user, url) { + var greeting = (user.profile && user.profile.name) ? + ("Hello " + user.profile.name + ",") : "Hello,"; + return greeting + "\n" + + "\n" + + "To start using the service, simply click the link below.\n" + + "\n" + + url + "\n" + + "\n" + + "Thanks.\n"; + } + } +}; diff --git a/packages/accounts-password/email_tests.js b/packages/accounts-password/email_tests.js new file mode 100644 index 0000000000..843e98c261 --- /dev/null +++ b/packages/accounts-password/email_tests.js @@ -0,0 +1,234 @@ +(function () { + // intentionally initialize later so that we can debug tests after + // they fail without trying to recreate a user with the same email + // address + var email1; + var email2; + var email3; + var email4; + + var resetPasswordToken; + var verifyEmailToken; + var enrollAccountToken; + + Accounts._isolateLoginTokenForTest(); + + testAsyncMulti("accounts emails - reset password flow", [ + function (test, expect) { + email1 = Meteor.uuid() + "-intercept@example.com"; + Accounts.createUser({email: email1, password: 'foobar'}, + expect(function (error) { + test.equal(error, undefined); + })); + }, + function (test, expect) { + Accounts.forgotPassword({email: email1}, expect(function (error) { + test.equal(error, undefined); + })); + }, + function (test, expect) { + Meteor.call("getInterceptedEmails", email1, expect(function (error, result) { + test.notEqual(result, undefined); + test.equal(result.length, 2); // the first is the email verification + var content = result[1]; + + var match = content.match( + new RegExp(window.location.protocol + "//" + + window.location.host + "/#\\/reset-password/(\\S*)")); + test.isTrue(match); + resetPasswordToken = match[1]; + })); + }, + function (test, expect) { + Accounts.resetPassword(resetPasswordToken, "newPassword", expect(function(error) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Meteor.loginWithPassword( + {email: email1}, "newPassword", + expect(function (error) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + } + ]); + + var getVerifyEmailToken = function (email, test, expect) { + Meteor.call("getInterceptedEmails", email, expect(function (error, result) { + test.isFalse(error); + test.notEqual(result, undefined); + test.equal(result.length, 1); + var content = result[0]; + + var match = content.match( + new RegExp(window.location.protocol + "//" + + window.location.host + "/#\\/verify-email/(\\S*)")); + test.isTrue(match); + verifyEmailToken = match[1]; + })); + }; + + var waitUntilLoggedIn = function (test, expect) { + var unblockNextFunction = expect(); + var quiesceCallback = function () { + Meteor._autorun(function (handle) { + if (!Meteor.userLoaded()) return; + handle.stop(); + unblockNextFunction(); + }); + }; + return expect(function (error) { + test.equal(error, undefined); + Meteor.default_connection.onQuiesce(quiesceCallback); + }); + }; + + testAsyncMulti("accounts emails - verify email flow", [ + function (test, expect) { + email2 = Meteor.uuid() + "-intercept@example.com"; + email3 = Meteor.uuid() + "-intercept@example.com"; + Accounts.createUser( + {email: email2, password: 'foobar'}, + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email2); + test.isFalse(Meteor.user().emails[0].verified); + // We should NOT be publishing verification tokens! + test.isFalse(_.has(Meteor.user().emails[0], 'verificationTokens')); + }, + function (test, expect) { + getVerifyEmailToken(email2, test, expect); + }, + function (test, expect) { + // Log out, to test that verifyEmail logs us back in. (And if we don't + // do that, waitUntilLoggedIn won't be able to prevent race conditions.) + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Accounts.verifyEmail(verifyEmailToken, + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email2); + test.isTrue(Meteor.user().emails[0].verified); + }, + function (test, expect) { + Meteor.call( + "addEmailForTestAndVerify", email3, + expect(function (error, result) { + test.isFalse(error); + })); + }, + function (test, expect) { + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(Meteor.user().emails.length, 2); + test.equal(Meteor.user().emails[1].address, email3); + test.isFalse(Meteor.user().emails[1].verified); + })); + }, + function (test, expect) { + getVerifyEmailToken(email3, test, expect); + }, + function (test, expect) { + // Log out, to test that verifyEmail logs us back in. (And if we don't + // do that, waitUntilLoggedIn won't be able to prevent race conditions.) + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Accounts.verifyEmail(verifyEmailToken, + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails[1].address, email3); + test.isTrue(Meteor.user().emails[1].verified); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + } + ]); + + var getEnrollAccountToken = function (email, test, expect) { + Meteor.call("getInterceptedEmails", email, expect(function (error, result) { + test.notEqual(result, undefined); + test.equal(result.length, 1); + var content = result[0]; + + var match = content.match( + new RegExp(window.location.protocol + "//" + + window.location.host + "/#\\/enroll-account/(\\S*)")); + test.isTrue(match); + enrollAccountToken = match[1]; + })); + }; + + testAsyncMulti("accounts emails - enroll account flow", [ + function (test, expect) { + email4 = Meteor.uuid() + "-intercept@example.com"; + Meteor.call("createUserOnServer", email4, + expect(function (error, result) { + test.isFalse(error); + var user = result; + test.equal(user.emails.length, 1); + test.equal(user.emails[0].address, email4); + test.isFalse(user.emails[0].verified); + })); + }, + function (test, expect) { + getEnrollAccountToken(email4, test, expect); + }, + function (test, expect) { + Accounts.resetPassword(enrollAccountToken, 'password', + waitUntilLoggedIn(test, expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email4); + test.isTrue(Meteor.user().emails[0].verified); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + Meteor.loginWithPassword({email: email4}, 'password', + waitUntilLoggedIn(test ,expect)); + }, + function (test, expect) { + test.equal(Meteor.user().emails.length, 1); + test.equal(Meteor.user().emails[0].address, email4); + test.isTrue(Meteor.user().emails[0].verified); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + } + ]); +}) (); diff --git a/packages/accounts-password/email_tests_setup.js b/packages/accounts-password/email_tests_setup.js new file mode 100644 index 0000000000..9684f1e47c --- /dev/null +++ b/packages/accounts-password/email_tests_setup.js @@ -0,0 +1,40 @@ +(function () { + // + // a mechanism to intercept emails sent to addressing including + // the string "intercept", storing them in an array that can then + // be retrieved using the getInterceptedEmails method + // + var oldEmailSend = Email.send; + var interceptedEmails = {}; // (email address) -> (array of contents) + + Email.send = function (options) { + var to = options.to; + if (to.indexOf('intercept') === -1) { + oldEmailSend(options); + } else { + if (!interceptedEmails[to]) + interceptedEmails[to] = []; + + interceptedEmails[to].push(options.text); + } + }; + + Meteor.methods({ + getInterceptedEmails: function (email) { + return interceptedEmails[email]; + }, + + addEmailForTestAndVerify: function (email) { + Meteor.users.update( + {_id: this.userId}, + {$push: {emails: {address: email, verified: false}}}); + Accounts.sendVerificationEmail(this.userId, email); + }, + + createUserOnServer: function (email) { + var userId = Accounts.createUser({email: email}); + Accounts.sendEnrollmentEmail(userId); + return Meteor.users.findOne(userId); + } + }); +}) (); diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js new file mode 100644 index 0000000000..9eb408ad43 --- /dev/null +++ b/packages/accounts-password/package.js @@ -0,0 +1,22 @@ +Package.describe({ + summary: "Password support for accounts." +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('srp', ['client', 'server']); + api.use('email', ['server']); + + api.add_files('email_templates.js', 'server'); + api.add_files('passwords_server.js', 'server'); + api.add_files('passwords_client.js', 'client'); + api.add_files('passwords_common.js', ['server', 'client']); +}); + +Package.on_test(function(api) { + api.use(['accounts-password', 'tinytest', 'test-helpers', 'deps']); + api.add_files('passwords_tests_setup.js', 'server'); + api.add_files('passwords_tests.js', ['client', 'server']); + api.add_files('email_tests_setup.js', 'server'); + api.add_files('email_tests.js', 'client'); +}); diff --git a/packages/accounts-password/passwords_client.js b/packages/accounts-password/passwords_client.js new file mode 100644 index 0000000000..00e21e0498 --- /dev/null +++ b/packages/accounts-password/passwords_client.js @@ -0,0 +1,190 @@ +(function () { + Accounts.createUser = function (options, extra, callback) { + options = _.clone(options); // we'll be modifying options + + if (typeof extra === "function") { + callback = extra; + extra = {}; + } + + if (!options.password) + throw new Error("Must set options.password"); + var verifier = Meteor._srp.generateVerifier(options.password); + // strip old password, replacing with the verifier object + delete options.password; + options.srp = verifier; + + Meteor.apply('createUser', [options, extra], {wait: true}, + function (error, result) { + if (error || !result) { + error = error || new Error("No result"); + callback && callback(error); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(undefined, {message: 'Success'}); + }); + }; + + // @param selector {String|Object} One of the following: + // - {username: (username)} + // - {email: (email)} + // - a string which may be a username or email, depending on whether + // it contains "@". + // @param password {String} + // @param callback {Function(error|undefined)} + Meteor.loginWithPassword = function (selector, password, callback) { + var srp = new Meteor._srp.Client(password); + var request = srp.startExchange(); + + if (typeof selector === 'string') + if (selector.indexOf('@') === -1) + selector = {username: selector}; + else + selector = {email: selector}; + + request.user = selector; + + Meteor.apply('beginPasswordExchange', [request], function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to beginPasswordExchange"); + callback && callback(error); + return; + } + + var response = srp.respondToChallenge(result); + Meteor.apply('login', [ + {srp: response} + ], {wait: true}, function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to login"); + callback && callback(error); + return; + } + + if (!srp.verifyConfirmation({HAMK: result.HAMK})) { + callback && callback(new Error("Server is cheating!")); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + }); + }); + }; + + + // @param oldPassword {String|null} + // @param newPassword {String} + // @param callback {Function(error|undefined)} + Accounts.changePassword = function (oldPassword, newPassword, callback) { + if (!Meteor.user()) { + callback && callback(new Error("Must be logged in to change password.")); + return; + } + + var verifier = Meteor._srp.generateVerifier(newPassword); + + if (!oldPassword) { + Meteor.apply('changePassword', [{srp: verifier}], function (error, result) { + if (error || !result) { + callback && callback( + error || new Error("No result from changePassword.")); + } else { + callback && callback(); + } + }); + } else { // oldPassword + var srp = new Meteor._srp.Client(oldPassword); + var request = srp.startExchange(); + request.user = {id: Meteor.user()._id}; + Meteor.apply('beginPasswordExchange', [request], function (error, result) { + if (error || !result) { + callback && callback( + error || new Error("No result from call to beginPasswordExchange")); + return; + } + + var response = srp.respondToChallenge(result); + response.srp = verifier; + Meteor.apply('changePassword', [response], function (error, result) { + if (error || !result) { + callback && callback( + error || new Error("No result from changePassword.")); + } else { + if (!srp.verifyConfirmation(result)) { + // Monkey business! + callback && callback(new Error("Old password verification failed.")); + } else { + callback && callback(); + } + } + }); + }); + } + }; + + // Sends an email to a user with a link that can be used to reset + // their password + // + // @param options {Object} + // - email: (email) + // @param callback (optional) {Function(error|undefined)} + Accounts.forgotPassword = function(options, callback) { + if (!options.email) + throw new Error("Must pass options.email"); + Meteor.call("forgotPassword", options, callback); + }; + + // Resets a password based on a token originally created by + // Accounts.forgotPassword, and then logs in the matching user. + // + // @param token {String} + // @param newPassword {String} + // @param callback (optional) {Function(error|undefined)} + Accounts.resetPassword = function(token, newPassword, callback) { + if (!token) + throw new Error("Need to pass token"); + if (!newPassword) + throw new Error("Need to pass newPassword"); + + var verifier = Meteor._srp.generateVerifier(newPassword); + Meteor.apply( + "resetPassword", [token, verifier], {wait: true}, + function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to resetPassword"); + callback && callback(error); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + }); + }; + + // Verifies a user's email address based on a token originally + // created by Accounts.sendVerificationEmail + // + // @param token {String} + // @param callback (optional) {Function(error|undefined)} + Accounts.verifyEmail = function(token, callback) { + if (!token) + throw new Error("Need to pass token"); + + Meteor.call( + "verifyEmail", token, + function (error, result) { + if (error || !result) { + error = error || new Error("No result from call to verifyEmail"); + callback && callback(error); + return; + } + + Accounts._makeClientLoggedIn(result.id, result.token); + callback && callback(); + }); + }; +})(); + diff --git a/packages/accounts-password/passwords_common.js b/packages/accounts-password/passwords_common.js new file mode 100644 index 0000000000..7ad0470e16 --- /dev/null +++ b/packages/accounts-password/passwords_common.js @@ -0,0 +1 @@ +Accounts.password = {}; diff --git a/packages/accounts-password/passwords_server.js b/packages/accounts-password/passwords_server.js new file mode 100644 index 0000000000..5ce4b5bf06 --- /dev/null +++ b/packages/accounts-password/passwords_server.js @@ -0,0 +1,446 @@ +(function () { + var selectorFromUserQuery = function (user) { + if (!user) + throw new Meteor.Error(400, "Must pass a user property in request"); + if (_.keys(user).length !== 1) + throw new Meteor.Error(400, "User property must have exactly one field"); + + var selector; + if (user.id) + selector = {_id: user.id}; + else if (user.username) + selector = {username: user.username}; + else if (user.email) + selector = {"emails.address": user.email}; + else + throw new Meteor.Error(400, "Must pass username, email, or id in request.user"); + + return selector; + }; + + Meteor.methods({ + // @param request {Object} with fields: + // user: either {username: (username)}, {email: (email)}, or {id: (userId)} + // A: hex encoded int. the client's public key for this exchange + // @returns {Object} with fields: + // identiy: string uuid + // salt: string uuid + // B: hex encoded int. server's public key for this exchange + beginPasswordExchange: function (request) { + var selector = selectorFromUserQuery(request.user); + + var user = Meteor.users.findOne(selector); + if (!user) + throw new Meteor.Error(403, "User not found"); + + if (!user.services || !user.services.password || + !user.services.password.srp) + throw new Meteor.Error(403, "User has no password set"); + + var verifier = user.services.password.srp; + var srp = new Meteor._srp.Server(verifier); + var challenge = srp.issueChallenge({A: request.A}); + + // save off results in the current session so we can verify them + // later. + this._sessionData.srpChallenge = + { userId: user._id, M: srp.M, HAMK: srp.HAMK }; + + return challenge; + }, + + changePassword: function (options) { + if (!this.userId) + throw new Meteor.Error(401, "Must be logged in"); + + // If options.M is set, it means we went through a challenge with + // the old password. + + if (!options.M /* could allow unsafe password changes here */) { + throw new Meteor.Error(403, "Old password required."); + } + + if (options.M) { + var serialized = this._sessionData.srpChallenge; + if (!serialized || serialized.M !== options.M) + throw new Meteor.Error(403, "Incorrect password"); + if (serialized.userId !== this.userId) + // No monkey business! + throw new Meteor.Error(403, "Incorrect password"); + // Only can use challenges once. + delete this._sessionData.srpChallenge; + } + + var verifier = options.srp; + if (!verifier && options.password) { + verifier = Meteor._srp.generateVerifier(options.password); + } + if (!verifier || !verifier.identity || !verifier.salt || + !verifier.verifier) + throw new Meteor.Error(400, "Invalid verifier"); + + // XXX this should invalidate all login tokens other than the current one + // (or it should assign a new login token, replacing existing ones) + Meteor.users.update({_id: this.userId}, + {$set: {'services.password.srp': verifier}}); + + var ret = {passwordChanged: true}; + if (serialized) + ret.HAMK = serialized.HAMK; + return ret; + }, + + forgotPassword: function (options) { + var email = options.email; + if (!email) + throw new Meteor.Error(400, "Need to set options.email"); + + var user = Meteor.users.findOne({"emails.address": email}); + if (!user) + throw new Meteor.Error(403, "User not found"); + + Accounts.sendResetPasswordEmail(user._id, email); + }, + + resetPassword: function (token, newVerifier) { + if (!token) + throw new Meteor.Error(400, "Need to pass token"); + if (!newVerifier) + throw new Meteor.Error(400, "Need to pass newVerifier"); + + var user = Meteor.users.findOne({"services.password.reset.token": token}); + if (!user) + throw new Meteor.Error(403, "Token expired"); + var email = user.services.password.reset.email; + if (!_.include(_.pluck(user.emails || [], 'address'), email)) + throw new Meteor.Error(403, "Token has invalid email address"); + + var stampedLoginToken = Accounts._generateStampedLoginToken(); + + // Update the user record by: + // - Changing the password verifier to the new one + // - Replacing all valid login tokens with new ones (changing + // password should invalidate existing sessions). + // - Forgetting about the reset token that was just used + // - Verifying their email, since they got the password reset via email. + Meteor.users.update({_id: user._id, 'emails.address': email}, { + $set: {'services.password.srp': newVerifier, + 'services.resume.loginTokens': [stampedLoginToken], + 'emails.$.verified': true}, + $unset: {'services.password.reset': 1} + }); + + this.setUserId(user._id); + return {token: stampedLoginToken.token, id: user._id}; + }, + + verifyEmail: function (token) { + if (!token) + throw new Meteor.Error(400, "Need to pass token"); + + var user = Meteor.users.findOne({'emails.verificationTokens.token': token}); + if (!user) + throw new Meteor.Error(403, "Verify email link expired"); + + // Log the user in with a new login token. + var stampedLoginToken = Accounts._generateStampedLoginToken(); + + // By including the token again in the query, we can use 'emails.$' in the + // modifier to get a reference to the specific object in the emails + // array. See + // http://www.mongodb.org/display/DOCS/Updating/#Updating-The%24positionaloperator) + // http://www.mongodb.org/display/DOCS/Updating#Updating-%24pull + Meteor.users.update( + {_id: user._id, 'emails.verificationTokens.token': token}, { + $set: {'emails.$.verified': true}, + $pull: {'emails.$.verificationTokens': {token: token}}, + $push: {'services.resume.loginTokens': stampedLoginToken}}); + + this.setUserId(user._id); + return {token: stampedLoginToken.token, id: user._id}; + } + }); + + + // send the user an email with a link that when opened allows the user + // to set a new password, without the old password. + Accounts.sendResetPasswordEmail = function (userId, email) { + // Make sure the user exists, and email is one of their addresses. + var user = Meteor.users.findOne(userId); + if (!user) + throw new Error("Can't find user"); + // pick the first email if we weren't passed an email. + if (!email && user.emails && user.emails[0]) + email = user.emails[0].address; + // make sure we have a valid email + if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) + throw new Error("No such email for user."); + + var token = Meteor.uuid(); + var when = +(new Date); + Meteor.users.update(userId, {$set: { + "services.password.reset": { + token: token, + email: email, + when: when + } + }}); + + var resetPasswordUrl = Accounts.urls.resetPassword(token); + Email.send({ + to: email, + from: Accounts.emailTemplates.from, + subject: Accounts.emailTemplates.resetPassword.subject(user), + text: Accounts.emailTemplates.resetPassword.text(user, resetPasswordUrl)}); + }; + + + // send the user an email with a link that when opened marks that + // address as verified + Accounts.sendVerificationEmail = function (userId, email) { + // XXX Also generate a link using which someone can delete this + // account if they own said address but weren't those who created + // this account. + + // Make sure the user exists, and email is one of their addresses. + var user = Meteor.users.findOne(userId); + if (!user) + throw new Error("Can't find user"); + // pick the first unverified email if we weren't passed an email. + if (!email) { + email = _.find(user.emails || [], function (e) { return !e.verified; }); + email = (email || {}).address; + } + // make sure we have a valid email + if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) + throw new Error("No such email for user."); + + + var stampedToken = {token: Meteor.uuid(), when: +(new Date)}; + Meteor.users.update({_id: userId, 'emails.address': email}, + {$push: {'emails.$.verificationTokens': stampedToken}}); + + var verifyEmailUrl = Accounts.urls.verifyEmail(stampedToken.token); + Email.send({ + to: email, + from: Accounts.emailTemplates.from, + subject: Accounts.emailTemplates.verifyEmail.subject(user), + text: Accounts.emailTemplates.verifyEmail.text(user, verifyEmailUrl) + }); + }; + + // send the user an email informing them that their account was created, with + // a link that when opened both marks their email as verified and forces them + // to choose their password. The email must be one of the addresses in the + // user's emails field, or undefined to pick the first email automatically. + Accounts.sendEnrollmentEmail = function (userId, email) { + // XXX refactor! This is basically identical to sendResetPasswordEmail. + + // Make sure the user exists, and email is in their addresses. + var user = Meteor.users.findOne(userId); + if (!user) + throw new Error("Can't find user"); + // pick the first email if we weren't passed an email. + if (!email && user.emails && user.emails[0]) + email = user.emails[0].address; + // make sure we have a valid email + if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) + throw new Error("No such email for user."); + + + var token = Meteor.uuid(); + var when = +(new Date); + Meteor.users.update(userId, {$set: { + "services.password.reset": { + token: token, + email: email, + when: when + } + }}); + + var enrollAccountUrl = Accounts.urls.enrollAccount(token); + Email.send({ + to: email, + from: Accounts.emailTemplates.from, + subject: Accounts.emailTemplates.enrollAccount.subject(user), + text: Accounts.emailTemplates.enrollAccount.text(user, enrollAccountUrl) + }); + }; + + + // handler to login with password + Accounts.registerLoginHandler(function (options) { + if (!options.srp) + return undefined; // don't handle + if (!options.srp.M) + throw new Meteor.Error(400, "Must pass M in options.srp"); + + // we're always called from within a 'login' method, so this should + // be safe. + var currentInvocation = Meteor._CurrentInvocation.get(); + var serialized = currentInvocation._sessionData.srpChallenge; + if (!serialized || serialized.M !== options.srp.M) + throw new Meteor.Error(403, "Incorrect password"); + // Only can use challenges once. + delete currentInvocation._sessionData.srpChallenge; + + var userId = serialized.userId; + var user = Meteor.users.findOne(userId); + // Was the user deleted since the start of this challenge? + if (!user) + throw new Meteor.Error(403, "User not found"); + var stampedLoginToken = Accounts._generateStampedLoginToken(); + Meteor.users.update( + userId, {$push: {'services.resume.loginTokens': stampedLoginToken}}); + + return {token: stampedLoginToken.token, id: userId, HAMK: serialized.HAMK}; + }); + + // handler to login with plaintext password. + // + // The meteor client doesn't use this, it is for other DDP clients who + // haven't implemented SRP. Since it sends the password in plaintext + // over the wire, it should only be run over SSL! + // + // Also, it might be nice if servers could turn this off. Or maybe it + // should be opt-in, not opt-out? Accounts.config option? + Accounts.registerLoginHandler(function (options) { + if (!options.password || !options.user) + return undefined; // don't handle + + var selector = selectorFromUserQuery(options.user); + var user = Meteor.users.findOne(selector); + if (!user) + throw new Meteor.Error(403, "User not found"); + + if (!user.services || !user.services.password || + !user.services.password.srp) + throw new Meteor.Error(403, "User has no password set"); + + // Just check the verifier output when the same identity and salt + // are passed. Don't bother with a full exchange. + var verifier = user.services.password.srp; + var newVerifier = Meteor._srp.generateVerifier(options.password, { + identity: verifier.identity, salt: verifier.salt}); + + if (verifier.verifier !== newVerifier.verifier) + throw new Meteor.Error(403, "Incorrect password"); + + var stampedLoginToken = Accounts._generateStampedLoginToken(); + Meteor.users.update( + user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}}); + + return {token: stampedLoginToken.token, id: user._id}; + }); + + + Meteor.setPassword = function (userId, newPassword) { + var user = Meteor.users.findOne(userId); + if (!user) + throw new Meteor.Error(403, "User not found"); + var newVerifier = Meteor._srp.generateVerifier(newPassword); + + Meteor.users.update({_id: user._id}, { + $set: {'services.password.srp': newVerifier}}); + }; + + + //////////// + // Creating users: + + + // Shared createUser function called from the createUser method, both + // if originates in client or server code. Calls user provided hooks, + // does the actual user insertion. + // + // returns an object with id: userId, and (if options.generateLoginToken is + // set) token: loginToken. + var createUser = function (options, extra) { + extra = extra || {}; + var username = options.username; + var email = options.email; + if (!username && !email) + throw new Meteor.Error(400, "Need to set a username or email"); + + // Raw password. The meteor client doesn't send this, but a DDP + // client that didn't implement SRP could send this. This should + // only be done over SSL. + if (options.password) { + if (options.srp) + throw new Meteor.Error(400, "Don't pass both password and srp in options"); + options.srp = Meteor._srp.generateVerifier(options.password); + } + + var user = {services: {}}; + if (options.srp) + user.services.password = {srp: options.srp}; // XXX validate verifier + if (username) + user.username = username; + if (email) + user.emails = [{address: email, verified: false}]; + + return Accounts.insertUserDoc(options, extra, user); + }; + + // method for create user. Requests come from the client. + Meteor.methods({ + createUser: function (options, extra) { + options = _.clone(options); + options.generateLoginToken = true; + if (Accounts._options.forbidClientAccountCreation) + throw new Meteor.Error(403, "Signups forbidden"); + + // Create user. result contains id and token. + var result = createUser(options, extra); + // safety belt. createUser is supposed to throw on error. send 500 error + // instead of sending a verification email with empty userid. + if (!result.id) + throw new Error("createUser failed to insert new user"); + + // If `Accounts._options.sendVerificationEmail` is set, register + // a token to verify the user's primary email, and send it to + // that address. + if (options.email && Accounts._options.sendVerificationEmail) + Accounts.sendVerificationEmail(result.id, options.email); + + // client gets logged in as the new user afterwards. + this.setUserId(result.id); + return result; + } + }); + + // Create user directly on the server. + // + // Unlike the client version, this does not log you in as this user + // after creation. + // + // returns userId or throws an error if it can't create + // + // XXX add another argument ("server options") that gets sent to onCreateUser, + // which is always empty when called from the createUser method? eg, "admin: + // true", which we want to prevent the client from setting, but which a custom + // method calling Accounts.createUser could set? + Accounts.createUser = function (options, extra, callback) { + options = _.clone(options); + options.generateLoginToken = false; + if (typeof extra === "function") { + callback = extra; + extra = {}; + } + + // XXX allow an optional callback? + if (callback) { + throw new Error("Meteor.createUser with callback not supported on the server yet."); + } + + var userId = createUser(options, extra).id; + + return userId; + }; + + // PASSWORD-SPECIFIC INDEXES ON USERS + Meteor.users._ensureIndex('emails.validationTokens.token', + {unique: 1, sparse: 1}); + Meteor.users._ensureIndex('emails.password.reset.token', + {unique: 1, sparse: 1}); +})(); diff --git a/packages/accounts-password/passwords_tests.js b/packages/accounts-password/passwords_tests.js new file mode 100644 index 0000000000..79058da652 --- /dev/null +++ b/packages/accounts-password/passwords_tests.js @@ -0,0 +1,334 @@ +if (Meteor.isClient) (function () { + + // XXX note, only one test can do login/logout things at once! for + // now, that is this test. + + Accounts._isolateLoginTokenForTest(); + + var logoutStep = function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }; + + var verifyUsername = function (someUsername, test, expect) { + var callWhenLoaded = expect(function() { + test.equal(Meteor.user().username, someUsername); + }); + return function () { + Meteor._autorun(function(handle) { + if (!Meteor.userLoaded()) return; + handle.stop(); + callWhenLoaded(); + }); + }; + }; + var loggedInAs = function (someUsername, test, expect) { + var quiesceCallback = verifyUsername(someUsername, test, expect); + return expect(function (error) { + test.equal(error, undefined); + Meteor.default_connection.onQuiesce(quiesceCallback); + }); + }; + + // declare variable outside the testAsyncMulti, so we can refer to + // them from multiple tests, but initialize them to new values inside + // the test so when we use the 'debug' link in the tests, they get new + // values and the tests don't fail. + var username, username2, username3; + var email; + var password, password2, password3; + + testAsyncMulti("passwords - long series", [ + function (test, expect) { + username = Meteor.uuid(); + username2 = Meteor.uuid(); + username3 = Meteor.uuid(); + // use -intercept so that we don't print to the console + email = Meteor.uuid() + '-intercept@example.com'; + password = 'password'; + password2 = 'password2'; + password3 = 'password3'; + }, + + function (test, expect) { + Accounts.createUser( + {username: username, email: email, password: password}, + loggedInAs(username, test, expect)); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword(username, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + // This next step tests reactive contexts which are reactive on + // Meteor.user() without explicitly calling Meteor.userLoaded() --- we want + // to make sure that user loading finishing invalidates them too. + function (test, expect) { + // Set up a reactive context that only refreshes when Meteor.user() is + // invalidated. + var user; + var handle1 = Meteor._autorun(function () { + user = Meteor.user(); + }); + // At the beginning, we're not logged in. + test.equal(user, null); + + // This will get called once a second context (which does explicitly call + // Meteor.userLoaded()) tells us we are ready. + var callWhenLoaded = expect(function () { + Meteor.flush(); + // ... and this means that the first context did refresh and give us + // data. + test.isTrue(user.emails); + handle1.stop(); + }); + var waitForLoaded = expect(function () { + Meteor._autorun(function(handle2) { + if (!Meteor.userLoaded()) return; + handle2.stop(); + callWhenLoaded(); + }); + }); + Meteor.loginWithPassword(username, password, expect(function (error) { + test.equal(error, undefined); + test.notEqual(Meteor.userId(), null); + // Since userId has changed, the first autorun has been invalidated, so + // flush will re-run it and user will become not null. In the *CURRENT + // IMPLEMENTATION*, we will have just called _makeClientLoggedIn which + // just started a new meteor.currentUser subscription. There is no way + // that it is complete yet because we haven't gotten back to the event + // loop to actually get the data, so user.emails hasn't been populated + // yet. (That said, if we redo how userLoaded is implemented to not + // involve unsub/sub, it's possible that this test may become flaky by + // the test.isFalse failing.) + Meteor.flush(); + test.notEqual(user, null); + test.isFalse(user.emails); + waitForLoaded(); + })); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword({username: username}, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword(email, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + function (test, expect) { + Meteor.loginWithPassword({email: email}, password, + loggedInAs(username, test, expect)); + }, + logoutStep, + // plain text password. no API for this, have to send a raw message. + function (test, expect) { + Meteor.call( + // wrong password + 'login', {user: {email: email}, password: password2}, + expect(function (error, result) { + test.isTrue(error); + test.isFalse(result); + test.isFalse(Meteor.user()); + })); + }, + function (test, expect) { + var quiesceCallback = verifyUsername(username, test, expect); + Meteor.call( + // right password + 'login', {user: {email: email}, password: password}, + expect(function (error, result) { + test.equal(error, undefined); + test.isTrue(result.id); + test.isTrue(result.token); + // emulate the real login behavior, so as not to confuse test. + Accounts._makeClientLoggedIn(result.id, result.token); + Meteor.default_connection.onQuiesce(quiesceCallback); + })); + }, + // change password with bad old password. we stay logged in. + function (test, expect) { + var quiesceCallback = verifyUsername(username, test, expect); + Accounts.changePassword(password2, password2, expect(function (error) { + test.isTrue(error); + Meteor.default_connection.onQuiesce(quiesceCallback); + })); + }, + // change password with good old password. + function (test, expect) { + Accounts.changePassword(password, password2, + loggedInAs(username, test, expect)); + }, + logoutStep, + // old password, failed login + function (test, expect) { + Meteor.loginWithPassword(email, password, expect(function (error) { + test.isTrue(error); + test.isFalse(Meteor.user()); + })); + }, + // new password, success + function (test, expect) { + Meteor.loginWithPassword(email, password2, + loggedInAs(username, test, expect)); + }, + logoutStep, + // create user with raw password + function (test, expect) { + var quiesceCallback = verifyUsername(username2, test, expect); + Meteor.call('createUser', {username: username2, password: password2}, + expect(function (error, result) { + test.equal(error, undefined); + test.isTrue(result.id); + test.isTrue(result.token); + // emulate the real login behavior, so as not to confuse test. + Accounts._makeClientLoggedIn(result.id, result.token); + Meteor.default_connection.onQuiesce(quiesceCallback); + })); + }, + logoutStep, + function(test, expect) { + Meteor.loginWithPassword({username: username2}, password2, + loggedInAs(username2, test, expect)); + }, + logoutStep, + // test Accounts.validateNewUser + function(test, expect) { + Accounts.createUser({username: username3, password: password3}, + // should fail the new user validators + {profile: {invalid: true}}, + expect(function (error) { + test.equal(error.error, 403); + test.equal( + error.reason, + "User validation failed"); + })); + }, + logoutStep, + function(test, expect) { + Accounts.createUser({username: username3, password: password3}, + // should fail the new user validator with a special + // exception + {profile: {invalidAndThrowException: true}}, + expect(function (error) { + test.equal( + error.reason, + "An exception thrown within Accounts.validateNewUser"); + })); + }, + // test Accounts.onCreateUser + function(test, expect) { + Accounts.createUser( + {username: username3, password: password3}, + {testOnCreateUserHook: true}, + loggedInAs(username3, test, expect)); + }, + function(test, expect) { + test.equal(Meteor.user().profile.touchedByOnCreateUser, true); + }, + + // test Meteor.user(). This test properly belongs in + // accounts-base/accounts_tests.js, but this is where the tests that + // actually log in are. + function(test, expect) { + var clientUser = Meteor.user(); + Meteor.call('testMeteorUser', expect(function (err, result) { + test.equal(result._id, clientUser._id); + test.equal(result.profile.touchedByOnCreateUser, true); + test.equal(err, undefined); + })); + }, + logoutStep, + function(test, expect) { + var clientUser = Meteor.user(); + test.equal(clientUser, null); + Meteor.call('testMeteorUser', expect(function (err, result) { + test.equal(err, undefined); + test.equal(result, null); + })); + } + + ]); + +}) (); + + +if (Meteor.isServer) (function () { + + Tinytest.add( + 'passwords - setup more than one onCreateUserHook', + function (test) { + test.throws(function() { + Accounts.onCreateUser(function () {}); + }); + }); + + + Tinytest.add( + 'passwords - createUser hooks', + function (test) { + var email = Meteor.uuid() + '@example.com'; + test.throws(function () { + // should fail the new user validators + Accounts.createUser({email: email}, {profile: {invalid: true}}); + }); + + // disable sending emails + var oldEmailSend = Email.send; + Email.send = function() {}; + var userId = Accounts.createUser({email: email}, + {testOnCreateUserHook: true}); + Email.send = oldEmailSend; + + test.isTrue(userId); + var user = Meteor.users.findOne(userId); + test.equal(user.profile.touchedByOnCreateUser, true); + }); + + + Tinytest.add( + 'passwords - setPassword', + function (test) { + var username = Meteor.uuid(); + + var userId = Accounts.createUser({username: username}, {}); + + var user = Meteor.users.findOne(userId); + // no services yet. + test.equal(user.services.password, undefined); + + // set a new password. + Meteor.setPassword(userId, 'new password'); + user = Meteor.users.findOne(userId); + var oldVerifier = user.services.password.srp; + test.isTrue(user.services.password.srp); + + // reset with the same password, see we get a different verifier + Meteor.setPassword(userId, 'new password'); + user = Meteor.users.findOne(userId); + var newVerifier = user.services.password.srp; + test.notEqual(oldVerifier.salt, newVerifier.salt); + test.notEqual(oldVerifier.identity, newVerifier.identity); + test.notEqual(oldVerifier.verifier, newVerifier.verifier); + + // cleanup + Meteor.users.remove(userId); + }); + + + // This test properly belongs in accounts-base/accounts_tests.js, but + // this is where the tests that actually log in are. + Tinytest.add('accounts - user() out of context', function (test) { + // basic server context, no method. + test.throws(function () { + Meteor.user(); + }); + }); + + // XXX would be nice to test Accounts.config({forbidClientAccountCreation: true}) +}) (); diff --git a/packages/accounts-password/passwords_tests_setup.js b/packages/accounts-password/passwords_tests_setup.js new file mode 100644 index 0000000000..bd5aa463e6 --- /dev/null +++ b/packages/accounts-password/passwords_tests_setup.js @@ -0,0 +1,39 @@ +Accounts.validateNewUser(function (user) { + if (user.profile && user.profile.invalidAndThrowException) + throw new Meteor.Error(403, "An exception thrown within Accounts.validateNewUser"); + return !(user.profile && user.profile.invalid); +}); + +Accounts.onCreateUser(function (options, extra, user) { + if (extra.testOnCreateUserHook) { + user.profile = (user.profile || {}); + user.profile.touchedByOnCreateUser = true; + return user; + } else { + return 'TEST DEFAULT HOOK'; + } +}); + + +// Because this is global state that affects every client, we can't turn +// it on and off during the tests. Doing so would mean two simultaneous +// test runs could collide with each other. +// +// We should probably have some sort of server-isolation between +// multiple test runs. Perhaps a separate server instance per run. This +// problem isn't unique to this test, there are other places in the code +// where we do various hacky things to work around the lack of +// server-side isolation. +// +// For now, we just test the one configuration state. You can comment +// out each configuration option and see that the tests fail. +Accounts.config({ + sendVerificationEmail: true +}); + + +// This test properly belongs in accounts-base/accounts_tests.js, but +// this is where the tests that actually log in are. +Meteor.methods({ + testMeteorUser: function () { return Meteor.user(); } +}); diff --git a/packages/accounts-twitter/package.js b/packages/accounts-twitter/package.js new file mode 100644 index 0000000000..bae64bbd53 --- /dev/null +++ b/packages/accounts-twitter/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Twitter accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth1-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['twitter_configure.html', 'twitter_configure.js'], + 'client'); + + api.add_files('twitter_common.js', ['client', 'server']); + api.add_files('twitter_server.js', 'server'); + api.add_files('twitter_client.js', 'client'); +}); diff --git a/packages/accounts-twitter/twitter_client.js b/packages/accounts-twitter/twitter_client.js new file mode 100644 index 0000000000..3eb979d57d --- /dev/null +++ b/packages/accounts-twitter/twitter_client.js @@ -0,0 +1,34 @@ +(function () { + // XXX support options.requestPermissions as we do for Facebook, Google, Github + Meteor.loginWithTwitter = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'twitter'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + // We need to keep state across the next two 'steps' so we're adding + // a state parameter to the url and the callback url that we'll be returned + // to by oauth provider + + // url back to app, enters "step 2" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var callbackUrl = Meteor.absoluteUrl('_oauth/twitter?close&state=' + state); + + // url to app, enters "step 1" as described in + // packages/accounts-oauth1-helper/oauth1_server.js + var url = '/_oauth/twitter/?requestTokenAndRedirect=' + + encodeURIComponent(callbackUrl) + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, url, callback); + }; + +})(); diff --git a/packages/accounts-twitter/twitter_common.js b/packages/accounts-twitter/twitter_common.js new file mode 100644 index 0000000000..3fdcd9d2bc --- /dev/null +++ b/packages/accounts-twitter/twitter_common.js @@ -0,0 +1,10 @@ +if (!Accounts.twitter) { + Accounts.twitter = {}; +} + +Accounts.twitter._urls = { + requestToken: "https://api.twitter.com/oauth/request_token", + authorize: "https://api.twitter.com/oauth/authorize", + accessToken: "https://api.twitter.com/oauth/access_token", + authenticate: "https://api.twitter.com/oauth/authenticate" +}; diff --git a/packages/accounts-twitter/twitter_configure.html b/packages/accounts-twitter/twitter_configure.html new file mode 100644 index 0000000000..67195547db --- /dev/null +++ b/packages/accounts-twitter/twitter_configure.html @@ -0,0 +1,13 @@ + diff --git a/packages/accounts-twitter/twitter_configure.js b/packages/accounts-twitter/twitter_configure.js new file mode 100644 index 0000000000..e41329cbde --- /dev/null +++ b/packages/accounts-twitter/twitter_configure.js @@ -0,0 +1,11 @@ +Template.configureLoginServiceDialogForTwitter.siteUrl = function () { + // Twitter doesn't recognize localhost as a domain name + return Meteor.absoluteUrl({replaceLocalhost: true}); +}; + +Template.configureLoginServiceDialogForTwitter.fields = function () { + return [ + {property: 'consumerKey', label: 'Consumer key'}, + {property: 'secret', label: 'Consumer secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-twitter/twitter_server.js b/packages/accounts-twitter/twitter_server.js new file mode 100644 index 0000000000..5414dae9d9 --- /dev/null +++ b/packages/accounts-twitter/twitter_server.js @@ -0,0 +1,20 @@ +(function () { + + Accounts.oauth.registerService('twitter', 1, function(oauthBinding) { + var identity = oauthBinding.get('https://api.twitter.com/1/account/verify_credentials.json'); + + return { + serviceData: { + id: identity.id, + screenName: identity.screen_name, + accessToken: oauthBinding.accessToken, + accessTokenSecret: oauthBinding.accessTokenSecret + }, + extra: { + profile: { + name: identity.name + } + } + }; + }); +}) (); diff --git a/packages/accounts-ui-unstyled/accounts_ui.js b/packages/accounts-ui-unstyled/accounts_ui.js new file mode 100644 index 0000000000..46c823b9b0 --- /dev/null +++ b/packages/accounts-ui-unstyled/accounts_ui.js @@ -0,0 +1,41 @@ +if (!Accounts.ui) + Accounts.ui = {}; + +if (!Accounts.ui._options) { + Accounts.ui._options = { + requestPermissions: {} + }; +} + + +Accounts.ui.config = function(options) { + if (options.passwordSignupFields) { + if (_.contains([ + "USERNAME_AND_EMAIL", + "USERNAME_AND_OPTIONAL_EMAIL", + "USERNAME_ONLY", + "EMAIL_ONLY" + ], options.passwordSignupFields)) { + if (Accounts.ui._options.passwordSignupFields) + throw new Error("Can't set `passwordSignupFields` more than once"); + else + Accounts.ui._options.passwordSignupFields = options.passwordSignupFields; + } else { + throw new Error("Invalid option for `passwordSignupFields`: " + options.passwordSignupFields); + } + } + + if (options.requestPermissions) { + _.each(options.requestPermissions, function (scope, service) { + if (Accounts.ui._options.requestPermissions[service]) + throw new Error("Can't set `requestPermissions` more than once for " + service); + else + Accounts.ui._options.requestPermissions[service] = scope; + }); + } +}; + +Accounts.ui._passwordSignupFields = function () { + return Accounts.ui._options.passwordSignupFields || "EMAIL_ONLY"; +}; + diff --git a/packages/accounts-ui-unstyled/login_buttons.html b/packages/accounts-ui-unstyled/login_buttons.html new file mode 100644 index 0000000000..341d8e5e6f --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons.html @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/packages/accounts-ui-unstyled/login_buttons.js b/packages/accounts-ui-unstyled/login_buttons.js new file mode 100644 index 0000000000..79d039f779 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons.js @@ -0,0 +1,146 @@ +(function () { + if (!Accounts._loginButtons) + Accounts._loginButtons = {}; + + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + // shared between dropdown and single mode + Template.loginButtons.events({ + 'click #login-buttons-logout': function() { + Meteor.logout(function () { + loginButtonsSession.closeDropdown(); + }); + } + }); + + + // + // loginButtonLoggedOut template + // + + Template._loginButtonsLoggedOut.dropdown = function () { + return Accounts._loginButtons.dropdown(); + }; + + Template._loginButtonsLoggedOut.services = function () { + return Accounts._loginButtons.getLoginServices(); + }; + + Template._loginButtonsLoggedOut.singleService = function () { + var services = Accounts._loginButtons.getLoginServices(); + if (services.length !== 1) + throw new Error( + "Shouldn't be rendering this template with more than one configured service"); + return services[0]; + }; + + Template._loginButtonsLoggedOut.configurationLoaded = function () { + return Accounts.loginServicesConfigured(); + }; + + + // + // loginButtonsLoggedIn template + // + + // decide whether we should show a dropdown rather than a row of + // buttons + Template._loginButtonsLoggedIn.dropdown = function () { + return Accounts._loginButtons.dropdown(); + }; + + Template._loginButtonsLoggedIn.displayName = function () { + return Accounts._loginButtons.displayName(); + }; + + + + // + // loginButtonsMessage template + // + + Template._loginButtonsMessages.errorMessage = function () { + return loginButtonsSession.get('errorMessage'); + }; + + Template._loginButtonsMessages.infoMessage = function () { + return loginButtonsSession.get('infoMessage'); + }; + + + // + // helpers + // + + Accounts._loginButtons.displayName = function () { + var user = Meteor.user(); + if (!user) + return ''; + + if (user.profile && user.profile.name) + return user.profile.name; + if (user.username) + return user.username; + if (user.emails && user.emails[0] && user.emails[0].address) + return user.emails[0].address; + + return ''; + }; + + Accounts._loginButtons.getLoginServices = function () { + var ret = []; + // make sure to put password last, since this is how it is styled + // in the ui as well. + _.each( + ['facebook', 'google', 'weibo', 'twitter', 'github', 'password'], + function (service) { + if (Accounts[service]) + ret.push({name: service}); + }); + + return ret; + }; + + Accounts._loginButtons.hasPasswordService = function () { + return Accounts.password; + }; + + Accounts._loginButtons.dropdown = function () { + return Accounts._loginButtons.hasPasswordService() || Accounts._loginButtons.getLoginServices().length > 1; + }; + + // XXX improve these. should this be in accounts-password instead? + // + // XXX these will become configurable, and will be validated on + // the server as well. + Accounts._loginButtons.validateUsername = function (username) { + if (username.length >= 3) { + return true; + } else { + loginButtonsSession.set('errorMessage', "Username must be at least 3 characters long"); + return false; + } + }; + Accounts._loginButtons.validateEmail = function (email) { + if (Accounts.ui._passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL" && email === '') + return true; + + if (email.indexOf('@') !== -1) { + return true; + } else { + loginButtonsSession.set('errorMessage', "Invalid email"); + return false; + } + }; + Accounts._loginButtons.validatePassword = function (password) { + if (password.length >= 6) { + return true; + } else { + loginButtonsSession.set('errorMessage', "Password must be at least 6 characters long"); + return false; + } + }; + +})(); + diff --git a/packages/accounts-ui-unstyled/login_buttons_dialogs.html b/packages/accounts-ui-unstyled/login_buttons_dialogs.html new file mode 100644 index 0000000000..692d28d815 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dialogs.html @@ -0,0 +1,122 @@ + + {{> _resetPasswordDialog}} + {{> _enrollAccountDialog}} + {{> _justVerifiedEmailDialog}} + {{> _configureLoginServiceDialog}} + + + {{> _loginButtonsMessagesDialog}} + + + + + + + + + + + + + diff --git a/packages/accounts-ui-unstyled/login_buttons_dialogs.js b/packages/accounts-ui-unstyled/login_buttons_dialogs.js new file mode 100644 index 0000000000..c9dfc714f7 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dialogs.js @@ -0,0 +1,235 @@ +(function () { + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + + // + // populate the session so that the appropriate dialogs are + // displayed by reading variables set by accounts-urls, which parses + // special URLs. since accounts-ui depends on accounts-urls, we are + // guaranteed to have these set at this point. + // + + if (Accounts._resetPasswordToken) { + loginButtonsSession.set('resetPasswordToken', Accounts._resetPasswordToken); + } + + if (Accounts._enrollAccountToken) { + loginButtonsSession.set('enrollAccountToken', Accounts._enrollAccountToken); + } + + // Needs to be in Meteor.startup because of a package loading order + // issue. We can't be sure that accounts-password is loaded earlier + // than accounts-ui so Accounts.verifyEmail might not be defined. + Meteor.startup(function () { + if (Accounts._verifyEmailToken) { + Accounts.verifyEmail(Accounts._verifyEmailToken, function(error) { + Accounts._enableAutoLogin(); + if (!error) + loginButtonsSession.set('justVerifiedEmail', true); + // XXX show something if there was an error. + }); + } + }); + + + // + // resetPasswordDialog template + // + + Template._resetPasswordDialog.events({ + 'click #login-buttons-reset-password-button': function () { + resetPassword(); + }, + 'keypress #reset-password-new-password': function (event) { + if (event.keyCode === 13) + resetPassword(); + }, + 'click #login-buttons-cancel-reset-password': function () { + loginButtonsSession.set('resetPasswordToken', null); + Accounts._enableAutoLogin(); + } + }); + + var resetPassword = function () { + loginButtonsSession.resetMessages(); + var newPassword = document.getElementById('reset-password-new-password').value; + if (!Accounts._loginButtons.validatePassword(newPassword)) + return; + + Accounts.resetPassword( + loginButtonsSession.get('resetPasswordToken'), newPassword, + function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.set('resetPasswordToken', null); + Accounts._enableAutoLogin(); + } + }); + }; + + Template._resetPasswordDialog.inResetPasswordFlow = function () { + return loginButtonsSession.get('resetPasswordToken'); + }; + + + // + // enrollAccountDialog template + // + + Template._enrollAccountDialog.events({ + 'click #login-buttons-enroll-account-button': function () { + enrollAccount(); + }, + 'keypress #enroll-account-password': function (event) { + if (event.keyCode === 13) + enrollAccount(); + }, + 'click #login-buttons-cancel-enroll-account': function () { + loginButtonsSession.set('enrollAccountToken', null); + Accounts._enableAutoLogin(); + } + }); + + var enrollAccount = function () { + loginButtonsSession.resetMessages(); + var password = document.getElementById('enroll-account-password').value; + if (!Accounts._loginButtons.validatePassword(password)) + return; + + Accounts.resetPassword( + loginButtonsSession.get('enrollAccountToken'), password, + function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.set('enrollAccountToken', null); + Accounts._enableAutoLogin(); + } + }); + }; + + Template._enrollAccountDialog.inEnrollAccountFlow = function () { + return loginButtonsSession.get('enrollAccountToken'); + }; + + + // + // justVerifiedEmailDialog template + // + + Template._justVerifiedEmailDialog.events({ + 'click #just-verified-dismiss-button': function () { + loginButtonsSession.set('justVerifiedEmail', false); + } + }); + + Template._justVerifiedEmailDialog.visible = function () { + return loginButtonsSession.get('justVerifiedEmail'); + }; + + + // + // loginButtonsMessagesDialog template + // + + Template._loginButtonsMessagesDialog.events({ + 'click #messages-dialog-dismiss-button': function () { + loginButtonsSession.resetMessages(); + } + }); + + Template._loginButtonsMessagesDialog.visible = function () { + var hasMessage = loginButtonsSession.get('infoMessage') || loginButtonsSession.get('errorMessage'); + return !Accounts._loginButtons.dropdown() && hasMessage; + }; + + + // + // configureLoginServiceDialog template + // + + Template._configureLoginServiceDialog.events({ + 'click #configure-login-service-dismiss-button': function () { + loginButtonsSession.set('configureLoginServiceDialogVisible', false); + }, + 'click #configure-login-service-dialog-save-configuration': function () { + if (loginButtonsSession.get('configureLoginServiceDialogVisible')) { + // Prepare the configuration document for this login service + var serviceName = loginButtonsSession.get('configureLoginServiceDialogServiceName'); + var configuration = { + service: serviceName + }; + _.each(configurationFields(), function(field) { + configuration[field.property] = document.getElementById( + 'configure-login-service-dialog-' + field.property).value + .replace(/^\s*|\s*$/g, ""); // trim; + }); + + // Configure this login service + Meteor.call("configureLoginService", configuration, function (error, result) { + if (error) + Meteor._debug("Error configuring login service " + serviceName, error); + else + loginButtonsSession.set('configureLoginServiceDialogVisible', false); + }); + } + }, + 'input': function (event) { + // if the event fired on one of the configuration input fields, + // check whether we should enable the 'save configuration' button + if (event.target.id.indexOf('configure-login-service-dialog') === 0) + updateSaveDisabled(); + } + }); + + // check whether the 'save configuration' button should be enabled. + // this is a really strange way to implement this and a Forms + // Abstraction would make all of this reactive, and simpler. + var updateSaveDisabled = function () { + var anyFieldEmpty = _.any(configurationFields(), function(field) { + return document.getElementById( + 'configure-login-service-dialog-' + field.property).value === ''; + }); + + loginButtonsSession.set('configureLoginServiceDialogSaveDisabled', anyFieldEmpty); + }; + + // Returns the appropriate template for this login service. This + // template should be defined in the service's package + var configureLoginServiceDialogTemplateForService = function () { + var serviceName = loginButtonsSession.get('configureLoginServiceDialogServiceName'); + return Template['configureLoginServiceDialogFor' + capitalize(serviceName)]; + }; + + var configurationFields = function () { + var template = configureLoginServiceDialogTemplateForService(); + return template.fields(); + }; + + Template._configureLoginServiceDialog.configurationFields = function () { + return configurationFields(); + }; + + Template._configureLoginServiceDialog.visible = function () { + return loginButtonsSession.get('configureLoginServiceDialogVisible'); + }; + + Template._configureLoginServiceDialog.configurationSteps = function () { + // renders the appropriate template + return configureLoginServiceDialogTemplateForService()(); + }; + + Template._configureLoginServiceDialog.saveDisabled = function () { + return loginButtonsSession.get('configureLoginServiceDialogSaveDisabled'); + }; + + + // XXX from http://epeli.github.com/underscore.string/lib/underscore.string.js + var capitalize = function(str){ + str = str == null ? '' : String(str); + return str.charAt(0).toUpperCase() + str.slice(1); + }; + +}) (); diff --git a/packages/accounts-ui-unstyled/login_buttons_dropdown.html b/packages/accounts-ui-unstyled/login_buttons_dropdown.html new file mode 100644 index 0000000000..b3cc079286 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dropdown.html @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/accounts-ui-unstyled/login_buttons_dropdown.js b/packages/accounts-ui-unstyled/login_buttons_dropdown.js new file mode 100644 index 0000000000..b4e46780a0 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_dropdown.js @@ -0,0 +1,494 @@ +(function () { + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + // events shared between loginButtonsLoggedOutDropdown and + // loginButtonsLoggedInDropdown + Template.loginButtons.events({ + 'click #login-name-link, click #login-sign-in-link': function () { + loginButtonsSession.set('dropdownVisible', true); + Meteor.flush(); + correctDropdownZIndexes(); + }, + 'click .login-close-text': function () { + loginButtonsSession.closeDropdown(); + } + }); + + + // + // loginButtonsLoggedInDropdown template and related + // + + Template._loginButtonsLoggedInDropdown.events({ + 'click #login-buttons-open-change-password': function() { + loginButtonsSession.resetMessages(); + loginButtonsSession.set('inChangePasswordFlow', true); + } + }); + + Template._loginButtonsLoggedInDropdown.displayName = function () { + return Accounts._loginButtons.displayName(); + }; + + Template._loginButtonsLoggedInDropdown.inChangePasswordFlow = function () { + return loginButtonsSession.get('inChangePasswordFlow'); + }; + + Template._loginButtonsLoggedInDropdown.inMessageOnlyFlow = function () { + return loginButtonsSession.get('inMessageOnlyFlow'); + }; + + Template._loginButtonsLoggedInDropdown.dropdownVisible = function () { + return loginButtonsSession.get('dropdownVisible'); + }; + + Template._loginButtonsLoggedInDropdownActions.allowChangingPassword = function () { + // it would be more correct to check whether the user has a password set, + // but in order to do that we'd have to send more data down to the client, + // and it'd be preferable not to send down the entire service.password document. + // + // instead we use the heuristic: if the user has a username or email set. + var user = Meteor.user(); + return user.username || (user.emails && user.emails[0] && user.emails[0].address); + }; + + + // + // loginButtonsLoggedOutDropdown template and related + // + + Template._loginButtonsLoggedOutDropdown.events({ + 'click #login-buttons-password': function () { + loginOrSignup(); + }, + + 'keypress #forgot-password-email': function (event) { + if (event.keyCode === 13) + forgotPassword(); + }, + + 'click #login-buttons-forgot-password': function () { + forgotPassword(); + }, + + 'click #signup-link': function () { + loginButtonsSession.resetMessages(); + + // store values of fields before swtiching to the signup form + var username = trimmedElementValueById('login-username'); + var email = trimmedElementValueById('login-email'); + var usernameOrEmail = trimmedElementValueById('login-username-or-email'); + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + + loginButtonsSession.set('inSignupFlow', true); + loginButtonsSession.set('inForgotPasswordFlow', false); + // force the ui to update so that we have the approprate fields to fill in + Meteor.flush(); + + // update new fields with appropriate defaults + if (username !== null) + document.getElementById('login-username').value = username; + else if (email !== null) + document.getElementById('login-email').value = email; + else if (usernameOrEmail !== null) + if (usernameOrEmail.indexOf('@') === -1) + document.getElementById('login-username').value = usernameOrEmail; + else + document.getElementById('login-email').value = usernameOrEmail; + // "login-password" is preserved thanks to the preserve-inputs package + + // Force redrawing the `login-dropdown-list` element because of + // a bizarre Chrome bug in which part of the DIV is not redrawn + // in case you had tried to unsuccessfully log in before + // switching to the signup form. + // + // Found tip on how to force a redraw on + // http://stackoverflow.com/questions/3485365/how-can-i-force-webkit-to-redraw-repaint-to-propagate-style-changes/3485654#3485654 + var redraw = document.getElementById('login-dropdown-list'); + redraw.style.display = 'none'; + redraw.offsetHeight; // it seems that this line does nothing but is necessary for the redraw to work + redraw.style.display = 'block'; + }, + 'click #forgot-password-link': function () { + loginButtonsSession.resetMessages(); + + // store values of fields before swtiching to the signup form + var email = trimmedElementValueById('login-email'); + var usernameOrEmail = trimmedElementValueById('login-username-or-email'); + + loginButtonsSession.set('inSignupFlow', false); + loginButtonsSession.set('inForgotPasswordFlow', true); + // force the ui to update so that we have the approprate fields to fill in + Meteor.flush(); + + // update new fields with appropriate defaults + if (email !== null) + document.getElementById('forgot-password-email').value = email; + else if (usernameOrEmail !== null) + if (usernameOrEmail.indexOf('@') !== -1) + document.getElementById('forgot-password-email').value = usernameOrEmail; + + }, + 'click #back-to-login-link': function () { + loginButtonsSession.resetMessages(); + + var username = trimmedElementValueById('login-username'); + var email = trimmedElementValueById('login-email') + || trimmedElementValueById('forgot-password-email'); // Ughh. Standardize on names? + + loginButtonsSession.set('inSignupFlow', false); + loginButtonsSession.set('inForgotPasswordFlow', false); + // force the ui to update so that we have the approprate fields to fill in + Meteor.flush(); + + if (document.getElementById('login-username')) + document.getElementById('login-username').value = username; + if (document.getElementById('login-email')) + document.getElementById('login-email').value = email; + // "login-password" is preserved thanks to the preserve-inputs package + if (document.getElementById('login-username-or-email')) + document.getElementById('login-username-or-email').value = email || username; + }, + 'keypress #login-username, keypress #login-email, keypress #login-username-or-email, keypress #login-password, keypress #login-password-again': function (event) { + if (event.keyCode === 13) + loginOrSignup(); + } + }); + + // additional classes that can be helpful in styling the dropdown + Template._loginButtonsLoggedOutDropdown.additionalClasses = function () { + if (!Accounts.password) { + return false; + } else { + if (loginButtonsSession.get('inSignupFlow')) { + return 'login-form-create-account'; + } else if (loginButtonsSession.get('inForgotPasswordFlow')) { + return 'login-form-forgot-password'; + } else { + return 'login-form-sign-in'; + } + } + }; + + Template._loginButtonsLoggedOutDropdown.dropdownVisible = function () { + return loginButtonsSession.get('dropdownVisible'); + }; + + Template._loginButtonsLoggedOutDropdown.hasPasswordService = function () { + return Accounts._loginButtons.hasPasswordService(); + }; + + Template._loginButtonsLoggedOutAllServices.services = function () { + return Accounts._loginButtons.getLoginServices(); + }; + + Template._loginButtonsLoggedOutAllServices.isPasswordService = function () { + return this.name === 'password'; + }; + + Template._loginButtonsLoggedOutAllServices.hasOtherServices = function () { + return Accounts._loginButtons.getLoginServices().length > 1; + }; + + Template._loginButtonsLoggedOutAllServices.hasPasswordService = function () { + return Accounts._loginButtons.hasPasswordService(); + }; + + Template._loginButtonsLoggedOutPasswordService.fields = function () { + var loginFields = [ + {fieldName: 'username-or-email', fieldLabel: 'Username or Email', + visible: function () { + return _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL"], + Accounts.ui._passwordSignupFields()); + }}, + {fieldName: 'username', fieldLabel: 'Username', + visible: function () { + return Accounts.ui._passwordSignupFields() === "USERNAME_ONLY"; + }}, + {fieldName: 'email', fieldLabel: 'Email', + visible: function () { + return Accounts.ui._passwordSignupFields() === "EMAIL_ONLY"; + }}, + {fieldName: 'password', fieldLabel: 'Password', inputType: 'password', + visible: function () { + return true; + }} + ]; + + var signupFields = [ + {fieldName: 'username', fieldLabel: 'Username', + visible: function () { + return _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + Accounts.ui._passwordSignupFields()); + }}, + {fieldName: 'email', fieldLabel: 'Email', + visible: function () { + return _.contains( + ["USERNAME_AND_EMAIL", "EMAIL_ONLY"], + Accounts.ui._passwordSignupFields()); + }}, + {fieldName: 'email', fieldLabel: 'Email (optional)', + visible: function () { + return Accounts.ui._passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL"; + }}, + {fieldName: 'password', fieldLabel: 'Password', inputType: 'password', + visible: function () { + return true; + }}, + {fieldName: 'password-again', fieldLabel: 'Password (again)', + inputType: 'password', + visible: function () { + // No need to make users double-enter their password if + // they'll necessarily have an email set, since they can use + // the "forgot password" flow. + return _.contains( + ["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + Accounts.ui._passwordSignupFields()); + }} + ]; + + return loginButtonsSession.get('inSignupFlow') ? signupFields : loginFields; + }; + + Template._loginButtonsLoggedOutPasswordService.inForgotPasswordFlow = function () { + return loginButtonsSession.get('inForgotPasswordFlow'); + }; + + Template._loginButtonsLoggedOutPasswordService.inLoginFlow = function () { + return !loginButtonsSession.get('inSignupFlow') && !loginButtonsSession.get('inForgotPasswordFlow'); + }; + + Template._loginButtonsLoggedOutPasswordService.inSignupFlow = function () { + return loginButtonsSession.get('inSignupFlow'); + }; + + Template._loginButtonsLoggedOutPasswordService.showForgotPasswordLink = function () { + return _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "EMAIL_ONLY"], + Accounts.ui._passwordSignupFields()); + }; + + + // + // loginButtonsChangePassword template + // + + Template._loginButtonsChangePassword.events({ + 'keypress #login-old-password, keypress #login-password, keypress #login-password-again': function (event) { + if (event.keyCode === 13) + changePassword(); + }, + 'click #login-buttons-do-change-password': function () { + changePassword(); + } + }); + + Template._loginButtonsChangePassword.fields = function () { + return [ + {fieldName: 'old-password', fieldLabel: 'Current Password', inputType: 'password', + visible: function () { + return true; + }}, + {fieldName: 'password', fieldLabel: 'New Password', inputType: 'password', + visible: function () { + return true; + }}, + {fieldName: 'password-again', fieldLabel: 'New Password (again)', + inputType: 'password', + visible: function () { + // No need to make users double-enter their password if + // they'll necessarily have an email set, since they can use + // the "forgot password" flow. + return _.contains( + ["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + Accounts.ui._passwordSignupFields()); + }} + ]; + }; + + + // + // helpers + // + + var elementValueById = function(id) { + var element = document.getElementById(id); + if (!element) + return null; + else + return element.value; + }; + + var trimmedElementValueById = function(id) { + var element = document.getElementById(id); + if (!element) + return null; + else + return element.value.replace(/^\s*|\s*$/g, ""); // trim; + }; + + var loginOrSignup = function () { + if (loginButtonsSession.get('inSignupFlow')) + signup(); + else + login(); + }; + + var login = function () { + loginButtonsSession.resetMessages(); + + var username = trimmedElementValueById('login-username'); + var email = trimmedElementValueById('login-email'); + var usernameOrEmail = trimmedElementValueById('login-username-or-email'); + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + + var loginSelector; + if (username !== null) { + if (!Accounts._loginButtons.validateUsername(username)) + return; + else + loginSelector = {username: username}; + } else if (email !== null) { + if (!Accounts._loginButtons.validateEmail(email)) + return; + else + loginSelector = {email: email}; + } else if (usernameOrEmail !== null) { + // XXX not sure how we should validate this. but this seems good enough (for now), + // since an email must have at least 3 characters anyways + if (!Accounts._loginButtons.validateUsername(usernameOrEmail)) + return; + else + loginSelector = usernameOrEmail; + } else { + throw new Error("Unexpected -- no element to use as a login user selector"); + } + + Meteor.loginWithPassword(loginSelector, password, function (error, result) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.closeDropdown(); + } + }); + }; + + var signup = function () { + loginButtonsSession.resetMessages(); + + var options = {}; // to be passed to Meteor.createUser + + var username = trimmedElementValueById('login-username'); + if (username !== null) { + if (!Accounts._loginButtons.validateUsername(username)) + return; + else + options.username = username; + } + + var email = trimmedElementValueById('login-email'); + if (email !== null) { + if (!Accounts._loginButtons.validateEmail(email)) + return; + else + options.email = email; + } + + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + if (!Accounts._loginButtons.validatePassword(password)) + return; + else + options.password = password; + + if (!matchPasswordAgainIfPresent()) + return; + + Accounts.createUser(options, function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.closeDropdown(); + } + }); + }; + + var forgotPassword = function () { + loginButtonsSession.resetMessages(); + + var email = trimmedElementValueById("forgot-password-email"); + if (email.indexOf('@') !== -1) { + Accounts.forgotPassword({email: email}, function (error) { + if (error) + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + else + loginButtonsSession.set('infoMessage', "Email sent"); + }); + } else { + loginButtonsSession.set('errorMessage', "Invalid email"); + } + }; + + var changePassword = function () { + loginButtonsSession.resetMessages(); + + // notably not trimmed. a password could (?) start or end with a space + var oldPassword = elementValueById('login-old-password'); + + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + if (!Accounts._loginButtons.validatePassword(password)) + return; + + if (!matchPasswordAgainIfPresent()) + return; + + Accounts.changePassword(oldPassword, password, function (error) { + if (error) { + loginButtonsSession.set('errorMessage', error.reason || "Unknown error"); + } else { + loginButtonsSession.set('inChangePasswordFlow', false); + loginButtonsSession.set('inMessageOnlyFlow', true); + loginButtonsSession.set('infoMessage', "Password changed"); + } + }); + }; + + var matchPasswordAgainIfPresent = function () { + // notably not trimmed. a password could (?) start or end with a space + var passwordAgain = elementValueById('login-password-again'); + if (passwordAgain !== null) { + // notably not trimmed. a password could (?) start or end with a space + var password = elementValueById('login-password'); + if (password !== passwordAgain) { + loginButtonsSession.set('errorMessage', "Passwords don't match"); + return false; + } + } + return true; + }; + + var correctDropdownZIndexes = function () { + // IE <= 7 has a z-index bug that means we can't just give the + // dropdown a z-index and expect it to stack above the rest of + // the page even if nothing else has a z-index. The nature of + // the bug is that all positioned elements are considered to + // have z-index:0 (not auto) and therefore start new stacking + // contexts, with ties broken by page order. + // + // The fix, then is to give z-index:1 to all ancestors + // of the dropdown having z-index:0. + for(var n = document.getElementById('login-dropdown-list').parentNode; + n.nodeName !== 'BODY'; + n = n.parentNode) + if (n.style.zIndex === 0) + n.style.zIndex = 1; + }; + + +}) (); diff --git a/packages/accounts-ui-unstyled/login_buttons_images.css b/packages/accounts-ui-unstyled/login_buttons_images.css new file mode 100644 index 0000000000..07e05215ba --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_images.css @@ -0,0 +1,21 @@ +/* These should be in their respective packages. https://app.asana.com/0/988582960612/1477837179813 */ + +#login-buttons-image-google { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAADCklEQVQ4jSXSy2ucVRjA4d97zvdNJpPJbTJJE9rYaCINShZtRCFIA1bbLryBUlyoLQjqVl12W7UbN4qb1gtuYhFRRBCDBITaesFbbI3RFBLSptEY05l0ZjLfnMvrov/Bs3gAcF71x6VVHTk+o8nDH+hrH89rUK9Z9Yaen57S3wVtGaMBNGC0IegWKIDxTtVaOHVugZVmH3HX3Zz+4l+W1xvkOjuZfPsspY4CNkZELEgEIJKwYlBjEwjec/mfCMVuorVs76R8+P0KYMmP30U2dT8eIZqAR2ipRcWjEYxGSCRhV08e04oYMoxYLi97EI9YCJ0FHBYbIVGDlUBLwRlLIuYW6chEmQt/rJO09RJjhjEJEYvJYGNhkbUhw43OXtIWDFRq9G87nAaSK6sVRm8r8fzRMWbOX2Xx7ypd7ZET03sQhDOz73DqSJOrd+7HSo4QIu0Nx/4rOzx+cRXZ9+z7+uqJ+3hiepxK3fHZT2tMjXYzOtzL6dmznPzhLexgN0QlxAAYxAlqUqRmkf5j59RlNQ6MFHhgcpCTTx8EUb5e+plD7x4jjg1ANCAgrRQAdR7xKXjBlGyLYi7PxaUmb8z8xcpGHVXLHaXdjI0egKyJiQYTEhSPREVIEUBNC+Mqm+xpz3j0njLPHB2nsh1QgeG+IS48dYbD5YNoo0ZUAbVEuTUoKuBSZOarX/WhyQn6eg2+usDWf0s0tq8zNPYk+WI/Lnge++hlvlyfQ3NdECzGRWKwEEA0qNY251n69kV6+Y0kbaCZoebG2X3oU7pKoyxuXOPe945zs9DCeosGIXoBDyaLdf6ce4Hbk+/Y299ksKtAuaeNsiyw8c1LKIZ95b0MdgxA5giixACpTxEPSau6QdFfI5/2cLPmEW+JAQrtJUJzDXF1dkwHzVodJMX4HFEcQQMaFdPeM0Jb/4PUtzzaLKAhRyJFwo6lbegRNFfk819muV5dR4JBQoQdQ2xFiDmSNDHiaptamR9Gq5cQ18AledrGDpOfeI5Lq8u88smbhMRisoSAgAYghdfn5H/JkHuRZ1owLAAAAABJRU5ErkJggg==); +} + +#login-buttons-image-facebook { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAq0lEQVQ4jWP8//8/AyWAhYGBgcEmauYZBgYGYxL1nj2yLN2ECcohVTNcDwsxKlXlhRm6yzwZRAS5GRgYGBhsombC5ZhwaUIGyJrRAVEuwGYzSS7AB/C64MiydKx8ZJfgNeDN+68MDAwIL8D4RLsgIHsJis0wPjKgOAyoE4hcnGwMGkpiBBUbacvA2TfuvaKiC759/3X23NUnOPMDtgTEwMBwloGBgYGR0uwMAGOPLJS9mkQHAAAAAElFTkSuQmCC); +} + +#login-buttons-image-weibo { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKySURBVDhPY2AgEpR5sjf/nS/6//UkoX+XJltuCvVxkcOp9cyZM1w/r13TuXvmDD9MkYIwg7qrNrubnzFb6J5intPHqrnvCnIwyKIYsmrVKuaFYWEFW2Sk79zX0f6/REHhKFABC0zRsky+rXMSeZdKCTLIHqgUvLAknW8L3IAQDw/RFlbWnQ801P+DNN8D4n0qyk94GRiEjTg5Lbz4+YOCdbhjVmTxbZwex7PUW58t8O1Ukf9gA2IDAoRPWFudfayt9f+mpsb/6yrK/28qKf4/ISf7YZu83K07QMNe6On9nyWusMtVm813azH/UWctZo/vc8TABjB3CApufAzSqKjw/7apyf+nMdH/XxUX/X+RnfX/qY/3/5tqqv/vq6v936KsfB2onltaiEHGx5AteFep4EmGUEHB1Adamv9v6er8fztp0v//79////nr1/+3X778B4N///5/O3jw/0N39//nlBQ/louLd4MMAWImcPhsU1G6DfLvt717wepnz537X0FB4T8fL+//AH///2/evgWL/7l///9dE+P/b4AWTZSWXg/UzAj2/w2gs59mZYEV7d+//z8rE9N/JUXF/w62tiD//a+urIS4BAgeA712Cxg2F40M36alpXGBDTgmI/3hdUU5WEFjff3/wvx8MNvcxARsQE1VFUQ30Et37Oz+P1RV+b/J0nIjUATigmgBvtzH5mb//9++/f/mkyf/A4KC/nv7+oI1W1hb/3/1+fP//9+//39ekP//CVDzTlnZxxtnz1ZBSUDeDAyZh7W13nybOeP/7W1b/09rbf2/FhgWHy9c+P912bL/D11d/l+WEP8/SUR4Ox8DA6pmmEkpHh4ya0JCim4lJGx7kZp8821CwrN7Hh4Pr7m6nDoSET61PjDQichsA3T7//+s/16/5gXSkIAa1AAAh8dhOVd5xHAAAAAASUVORK5CYII=); +} + +#login-buttons-image-twitter { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAByklEQVQ4jaVTz0sbQRh92V10l006GaKJCtEtmqMYU0Qpwqb4B6zgXdT0WEr7B0ih4MGLP05CUWMvHkQwglhvGhsvKmJOBhTUQjWU2slilKarrAfdZROTQ8m7fPMx33szb75vXKZpohpwVbEBCNaCMUYopXppAWOMxDNsOPf3H1WIeDoSURYYYwQAKKW6y7KgLe2vam11KyMRZcEpEP6SOkwbUgc4ATAKUF8YW2fXhZejvaHPsc7gvH2DnCfQGEtdxrd/5NRJteUDpVTf+5kLp2WlA6JsCyZv9ChplPKdTfJZkYWhEF3bvnV3fb36NZSY3dP6Q/5V4hFvIAaKPckE8W5pLBIQdwHAthBdPtpJuhpeAwDu74DrP4/R1/Ts4cwBWg/gN+DowoSqTBPezAMAeAHw+suSw4Q7schFApF6af19a+2yLVIB7xR+0Zk75yCveu82FMnMViKHCXcSa3PPVBJAX5BszL2SP2kNwvdy5M1e+S2AogME4HFYPibPpxKZC03nRAp/M+Dx2UWDzTXfpttrx72ikCoVtrrAAwgdXBk9iazxxtpskfhs1O86aHXXpAEcA7ivJGDBDcDnyAsA2FMsi1KB/0bVv/EBBBSY9mZ7PAsAAAAASUVORK5CYII=); +} + +#login-buttons-image-github { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9wJGBYxHYxl31wAAAHpSURBVDjLpZI/aFNRFMZ/973bJqGRPopV4qNq/+SpTYnWRhCKilShg9BGcHOM+GfQoZuLk4iLgw4qZNBaHLuIdBNHl7Ta1qdNFI3SihnaNG1MpH3vuiQYQnwZvHCG893zffc751z4z6PX5T5gA1DAKnAaOAQEgAfAVeCpl+CeCrlRuEC6maO4h0A1wl4tPAHMqNUthvrDdHYY7A3t4rDVjeO6rBU2FaABM1WCrBNoi48Mi+nH9yj+KtPibAKwJXfQ5vcRG7soUnYmWEuQgAEIYBv4cGpoILI0Z4tyYYPegS6UguyijZQ6J45GSNmZHzUcJYD2ii2Ajv7efZ8WZ6ZwXFj79hXpayW4O0SL1Nl/8jzZlZ9dQLFS70pgvZKIyGD0yvu5eRmMnrk1PjI81ir1qBACTdPevXj95mVuNX8XKDQc/+T334bZZ104cvzYw2s3J3qAL5WXSsDbf61NNMBu+wOBs+VSyQ84Nfhg028ZGx3/qyy0lC7lgi7lghBitoon03lvB8l0/k7Wnk+8mny0cyXzEcfZxgwfZPTyRMHsOzAFXE9YhtNQIJnOx4FpJXT1eSkn2g0frqMoFrfoCXcqlCOAGwnLuO/l4JymcWl5uRxzXUKghBAiZ5r+WaV4lrCM555zqO+x2d0ftGmpiA/0k70AAAAASUVORK5CYII=); +} diff --git a/packages/accounts-ui-unstyled/login_buttons_session.js b/packages/accounts-ui-unstyled/login_buttons_session.js new file mode 100644 index 0000000000..f55ad6c737 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_session.js @@ -0,0 +1,62 @@ +(function () { + var VALID_KEYS = [ + 'dropdownVisible', + + // XXX consider replacing these with one key that has an enum for values. + 'inSignupFlow', + 'inForgotPasswordFlow', + 'inChangePasswordFlow', + 'inMessageOnlyFlow', + + 'errorMessage', + 'infoMessage', + + 'resetPasswordToken', + 'enrollAccountToken', + 'justVerifiedEmail', + + 'configureLoginServiceDialogVisible', + 'configureLoginServiceDialogServiceName', + 'configureLoginServiceDialogSaveDisabled' + ]; + + var validateKey = function (key) { + if (!_.contains(VALID_KEYS, key)) + throw new Error("Invalid key in loginButtonsSession: " + key); + }; + + var KEY_PREFIX = "Meteor.loginButtons."; + + // XXX we should have a better pattern for code private to a package like this one + Accounts._loginButtonsSession = { + set: function(key, value) { + validateKey(key); + Session.set(KEY_PREFIX + key, value); + }, + + get: function(key) { + validateKey(key); + return Session.get(KEY_PREFIX + key); + }, + + closeDropdown: function () { + this.set('inSignupFlow', false); + this.set('inForgotPasswordFlow', false); + this.set('inChangePasswordFlow', false); + this.set('inMessageOnlyFlow', false); + this.set('dropdownVisible', false); + this.resetMessages(); + }, + + resetMessages: function () { + this.set("errorMessage", null); + this.set("infoMessage", null); + }, + + configureService: function (name) { + this.set('configureLoginServiceDialogVisible', true); + this.set('configureLoginServiceDialogServiceName', name); + this.set('configureLoginServiceDialogSaveDisabled', true); + } + }; +}) (); diff --git a/packages/accounts-ui-unstyled/login_buttons_single.html b/packages/accounts-ui-unstyled/login_buttons_single.html new file mode 100644 index 0000000000..a11dc82d20 --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_single.html @@ -0,0 +1,11 @@ + diff --git a/packages/accounts-ui-unstyled/login_buttons_single.js b/packages/accounts-ui-unstyled/login_buttons_single.js new file mode 100644 index 0000000000..d7aafb3c5e --- /dev/null +++ b/packages/accounts-ui-unstyled/login_buttons_single.js @@ -0,0 +1,48 @@ +(function () { + // for convenience + var loginButtonsSession = Accounts._loginButtonsSession; + + Template._loginButtonsLoggedOutSingleLoginButton.events({ + 'click .login-button': function () { + var serviceName = this.name; + loginButtonsSession.resetMessages(); + var callback = function (err) { + if (!err) { + loginButtonsSession.closeDropdown(); + } else if (err instanceof Accounts.LoginCancelledError) { + // do nothing + } else if (err instanceof Accounts.ConfigError) { + loginButtonsSession.configureService(serviceName); + } else { + loginButtonsSession.set('errorMessage', err.reason || "Unknown error"); + } + }; + + var loginWithService = Meteor["loginWith" + capitalize(serviceName)]; + + var options = {}; // use default scope unless specified + if (Accounts.ui._options.requestPermissions[serviceName]) + options.requestPermissions = Accounts.ui._options.requestPermissions[serviceName]; + + loginWithService(options, callback); + } + }); + + Template._loginButtonsLoggedOutSingleLoginButton.configured = function () { + return !!Accounts.loginServiceConfiguration.findOne({service: this.name}); + }; + + Template._loginButtonsLoggedOutSingleLoginButton.capitalizedName = function () { + if (this.name === 'github') + // XXX we should allow service packages to set their capitalized name + return 'GitHub'; + else + return capitalize(this.name); + }; + + // XXX from http://epeli.github.com/underscore.string/lib/underscore.string.js + var capitalize = function(str){ + str = str == null ? '' : String(str); + return str.charAt(0).toUpperCase() + str.slice(1); + }; +}) (); \ No newline at end of file diff --git a/packages/accounts-ui-unstyled/package.js b/packages/accounts-ui-unstyled/package.js new file mode 100644 index 0000000000..4defc581ad --- /dev/null +++ b/packages/accounts-ui-unstyled/package.js @@ -0,0 +1,23 @@ +Package.describe({ + summary: "Unstyled version of login widgets" +}); + +Package.on_use(function (api) { + api.use(['accounts-urls', 'accounts-base', 'underscore', 'templating'], 'client'); + + api.add_files([ + 'accounts_ui.js', + + 'login_buttons_images.css', + 'login_buttons.html', + 'login_buttons_single.html', + 'login_buttons_dropdown.html', + 'login_buttons_dialogs.html', + + 'login_buttons_session.js', + + 'login_buttons.js', + 'login_buttons_single.js', + 'login_buttons_dropdown.js', + 'login_buttons_dialogs.js'], 'client'); +}); diff --git a/packages/accounts-ui/login_buttons.less b/packages/accounts-ui/login_buttons.less new file mode 100644 index 0000000000..067ebb33c0 --- /dev/null +++ b/packages/accounts-ui/login_buttons.less @@ -0,0 +1,317 @@ +#login-buttons .login-header { + float: left; + padding-right: 2px; + line-height: 1.5; + font-family: 'Helvetica Neue', Helvetica, Arial, default; +} + +#login-buttons .loading { + width: 24px; + height: 24px; + background-image: url(data:image/gif;base64,R0lGODlhGAAYAKUAAAQGBISGhMTGxERGRKSmpOTm5CQmJGRmZJSWlNTW1DQ2NLS2tPT29BQWFHR2dFxeXIyOjMzOzExOTOzu7CwuLJyenNze3Dw+PLy+vBweHKyurGxubPz+/ISChAwODIyKjMzKzExKTKyqrOzq7CwqLGxqbJyanNza3Dw6PLy6vPz6/BwaHHx6fJSSlNTS1FRSVPTy9DQyNKSipOTi5ERCRMTCxCQiJP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJBgA3ACwAAAAAGAAYAAAG/sCbcHhj1CqQD0IkmKmI0CHMJHk9So5Aq6LBOKPDSEhBk1yzW1GqlmCAFyQS2dyRiUT2hcB1chNBGTZyEiIjRCoWNQIRJwVPQjAUKxkkDwVgNyonERYzExxCHR4NNhIwmEIcM40TTzANsCQnqEOIBRMMHCIAHisOtEQwnjAcB7wrEcC1tyoqKLw2j8o3IyMMKgYADQrTQxO42Lwx3UITMDDO0Kfd1dfGvTXdKp7Xu70H3RO3uTArvTYClCEaBkoUqQszaGli9SjSJBIhkoGBEYHRDGJDAAlSMCCACz8qRkRIsaeRNCELDMihYWWDEhNcSHJaB0XMnDMdtmhY0wYVFgwEL8xg0VJBhJeTmIyY+KCEyZcoQQAAIfkECQYAPAAsAAAAABgAGACFFBIUjIqMTE5MxMbENDI0rKqsbG5s5ObkJCIknJqcXF5c1NbUREJEvLq8fH589Pb0HBoclJKUVFZUzM7MPDo8tLK0dHZ07O7sLCospKKkZGZk3N7cTEpMxMLEhIaE/P78FBYUjI6MVFJUzMrMNDY0rK6sdHJ07OrsJCYknJ6cZGJk3NrcREZEvL68hIKE/Pr8HB4clJaUXFpc1NLUPD48tLa0fHp89PL0LC4spKakbGps5OLk////AAAAAAAAAAAABv5AnnDIe1QcCpHMEBu8iNDhzYNDYAgUhkCkMNUe0eGIAEGgCCQaRyBT6SyRXbgCAcFQOIrC4bFpdCY2HhEbUCMgdigUMSdEDwMeLgExGY1CNzggZQJyYQ8lEQk5NU88LgB2AjdhQx8NKQUVKzw3MHYYs6xDLyUVNQMfJZoIDrpQGzUtIycmiTPGRC8tHSM7HBAoJKXQQhMjEzsUZgLcRCsTCzs0eOTlQiszKwcSVwxg7ivpJw5oNBPuLxZsOHCjBgE1HtydWLHjwIsbHBhwkPAM2osVAy984JGChRIDllh92DDwxL0HGiS4cVDI0wqGBDe+U6HBgA0XFXaU+nBjQzq8jNu6mTDBJ0KMAsk6DEC3YceJoEM2hBhECWmDASPiOYUarUOCFDlKNFA2AeMNmcZebJiAtWzMMEEAACH5BAkGADwALAAAAAAYABgAhRQSFIyKjExOTMTGxDQyNKyqrGxubOTm5CQiJJyanFxeXNTW1ERCRLy6vHx+fPT29BwaHJSSlFRWVMzOzDw6PLSytHR2dOzu7CwqLKSipGRmZNze3ExKTMTCxISGhPz+/BQWFIyOjFRSVMzKzDQ2NKyurHRydOzq7CQmJJyenGRiZNza3ERGRLy+vISChPz6/BweHJSWlFxaXNTS1Dw+PLS2tHx6fPTy9CwuLKSmpGxqbOTi5P///wAAAAAAAAAAAAb+QJ5wyLuVTBwaQ+JoPIjQ4c0FA0EgMBSGQGClnlHhCAewwhAYHIGk5GhW4QpofsYIDCYVi8URSVQTUCNVWAQRB0Q3DRoyChomG1IEIGcCO2FFMToGFiEvQh4wlTeYQh8pNi4uHTwPGAgoBHClQg8RHiEpLxWwOB60RDMRCSkbDlskM8BDDykZOSMKayyfy0I1BSUDIiQMCtZDIxUNAxJ8KuBCEw0tMwZ9CmDWIwMjCzEijQvgL/UzBwMUqNCRAtyOCTMW3HhgQocJG5GAPViwYMWODzxqWHDgIsIFWh9WrNiwg1SrCC4ChMiAKMyLDSN3nMAo5ECIGM6gnaj24cE2gZgHdkLZ8CxbjQYdECaMeUJolBM1xrWoh3AByaAXqrncMICq1R0yLzygSevFhQNgw74gSyQIACH5BAkGADgALAAAAAAYABgAhQQGBISGhMTGxERGRKSmpOTm5CQmJGRmZJSWlNTW1FxaXLS2tPT29DQ2NBQWFHR2dIyOjMzOzExOTOzu7GxubJyenNze3Ly+vDw+PBweHKyurCwuLGRiZPz+/AwODIyKjMzKzExKTKyqrOzq7CwqLGxqbJyanNza3FxeXLy6vPz6/Dw6PBwaHISChJSSlNTS1FRSVPTy9HRydKSipOTi5MTCxERCRCQiJP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAb+QJxwiIuJDisDaXUQxYjQYazlAHgAWCuA1ZpEh6CNx+Epj83lDei7yLAcDhbLpuDYbm95ZgGNkG4ZNxsQBUQxMyuBNwYRUiEkkDA0X0UUkBshTzgIDZ0KDJRCHQ+dDSY4DDA2NgMWoVIKqxIMNRISMAivRAu3EjUVKCgKJ7pDDBwKKCYfJSUUHcVDzAcfHw8PH9FDM9cfCAEBLtpCIuAIIi4uCCrjBOkiAhUVM4XRDDPyAjQiGvDaCfxE0FBxIUWKBV50qTCY4gI7CwIiggAVSkWEiDVc4ejwomOEBJqiqDjxIsKLBNCEMDjBkmUBBik7qBhxIoEFlhSNFaBRoCcrzxEjdvb0mZOIiglIJ8QYMYFp06TsKMmMwYAqg6tYVaR81UGmiq9fu34JAgAh+QQJBgA8ACwAAAAAGAAYAIUUEhSMioxMTkzExsQ0MjSsqqxsbmzk5uQkIiScmpxcXlzU1tREQkS8urx8fnz09vQcGhyUkpRUVlTMzsw8Ojy0srR0dnTs7uwsKiykoqRkZmTc3txMSkzEwsSEhoT8/vwUFhSMjoxUUlTMysw0NjSsrqx0cnTs6uwkJiScnpxkYmTc2txERkS8vryEgoT8+vwcHhyUlpRcWlzU0tQ8Pjy0trR8enz08vQsLiykpqRsamzk4uT///8AAAAAAAAAAAAG/kCecMh7NBwSBo1jKt2I0OEjxSERMCgEDAJCuJ5R4UrDUZJIOIyWC8CNwhOVTMRhsWQmgwynBfkrUDsmGgqFBSdENzk0CFwQb0IPIRYmOi6IYRc6awRgAwEONiEPYUMfm1sePC8pEQEemKVCFyxZOA8bKQkRDbJQBWkIFRMFGQkHvok0aQ4DFQU5H8lEGhQ4CiM1Nb3TQy40FCITHS0d3d5lEisjHR0v5zwWAgIGBxMTAxfnFxoyEhE3VsyYsOJcAw0qFAz4sGPFghlgfF1wkccEqQsHNjh8J+tBBgcOTNQQ8uHEDo0rSGUqECGEhwgqV504cHLFjhvSVl2YUSBFOqsIO6C8wJjR4YyBAxrUKAEtxYYwQ2eeXLBgArsWDSrUiBXlw9ADNI2OGDBgBUdfXm+APXngwlkiQQAAIfkECQYAPAAsAAAAABgAGACFFBIUjIqMTE5MxMbENDI0rKqsbG5s5ObkJCIknJqcXF5c1NbUREJEvLq8fH589Pb0HBoclJKUVFZUzM7MPDo8tLK0dHZ07O7sLCospKKkZGZk3N7cTEpMxMLEhIaE/P78FBYUjI6MVFJUzMrMNDY0rK6sdHJ07OrsJCYknJ6cZGJk3NrcREZEvL68hIKE/Pr8HB4clJaUXFpc1NLUPD48tLa0fHp89PL0LC4spKakbGps5OLk////AAAAAAAAAAAABv5AnnDIe4xiJplE4ajdiNDho2FSSAQMGgGHwAWeUeEhYjFoZCIOg0LAIECkUXgT8ThMOo3FrqHhUAgQEBVQJxkxIS4uDRdENwUsCDAgEHJCLzU5KREZjWE3BoEgOGAbNQUZJS9hQy86MBAALjwfAw0VBZ6sQjc0kwg3FyMtNTO7UDmiJTsTAy1gx7xcICY7MyMDH9FEMpIcOwsTxttDBm4UBxsz4+Q8Fn80JzsrM9rtCiQ4EjfpC9DRF9YQcPBA3oYd7XJwoEGiwYcLJzaseLDthAIsHJ4UTLdi1a4HLhSkSSHkww2DK/4RORBChwoJGiheunAA3IwVFzx+ONHAgz4NExo0rIDy4gTHCSM6tGhQIEGMAC4smJgQpui8cM5OHQrhIcKGXSbnXevQoESBFBk6yDxm8kA4bBN2rCUSBAAh+QQJBgA2ACwAAAAAGAAYAIUMCgyEhoTExsRMSkzk5uSkpqQsKixsbmyUlpTU1tRcWlz09vQ8OjwcGhy0trR8enyMjozMzszs7uycnpzc3txkYmREQkQkIiRUUlSsrqw0MjR0dnT8/vy8vrwUEhSMiozMyszs6uysqqwsLix0cnScmpzc2txcXlz8+vw8PjwcHhy8uryEgoSUkpTU0tT08vSkoqTk4uRkZmRERkQkJiRUVlT///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG/kCbcGhDxQSZEgQyESyI0CEn1snAWoHHoYLBlF7R4cIkWFkR2YMCM2PMIuFFzFXOFEQi2IfbHhkcUCgEJnQdJmBiDgoMIxcXIFIvBBQuLk9hLxuNDSOIKBKTJhxhUiQXDR4sNhwLkhQopEQSDA0ADRKsLzEhsVAFqAAFrBIhl71CLyoeADIonwSjx0MDywy5vNJDJ8sqw9jZNtsANM6g0dkWAADWL5LGxyHKACe5u+AQtcEcKCGT76QxRqC6VQQUBRP/oLyoQaNWACEcJBGKIIEUhRObOg0RRKkMCAKwilAoMYCBgQsqIBGRQ8cMDAQIPmw4gcECoz9xEoBwGXODGho2GgbAIWWkypU0NDEgSBjFCJISH5g4CRMEACH5BAkGADsALAAAAAAYABgAhRQWFIyOjFRSVMzKzDQ2NKyurHRydOTm5CQmJJyenGRiZNza3ERGRLy+vISChPT29BweHJSWlFxaXNTS1Dw+PLS2tHx6fOzu7CwuLKSmpGxqbOTi5ExOTMTGxIyKjPz+/BwaHJSSlFRWVMzOzDw6PLSytHR2dOzq7CwqLKSipGRmZNze3ExKTMTCxISGhPz6/CQiJJyanFxeXNTW1ERCRLy6vHx+fPTy9DQyNKyqrGxubP///wAAAAAAAAAAAAAAAAb+wJ1wuPvcDotJZzDavIjQoXGzmA1ajVIukeo8o8LXabOajTqNSiGVCHgCG/DrclglB8xBJRXyOEwmI1BzB1QzBw9ELxMhNgYaKgtSNyd1K19RDwk6ChIqiTsPdCtOYFIRMiIsCUVHVKCmQg8aHAwsD2JkJ7FQFSwUJDVHdrC8OzcsJBg2F4YfxkQqOBgidTMT0EQmOAg0VCPY2UI6KDAkGxMDXuI7AggQLAdnDbvZJxgwIDoXVxUD4iFgQABQ4EWHGloOQDuAYyCMGztWqMmQ4gKvGwIggADgIEyNDAkiRFhhaoUAgQAwQBRyIkUMNxYSzPjyYIYHHAJBgOgAZUU/BA8WTHBSoEIGCwIYUGgEUQLMgRAmDBQVwIIGCRzlIBDgaepBDQMyJNSigBQBBhcreT0YEMGABBEybJQoNiQIACH5BAkGADwALAAAAAAYABgAhRQSFIyKjExOTMTGxDQyNKyqrGxubOTm5CQiJJyanFxeXNTW1ERCRLy6vHx+fPT29BwaHJSSlFRWVMzOzDw6PLSytHR2dOzu7CwqLKSipGRmZNze3ExKTMTCxISGhPz+/BQWFIyOjFRSVMzKzDQ2NKyurHRydOzq7CQmJJyenGRiZNza3ERGRLy+vISChPz6/BweHJSWlFxaXNTS1Dw+PLS2tHx6fPTy9CwuLKSmpGxqbOTi5P///wAAAAAAAAAAAAb+QJ5wyPu8bqfDbne4fIjQ4edxSW42i9lkMNi8osPj6bTbrLLcTqPSOIFf1cN1sTJPOrVKIZfZQF9JZRsnX2EbFRkJMTE7UoFXhVEPDTEhHhEPQjdldZFgHyUeDjY1RWULC5lgRC8BJgYmDzdnM36rUAM6GgojBwtbN7dQDzoyEjErIwMdT8JENiICJnctHc5QHhwsMiMNNQ3XRA4MNCIDFSUFns4KFAQKEwUZKY3hJxQ4ODYbiTEV4Txi5ENR40WKEJZWXDtAAQUCHMEGBHBhw4WbWzckOIQQQMiLEBZeWViwaoUABDAgkAgmZIcJDRpkyPAwQdWLGQ4wIIAAAsI8ACgTYkoQwIEBCxEcSOhM2fNflBUatDFwhwODQxggAOAYcetBiqQEcKBAwRSBiwvXJjmQQI6DiRIsoQQBACH5BAkGADIALAAAAAAYABgAhQwKDISGhMTGxERGRKSmpOTm5CwqLGRmZJSWlNza3LS2tPT29Dw6PHx6fBwaHFxeXMzOzOzu7DQyNGxubJyenLy+vIyOjFRSVKyurOTi5Pz+/CQiJBQSFIyKjMzKzExKTKyqrOzq7CwuLGxqbJyanNze3Ly6vPz6/ERCRHx+fGRiZNTS1PTy9DQ2NHRydKSipMTCxCQmJP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAb+QJlwKNMYj6cjcTnUnBYnFlT6hGqYw1OEFdl2uV7uCbsomEOZQmgbMp8Li+UikyjVWeMmi14v5YsJK4IJf0snGYIrhEIZAo4rV1hCGgmOAhkyJxUmFRUskkQnMJsVJwUYqCugSyWoGAUCL7Ihq6EEsgIgCAgkhbUYuyAkAQEItUsYHQEkHQ0NHcdELy7PFiMjLnHRMgETIxYUD+Kq0REq4hQwF+vQ0QTrFzALFwP1EMcF9AMXcSQtDAw+YALF4gGDfySEsPggwoAIFAIkJbggouKHT0IgGNjAMcYBeUIWrGgQIwZHA/eIKNjgoGXLDS1axHD5UgEWASI4AOCgs+cfzp0iIkpikcIBgKNIjzpIEeFYBAIHZMZocYCAtiVBAAAh+QQJBgA8ACwAAAAAGAAYAIUUEhSMioxMTkzExsQ0MjSsqqxsbmzk5uQkIiScmpxcXlzU1tREQkS8urx8fnz09vQcGhyUkpRUVlTMzsw8Ojy0srR0dnTs7uwsKiykoqRkZmTc3txMSkzEwsSEhoT8/vwUFhSMjoxUUlTMysw0NjSsrqx0cnTs6uwkJiScnpxkYmTc2txERkS8vryEgoT8+vwcHhyUlpRcWlzU0tQ8Pjy0trR8enz08vQsLiykpqRsamzk4uT///8AAAAAAAAAAAAG/kCecMj7XA673eF0e3yI0OFrMxiNZovNZnlxRoenWq3Rqk4Wq83uxHx9N5lcqVJrTdKbVZrrJh5iMSkZJSsPRC8XO2pLfTwPEQEBESU3XzwvB4snTzw1Fg4uGYaWRTtpO5UvJgYmDiekQx9oKzsfAwoaOgWwRCdnKzcxMjIqG7xSIxMzByYcIiqNxxNVKzIsLBrHRBMtHRMiNAwK2kMjFQ23BCQso9o1BRUDNjjqI+Q3ggUTFSgYOBbkBkRIkGLDDRwIUOAYcOxGpBAp3HiAgAABjR2wHoSw4SAAQx43SICAgYDCxygHXOgwYSFEoxEQQECAgcLAgFEvFggjpsGEOzEiNWLORICBAAcBLBgcFSFBw4QvI3AAkEnTHwkKFFhw0LCC1A0XCKgmLEqCQ4p2Xks0CyfBQQO0Q4IAACH5BAkGADwALAAAAAAYABgAhRQSFIyKjExOTMTGxDQyNKyqrGxubOTm5CQiJJyanFxeXNTW1ERCRLy6vHx+fPT29BwaHJSSlFRWVMzOzDw6PLSytHR2dOzu7CwqLKSipGRmZNze3ExKTMTCxISGhPz+/BQWFIyOjFRSVMzKzDQ2NKyurHRydOzq7CQmJJyenGRiZNza3ERGRLy+vISChPz6/BweHJSWlFxaXNTS1Dw+PLS2tHx6fPTy9CwuLKSmpGxqbOTi5P///wAAAAAAAAAAAAb+QJ5wyHttJoPRZHW4fYjQ4auTyhQqtc5ottpdnlHhJuSJxKy1RnKx2Z1e4YnJ5ghEUrXBZKJkH95QKxoacwEDN0QvJwtdBxdwQg8aMioaIRdhPB87XSdfQikcIjIOkJmbG38PPA8cDBwyB5mJbY4fDSQ0LDGzUBedLw44FAwLvUQfK20XEhgELKbHPCvKFwwoOCLSRBszqRQIKBzbQwszCzscMCgEq+R8MzsGEOsD5C9qIwclABAIBuQWNGgx4MINGCDqSbtRooIaOB76waCwo9eDElYqbBByA0c/BDTshTmRIUKCHDVMjQCREAEOEyPcGSngwEWIMyeg1IBADxs3CRYKNKhQocOEgzIbo4wg4M8ZMRajNBiwEEFWphsecCBwyoKDBAUmarizWMGBAgkyDMQYMJZIEAA7); +} + +@login-buttons-accounts-dialog-width: 178px; + +#login-buttons { + display: inline-block; + /* Achieve `display: inline-block` on a block element in IE 7; see + http://uncorkedstudios.com/2011/12/12/how-to-fix-the-ie7-and-inline-block-css-bug/ + */ + *display: inline; + zoom: 1; +} + +#login-buttons .login-button, .accounts-dialog .login-button { + float: left; + cursor: pointer; + padding: 1px 4px; + height: 20px; + + font-size: 80%; + font-family: 'Helvetica Neue', Helvetica, Arial, default; + line-height: 1.5; + color: white; + text-shadow: #062C50 0px 1px 0px; + + text-align: center; + + background: #79859D; + border: 1px solid #062C50; + border-top-color: #103372; + border-left-color: #103372; + border-radius: 3px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -o-border-radius: 3px; +} + +#login-buttons .login-header { + margin-right: 4px; +} + +#login-buttons .login-button-disabled, .accounts-dialog .login-button-disabled { + color: #ccc; +} + +#login-buttons .configure-button { + background: red; +} + +#login-buttons .login-link-text { + margin-left: 5px; /* so that other elements aren't too close */ +} + +.accounts-dialog * { /* precendence of this selector is significant */ +/* +A base for our dialog CSS, to reset browser styles and protect against +the app's CSS. Dialogs include the dropdown, config modals, and the +reset password modal. We can't completely isolate the dialogs from +the app's CSS, and that isn't the goal because the app can style them. +This rule is a compromise that should take precedence over some very +broad rules but be overridden by more specific ones. + +Add more directives here if they help the dialogs look good +out-of-the-box in more apps. +*/ + + padding: 0; + margin: 0; + line-height: inherit; + color: inherit; + font: inherit; +} + +.accounts-dialog .login-button { + width: @login-buttons-accounts-dialog-width; + margin-bottom: 4px; +} + +#login-buttons .login-image { + float: left; + margin: 2px 5px 2px 0px; + width: 16px; + height: 16px; +} + +#login-buttons .no-services { + color: red; +} + +#login-buttons .login-link-and-dropdown-list { + right: 5px; + position: relative; +} + +#login-buttons a, .accounts-dialog a { + cursor: pointer; + text-decoration: underline; +} + +#login-buttons .login-close-text { + float: left; + position: relative; + left: 1px; /* = #login-buttons.border-width */ + padding-bottom: 3px; +} + +.login-buttons-dropdown-hangs-left #login-buttons .login-close-text { + float: right; +} + +#login-buttons .login-close-text-clear { + clear: right; +} + +@login-buttons-accounts-dialog-padding-left: 8px; + +.accounts-dialog { + border: 1px solid #666; + z-index: 1000; + background: white; + + -moz-box-shadow: 0 3px 6px 1px rgba(0, 0, 0, 0.3); + -webkit-box-shadow: 0 3px 6px 1px rgba(0, 0, 0, 0.3); + box-shadow: 0 3px 6px 1px rgba(0, 0, 0, 0.3); + + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; + + margin-top: -5px; + padding-top: 4px; /* = border-width - margin-top */ + + margin-right: -8px; + padding-right: 8px; /* = -margin-right */ + + padding-left: @login-buttons-accounts-dialog-padding-left; + padding-bottom: 8px; + + margin-left: 0; + margin-bottom: 0; + + width: @login-buttons-accounts-dialog-width + 9; /* not sure what this 9 is */ + + font-size: 16px; + color: #333; + line-height: 1.6; +} + +#login-dropdown-list { + position: absolute; + top: 0px; +} + +.login-buttons-dropdown-hangs-left #login-dropdown-list { + right: 0px; +} + +#login-buttons .hline { + text-decoration: line-through; +} + +#login-buttons .or { + text-align: center; +} + +#login-buttons .hline { + color: lightgrey; +} + +#login-buttons .or-text { + font-weight: bold; +} + +.accounts-dialog label, .accounts-dialog .title { + font-weight: bold; + font-size: 80%; +} + +.accounts-dialog input { + width: @login-buttons-accounts-dialog-width + 4; +} + +.accounts-dialog .login-button-form-submit { + margin-top: 8px; +} + +.accounts-dialog .message { + font-size: 80%; + margin-top: 2px; +} + +#login-buttons-message-dialog .message { + /* we intentionally want it bigger on this dialog since it's the only thing displayed */ + font-size: 100%; +} + +.accounts-dialog .error-message { + color: red; +} + +.accounts-dialog .info-message { + color: green; +} + +.accounts-dialog .additional-link { + font-size: 60%; +} + +.accounts-dialog #login-buttons-cancel-reset-password { + float: right; +} + +.accounts-dialog #login-buttons-cancel-enroll-account { + float: right; +} + +#login-buttons #signup-link { + float: right; +} + +#login-buttons #forgot-password-link { + float: left; +} + +#login-buttons #back-to-login-link { + float: right; +} + +.accounts-centered-dialog { + font-family: 'Helvetica Neue', Helvetica, Arial, default; + + z-index: 1000; + position: fixed; + + left: 50%; + margin-left: -(@login-buttons-accounts-dialog-width + + @login-buttons-accounts-dialog-padding-left) / 2; + + top: 50%; + margin-top: -40px; /* = approximately -height/2, though height can change */ +} + +@configure-login-service-dialog-width: 530px; +#configure-login-service-dialog { + width: @configure-login-service-dialog-width; + margin-left: -(@configure-login-service-dialog-width + + @login-buttons-accounts-dialog-padding-left) / 2; + margin-top: -180px; /* = approximately -height/2, though height can change */ +} + +#configure-login-service-dialog .login-button-configure { + float: right; +} + +#just-verified-dismiss-button, #messages-dialog-dismiss-button { + margin-top: 4px; +} + +.hide-background { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 999; + + /* XXX consider replacing with DXImageTransform */ + background-color: rgb(0.2, 0.2, 0.2); /* fallback for IE7-8 */ + + background-color: rgba(0, 0, 0, 0.7); +} + +#configure-login-service-dialog table { + width: 100%; +} + +#configure-login-service-dialog .configuration_labels { + width: 30%; +} + +#configure-login-service-dialog .configuration_inputs { + width: 70%; +} + +#configure-login-service-dialog input { + width: 100%; + font-family: "Courier New", Courier, monospace; +} + +#configure-login-service-dialog ol { + margin-top: 10px; + margin-bottom: 10px; +} + +#configure-login-service-dialog .new-section { + margin-top: 10px; +} + +#configure-login-service-dialog ol li { + margin-left: 30px; +} + +#configure-login-service-dialog .url { + font-family: "Courier New", Courier, monospace; +} + diff --git a/packages/accounts-ui/package.js b/packages/accounts-ui/package.js new file mode 100644 index 0000000000..4557c75bb2 --- /dev/null +++ b/packages/accounts-ui/package.js @@ -0,0 +1,10 @@ +Package.describe({ + summary: "Simple templates to add login widgets to an app." +}); + +Package.on_use(function (api) { + api.use('accounts-ui-unstyled', 'client'); + api.use('less', 'server'); + + api.add_files(['login_buttons.less'], 'client'); +}); diff --git a/packages/accounts-urls/package.js b/packages/accounts-urls/package.js new file mode 100644 index 0000000000..e1fd55e3b9 --- /dev/null +++ b/packages/accounts-urls/package.js @@ -0,0 +1,9 @@ +Package.describe({ + summary: "Generate and consume reset password and verify account URLs", + internal: true +}); + +Package.on_use(function (api) { + api.add_files('url_client.js', 'client'); + api.add_files('url_server.js', 'server'); +}); diff --git a/packages/accounts-urls/url_client.js b/packages/accounts-urls/url_client.js new file mode 100644 index 0000000000..0d244506ec --- /dev/null +++ b/packages/accounts-urls/url_client.js @@ -0,0 +1,47 @@ +(function () { + if (typeof Accounts === 'undefined') + Accounts = {}; + + // reads a reset password token from the url's hash fragment, if it's + // there. if so prevent automatically logging in since it could be + // confusing to be logged in as user A while resetting password for + // user B + // + // reset password urls use hash fragments instead of url paths/query + // strings so that the reset password token is not sent over the wire + // on the http request + var match; + match = window.location.hash.match(/^\#\/reset-password\/(.*)$/); + if (match) { + Accounts._preventAutoLogin = true; + Accounts._resetPasswordToken = match[1]; + window.location.hash = ''; + } + + // reads a verify email token from the url's hash fragment, if + // it's there. also don't automatically log the user is, as for + // reset password links. + // + // XXX we don't need to use hash fragments in this case, and having + // the token appear in the url's path would allow us to use a custom + // middleware instead of verifying the email on pageload, which + // would be faster but less DDP-ish (and more specifically, any + // non-web DDP app, such as an iOS client, would do something more + // in line with the hash fragment approach) + match = window.location.hash.match(/^\#\/verify-email\/(.*)$/); + if (match) { + Accounts._preventAutoLogin = true; + Accounts._verifyEmailToken = match[1]; + window.location.hash = ''; + } + + // reads an account enrollment token from the url's hash fragment, if + // it's there. also don't automatically log the user is, as for + // reset password links. + match = window.location.hash.match(/^\#\/enroll-account\/(.*)$/); + if (match) { + Accounts._preventAutoLogin = true; + Accounts._enrollAccountToken = match[1]; + window.location.hash = ''; + } +})(); diff --git a/packages/accounts-urls/url_server.js b/packages/accounts-urls/url_server.js new file mode 100644 index 0000000000..898b8f3cd9 --- /dev/null +++ b/packages/accounts-urls/url_server.js @@ -0,0 +1,17 @@ +if (typeof Accounts === 'undefined') + Accounts = {}; + +if (!Accounts.urls) + Accounts.urls = {}; + +Accounts.urls.resetPassword = function (token) { + return Meteor.absoluteUrl('#/reset-password/' + token); +}; + +Accounts.urls.verifyEmail = function (token) { + return Meteor.absoluteUrl('#/verify-email/' + token); +}; + +Accounts.urls.enrollAccount = function (token) { + return Meteor.absoluteUrl('#/enroll-account/' + token); +}; diff --git a/packages/accounts-weibo/package.js b/packages/accounts-weibo/package.js new file mode 100644 index 0000000000..c178954131 --- /dev/null +++ b/packages/accounts-weibo/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: "Login service for Sina Weibo accounts" +}); + +Package.on_use(function(api) { + api.use('accounts-base', ['client', 'server']); + api.use('accounts-oauth2-helper', ['client', 'server']); + api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['weibo_configure.html', 'weibo_configure.js'], + 'client'); + + api.add_files('weibo_common.js', ['client', 'server']); + api.add_files('weibo_server.js', 'server'); + api.add_files('weibo_client.js', 'client'); +}); diff --git a/packages/accounts-weibo/weibo_client.js b/packages/accounts-weibo/weibo_client.js new file mode 100644 index 0000000000..7f2aea042f --- /dev/null +++ b/packages/accounts-weibo/weibo_client.js @@ -0,0 +1,28 @@ +(function () { + // XXX support options.requestPermissions as we do for Facebook, Google, Github + Meteor.loginWithWeibo = function (options, callback) { + // support both (options, callback) and (callback). + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'}); + if (!config) { + callback && callback(new Accounts.ConfigError("Service not configured")); + return; + } + + var state = Meteor.uuid(); + // XXX need to support configuring access_type and scope + var loginUrl = + 'https://api.weibo.com/oauth2/authorize' + + '?response_type=code' + + '&client_id=' + config.clientId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/weibo?close', {replaceLocalhost: true}) + + '&state=' + state; + + Accounts.oauth.initiateLogin(state, loginUrl, callback); + }; + +}) (); diff --git a/packages/accounts-weibo/weibo_common.js b/packages/accounts-weibo/weibo_common.js new file mode 100644 index 0000000000..19ec575ef6 --- /dev/null +++ b/packages/accounts-weibo/weibo_common.js @@ -0,0 +1,3 @@ +if (!Accounts.weibo) { + Accounts.weibo = {}; +} diff --git a/packages/accounts-weibo/weibo_configure.html b/packages/accounts-weibo/weibo_configure.html new file mode 100644 index 0000000000..42d2da9dd8 --- /dev/null +++ b/packages/accounts-weibo/weibo_configure.html @@ -0,0 +1,25 @@ + diff --git a/packages/accounts-weibo/weibo_configure.js b/packages/accounts-weibo/weibo_configure.js new file mode 100644 index 0000000000..b5848a16ba --- /dev/null +++ b/packages/accounts-weibo/weibo_configure.js @@ -0,0 +1,11 @@ +Template.configureLoginServiceDialogForWeibo.siteUrl = function () { + // Weibo doesn't recognize localhost as a domain + return Meteor.absoluteUrl({replaceLocalhost: true}); +}; + +Template.configureLoginServiceDialogForWeibo.fields = function () { + return [ + {property: 'clientId', label: 'App Key'}, + {property: 'secret', label: 'App Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-weibo/weibo_server.js b/packages/accounts-weibo/weibo_server.js new file mode 100644 index 0000000000..fe5e4bb4e6 --- /dev/null +++ b/packages/accounts-weibo/weibo_server.js @@ -0,0 +1,50 @@ +(function () { + + Accounts.oauth.registerService('weibo', 2, function(query) { + + var accessToken = getAccessToken(query); + var identity = getIdentity(accessToken.access_token, parseInt(accessToken.uid, 10)); + + return { + serviceData: { + id: accessToken.uid, + accessToken: accessToken.access_token, + screenName: identity.screen_name + }, + extra: {profile: {name: identity.screen_name}} + }; + }); + + var getAccessToken = function (query) { + var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'}); + if (!config) + throw new Accounts.ConfigError("Service not configured"); + + var result = Meteor.http.post( + "https://api.weibo.com/oauth2/access_token", {params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/weibo?close", {replaceLocalhost: true}), + grant_type: 'authorization_code' + }}); + + if (result.error) // if the http response was an error + throw result.error; + if (typeof result.content === "string") + result.content = JSON.parse(result.content); + if (result.content.error) // if the http response was a json object with an error attribute + throw result.content; + return result.content; + }; + + var getIdentity = function (accessToken, userId) { + var result = Meteor.http.get( + "https://api.weibo.com/2/users/show.json", + {params: {access_token: accessToken, uid: userId}}); + + if (result.error) + throw result.error; + return result.data; + }; +})(); diff --git a/packages/deps/deps.js b/packages/deps/deps.js index f1cff70855..2ef788dd43 100644 --- a/packages/deps/deps.js +++ b/packages/deps/deps.js @@ -62,7 +62,7 @@ try { f(ctx); } catch (e) { - Meteor._debug("Exception from Meteor.flush:", e); + Meteor._debug("Exception from Meteor.flush:", e.stack); } }); delete ctx._callbacks; // maybe help the GC diff --git a/packages/insecure/insecure.js b/packages/insecure/insecure.js new file mode 100644 index 0000000000..22a74ca954 --- /dev/null +++ b/packages/insecure/insecure.js @@ -0,0 +1 @@ +Meteor.Collection.insecure = true; diff --git a/packages/insecure/package.js b/packages/insecure/package.js new file mode 100644 index 0000000000..fe2e744a38 --- /dev/null +++ b/packages/insecure/package.js @@ -0,0 +1,8 @@ +Package.describe({ + summary: "Allow all database writes by default" +}); + +Package.on_use(function (api) { + api.use(['mongo-livedata']); + api.add_files(['insecure.js'], 'server'); +}); diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index c7394076b8..323dda5baf 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -1,25 +1,46 @@ // XXX namespacing -Meteor._MethodInvocation = function (isSimulation, unblock) { +Meteor._MethodInvocation = function (options) { var self = this; // true if we're running not the actual method, but a stub (that is, - // if we're on the client and presently running a simulation of a - // server-side method for latency compensation purposes). never true - // except in a client such as a browser, since there's no point in - // running stubs unless you have a zero-latency connection to the - // user. - this.isSimulation = isSimulation; + // if we're on a client (which may be a browser, or in the future a + // server connecting to another server) and presently running a + // simulation of a server-side method for latency compensation + // purposes). not currently true except in a client such as a browser, + // since there's usually no point in running stubs unless you have a + // zero-latency connection to the user. + this.isSimulation = options.isSimulation; // XXX Backwards compatibility only. Remove this before 1.0. - this.is_simulation = isSimulation; + this.is_simulation = this.isSimulation; // call this function to allow other method invocations (from the // same client) to continue running without waiting for this one to // complete. - this.unblock = unblock || function () {}; + this.unblock = options.unblock || function () {}; + + // current user id + this.userId = options.userId; + + // sets current user id in all appropriate server contexts and + // reruns subscriptions + this._setUserId = options.setUserId || 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 + // sort of thing someday. + this._sessionData = options.sessionData; }; +_.extend(Meteor._MethodInvocation.prototype, { + setUserId: function(userId) { + this.userId = userId; + this._setUserId(userId); + } +}); + Meteor._CurrentInvocation = new Meteor.EnvironmentVariable; Meteor.Error = function (error, reason, details) { @@ -43,4 +64,4 @@ Meteor.Error = function (error, reason, details) { self.details = details; }; -Meteor.Error.prototype = new Error; \ No newline at end of file +Meteor.Error.prototype = new Error; diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 27350ab93c..ec662c8785 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -12,8 +12,16 @@ Meteor._capture_subs = null; // @param url {String|Object} URL to Meteor app or sockjs endpoint (deprecated), // or an object as a test hook (see code) -Meteor._LivedataConnection = function (url, restart_on_update) { +// Options: +// reloadOnUpdate: should we try to reload when the server says +// there's new code available? +// reloadWithOutstanding: is it OK to reload if there are outstanding methods? +Meteor._LivedataConnection = function (url, options) { var self = this; + options = _.extend({ + reloadOnUpdate: false, + reloadWithOutstanding: false + }, options); // as a test hook, allow passing a stream instead of a url. if (typeof url === "object") { @@ -29,8 +37,27 @@ Meteor._LivedataConnection = function (url, restart_on_update) { self.stores = {}; // name -> object with methods self.method_handlers = {}; // name -> func self.next_method_id = 1; - // waiting for results of method + + // --- Three classes of outstanding methods --- + + // 1. either already sent, or waiting to be sent with no special + // consideration once we reconnect self.outstanding_methods = []; // each item has keys: msg, callback + + // 2. the sole outstanding method that needs to be waited on, or null + // same keys as outstanding_methods (notably wait is implicitly true + // but not set) + self.outstanding_wait_method = null; // same keys as outstanding_methods + // stores response from `outstanding_wait_method` while we wait for + // previous method calls to complete, as received in _livedata_result + self.outstanding_wait_method_response = null; + + // 3. methods blocked on outstanding_wait_method being completed. + self.blocked_methods = []; // each item has keys: msg, callback, wait + + // if set, called when we reconnect, queuing method calls _before_ + // the existing outstanding ones + self.onReconnect = null; // waiting for data from method self.unsatisfied_methods = {}; // map from method_id -> true // sub was ready, is no longer (due to reconnect) @@ -48,21 +75,26 @@ Meteor._LivedataConnection = function (url, restart_on_update) { // yet ready. self.sub_ready_callbacks = {}; + // Per-connection scratch area. This is only used internally, but we + // should have real and documented API for this sort of thing someday. + self.sessionData = {}; + // just for testing self.quiesce_callbacks = []; - - // Setup auto-reload persistence. - Meteor._reload.onMigrate(function (retry) { - if (!self._readyToMigrate()) { - if (self._retryMigrate) - throw new Error("Two migrations in progress?"); - self._retryMigrate = retry; - return false; - } - - return [true]; - }); + // Block auto-reload while we're waiting for method responses. + if (!options.reloadWithOutstanding) { + Meteor._reload.onMigrate(function (retry) { + if (!self._readyToMigrate()) { + if (self._retryMigrate) + throw new Error("Two migrations in progress?"); + self._retryMigrate = retry; + return false; + } else { + return [true]; + } + }); + } // Setup stream (if not overriden above) self.stream = self.stream || new Meteor._Stream(self.url); @@ -94,7 +126,6 @@ Meteor._LivedataConnection = function (url, restart_on_update) { }); self.stream.on('reset', function () { - // Send a connect message at the beginning of the stream. // NOTE: reset is called even on the first connection, so this is // the only place we send this message. @@ -115,10 +146,15 @@ Meteor._LivedataConnection = function (url, restart_on_update) { // immediately before disconnection.. do we need to add app-level // acking of data messages? - // Send pending methods. - _.each(self.outstanding_methods, function (m) { - self.stream.send(JSON.stringify(m.msg)); - }); + // If an `onReconnect` handler is set, call it first. Go through + // some hoops to ensure that methods that are called from within + // `onReconnect` get executed _before_ ones that were originally + // outstanding (since `onReconnect` is used to re-establish auth + // certificates) + if (self.onReconnect) + self._callOnReconnectAndSendAppropriateOutstandingMethods(); + else + self._sendOutstandingMethods(); // add new subscriptions at the end. this way they take effect after // the handlers and we don't see flicker. @@ -128,13 +164,14 @@ Meteor._LivedataConnection = function (url, restart_on_update) { }); }); - if (restart_on_update) + if (options.reloadOnUpdate) { self.stream.on('update_available', function () { // Start trying to migrate to a new version. Until all packages // signal that they're ready for a migration, the app will // continue running normally. Meteor._reload.reload(); }); + } // we never terminate the observe(), since there is no way to // destroy a LivedataConnection.. but this shouldn't matter, since we're @@ -186,7 +223,11 @@ _.extend(Meteor._LivedataConnection.prototype, { if (args.length && typeof args[args.length - 1] === "function") var callback = args.pop(); - var existing = self.subs.find({name: name, args: args}, {reactive: false}).fetch(); + // Look for existing subs (ignore those with count=0, since they're going to + // get removed on the next time through the event loop). + var existing = self.subs.find( + {name: name, args: args, count: {$gt: 0}}, + {reactive: false}).fetch(); if (existing && existing[0]) { // already subbed, inc count. @@ -242,11 +283,23 @@ _.extend(Meteor._LivedataConnection.prototype, { return this.apply(name, args, callback); }, - apply: function (name, args, callback) { + // @param options {Optional Object} + // wait: Boolean - Should we block subsequent method calls on this + // method's result having been received? + // (does not affect methods called from within this method) + // @param callback {Optional Function} + apply: function (name, args, options, callback) { var self = this; - var enclosing = Meteor._CurrentInvocation.get(); - if (callback) + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + + if (callback) { // XXX would it be better form to do the binding in stream.on, // or caller, instead of here? callback = Meteor.bindEnvironment(callback, function (e) { @@ -254,8 +307,8 @@ _.extend(Meteor._LivedataConnection.prototype, { Meteor._debug("Exception while delivering result of invoking '" + name + "'", e.stack); }); + } - var isSimulation = enclosing && enclosing.isSimulation; if (Meteor.isClient) { // If on a client, run the stub, if we have one. The stub is // supposed to make some temporary writes to the database to @@ -271,7 +324,14 @@ _.extend(Meteor._LivedataConnection.prototype, { // of the stub as our return value. var stub = self.method_handlers[name]; if (stub) { - var invocation = new Meteor._MethodInvocation(true /* isSimulation */); + var setUserId = function(userId) { + self.setUserId(userId); + }; + var invocation = new Meteor._MethodInvocation({ + isSimulation: true, + userId: self.userId(), setUserId: setUserId, + sessionData: self.sessionData + }); try { var ret = Meteor._CurrentInvocation.withValue(invocation,function () { return stub.apply(invocation, args); @@ -283,10 +343,10 @@ _.extend(Meteor._LivedataConnection.prototype, { } // If we're in a simulation, stop and return the result we have, - // rather than going on to do an RPC. This can only happen on - // the client (since we only bother with stubs and simulations - // on the client.) If there was not stub, we'll end up returning - // undefined. + // rather than going on to do an RPC. If there was no stub, + // we'll end up returning undefined. + var enclosing = Meteor._CurrentInvocation.get(); + var isSimulation = enclosing && enclosing.isSimulation; if (isSimulation) { if (callback) { callback(exception, ret); @@ -337,9 +397,31 @@ _.extend(Meteor._LivedataConnection.prototype, { params: args, id: '' + (self.next_method_id++) }; - self.outstanding_methods.push({msg: msg, callback: callback}); + + if (self.outstanding_wait_method) { + self.blocked_methods.push({ + msg: msg, + callback: callback, + wait: options.wait + }); + } else { + var method_object = { + msg: msg, + callback: callback + }; + + if (options.wait) + self.outstanding_wait_method = method_object; + else + self.outstanding_methods.push(method_object); + + self.stream.send(JSON.stringify(msg)); + } + + // Even if we are waiting on other method calls mark this method + // as unsatisfied so that the user never ends up seeing + // intermediate versions of the server's datastream self.unsatisfied_methods[msg.id] = true; - self.stream.send(JSON.stringify(msg)); // If we're using the default callback on the server, // synchronously return the result from the remote host. @@ -351,16 +433,37 @@ _.extend(Meteor._LivedataConnection.prototype, { } }, - status: function () { + status: function (/*passthrough args*/) { var self = this; - return self.stream.status(); + return self.stream.status.apply(self.stream, arguments); }, - reconnect: function () { + reconnect: function (/*passthrough args*/) { var self = this; - return self.stream.reconnect(); + return self.stream.reconnect.apply(self.stream, arguments); }, + /// + /// Reactive user system + /// XXX Can/should this be generalized pattern? + /// + userId: function () { + var self = this; + if (self._userIdListeners) + self._userIdListeners.addCurrentContext(); + return self._userId; + }, + + setUserId: function (userId) { + var self = this; + self._userId = userId; + if (self._userIdListeners) + self._userIdListeners.invalidateAll(); + }, + + _userId: null, + _userIdListeners: Meteor.deps && new Meteor.deps._ContextSet, + // PRIVATE: called when we are up-to-date with the server. intended // for use only in tests. currently, you are very limited in what // you may do inside your callback -- in particular, don't do @@ -507,36 +610,86 @@ _.extend(Meteor._LivedataConnection.prototype, { }, _livedata_result: function (msg) { - var self = this; // id, result or error. error has error (code), reason, details + var self = this; // find the outstanding request // should be O(1) in nearly all realistic use cases - for (var i = 0; i < self.outstanding_methods.length; i++) { - var m = self.outstanding_methods[i]; - if (m.msg.id === msg.id) - break; + var m; + if (self.outstanding_wait_method && + self.outstanding_wait_method.msg.id === msg.id) { + m = self.outstanding_wait_method; + self.outstanding_wait_method_response = msg; + } else { + for (var i = 0; i < self.outstanding_methods.length; i++) { + m = self.outstanding_methods[i]; + if (m.msg.id === msg.id) + break; + } + + // remove + self.outstanding_methods.splice(i, 1); } + if (!m) { - // XXX write a better error - Meteor._debug("Can't interpret method response message"); + Meteor._debug("Can't match method response to original method call", msg); return; } - // remove - self.outstanding_methods.splice(i, 1); + if (self.outstanding_wait_method) { + // Wait until we have completed all outstanding methods. + if (self.outstanding_methods.length === 0 && + self.outstanding_wait_method_response) { - // deliver result - if (m.callback) { - // callback will have already been bindEnvironment'd by apply(), - // so no need to catch exceptions - if ('error' in msg) - m.callback(new Meteor.Error(msg.error.error, msg.error.reason, - msg.error.details)); - else - // msg.result may be undefined if the method didn't return a - // value - m.callback(undefined, msg.result); + // Start by saving the outstanding wait method details, since + // we're going to reshift the blocked ones and try to send + // them *before* calling the method callback. It is necessary + // to call method callbacks last since they might themselves + // call other methods + var savedOutstandingWaitMethod = self.outstanding_wait_method; + var savedOutstandingWaitMethodResponse = self.outstanding_wait_method_response; + self.outstanding_wait_method_response = null; + self.outstanding_wait_method = null; + + // Find first blocked method with wait: true + var i; + for (i = 0; i < self.blocked_methods.length; i++) + if (self.blocked_methods[i].wait) + break; + + // Move as many blocked methods as we can into + // outstanding_methods and outstanding_wait_method if needed + self.outstanding_methods = _.first(self.blocked_methods, i); + if (i !== self.blocked_methods.length) { + self.outstanding_wait_method = self.blocked_methods[i]; + self.blocked_methods = _.rest(self.blocked_methods, i+1); + } else { + self.blocked_methods = []; + } + + // Send any new outstanding methods after we reshift the + // blocked methods. Intentionally do this before calling the + // method response because they might call additional methods + // that shouldn't be sent twice. + self._sendOutstandingMethods(); + + // Fire necessary outstanding method callbacks, making sure we + // only fire the outstanding wait method after all other outstanding + // methods' callbacks were fired + if (m === savedOutstandingWaitMethod) { + self._deliverMethodResponse(savedOutstandingWaitMethod, + savedOutstandingWaitMethodResponse /*(=== msg)*/); + } else { + self._deliverMethodResponse(m, msg); + self._deliverMethodResponse(savedOutstandingWaitMethod, + savedOutstandingWaitMethodResponse /*(!== msg)*/); + } + } else { + if (m !== self.outstanding_wait_method) + self._deliverMethodResponse(m, msg); + } + } else { + self._deliverMethodResponse(m, msg); } // if we were blocking a migration, see if it's now possible to @@ -547,16 +700,84 @@ _.extend(Meteor._LivedataConnection.prototype, { } }, + // @param method {Object} as in `outstanding_methods` + // @param response {Object{id, result | error}} + _deliverMethodResponse: function(method, response) { + // callback will have already been bindEnvironment'd by apply(), + // so no need to catch exceptions + if ('error' in response) { + method.callback(new Meteor.Error( + response.error.error, response.error.reason, + response.error.details)); + } else { + // msg.result may be undefined if the method didn't return a + // value + method.callback(undefined, response.result); + } + }, + + _sendOutstandingMethods: function() { + var self = this; + _.each(self.outstanding_methods, function (m) { + self.stream.send(JSON.stringify(m.msg)); + }); + if (self.outstanding_wait_method) + self.stream.send(JSON.stringify(self.outstanding_wait_method.msg)); + }, + _livedata_error: function (msg) { Meteor._debug("Received error from server: ", msg.reason); if (msg.offending_message) Meteor._debug("For: ", msg.offending_message); }, - // true if we're OK for a migration to happen - _readyToMigrate: function () { + _callOnReconnectAndSendAppropriateOutstandingMethods: function() { var self = this; - return self.outstanding_methods.length === 0; + var old_outstanding_methods = self.outstanding_methods; + var old_outstanding_wait_method = self.outstanding_wait_method; + var old_blocked_methods = self.blocked_methods; + self.outstanding_methods = []; + self.outstanding_wait_method = null; + self.blocked_methods = []; + + self.onReconnect(); + + if (self.outstanding_wait_method) { + // self.onReconnect() caused us to wait on a method. Add all old + // methods to blocked_methods, and we don't need to send any + // additional methods + self.blocked_methods = self.blocked_methods.concat( + old_outstanding_methods); + + if (old_outstanding_wait_method) { + self.blocked_methods.push(_.extend( + old_outstanding_wait_method, {wait: true})); + } + + self.blocked_methods = self.blocked_methods.concat( + old_blocked_methods); + } else { + // self.onReconnect() did not cause us to wait on a method. Add + // as many methods as we can to outstanding_methods and send + // them + _.each(old_outstanding_methods, function(method) { + self.outstanding_methods.push(method); + self.stream.send(JSON.stringify(method.msg)); + }); + + self.outstanding_wait_method = old_outstanding_wait_method; + if (self.outstanding_wait_method) + self.stream.send(JSON.stringify(self.outstanding_wait_method.msg)); + + self.blocked_methods = old_blocked_methods; + } + }, + + _readyToMigrate: function() { + var self = this; + return self.outstanding_methods.length === 0 && + !self.outstanding_wait_method && + self.blocked_methods.length === 0; } }); @@ -568,8 +789,9 @@ _.extend(Meteor, { // "/", // "http://subdomain.meteor.com/sockjs" (deprecated), // "/sockjs" (deprecated) - connect: function (url, _restartOnUpdate) { - var ret = new Meteor._LivedataConnection(url, _restartOnUpdate); + connect: function (url, _reloadOnUpdate) { + var ret = new Meteor._LivedataConnection( + url, {reloadOnUpdate: _reloadOnUpdate}); Meteor._LivedataConnection._allConnections.push(ret); // hack. see below. return ret; }, diff --git a/packages/livedata/livedata_connection_tests.js b/packages/livedata/livedata_connection_tests.js index 35ba174607..6e216e2d76 100644 --- a/packages/livedata/livedata_connection_tests.js +++ b/packages/livedata/livedata_connection_tests.js @@ -1,3 +1,10 @@ +var newConnection = function (stream) { + // Some of these tests leave outstanding methods with no result yet + // returned. This should not block us from re-running tests when sources + // change. + return new Meteor._LivedataConnection(stream, {reloadWithOutstanding: true}); +}; + var test_got_message = function (test, stream, expected) { if (stream.sent.length === 0) { test.fail({error: 'no message received', expected: expected}); @@ -12,12 +19,7 @@ var test_got_message = function (test, stream, expected) { test.equal(got, expected); }; -var SESSION_ID = '17'; - -Tinytest.add("livedata stub - receive data", function (test) { - var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); - +var startAndConnect = function(test, stream) { stream.reset(); // initial connection start. test_got_message(test, stream, {msg: 'connect'}); @@ -25,6 +27,15 @@ Tinytest.add("livedata stub - receive data", function (test) { stream.receive({msg: 'connected', session: SESSION_ID}); test.length(stream.sent, 0); +}; + +var SESSION_ID = '17'; + +Tinytest.add("livedata stub - receive data", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + + startAndConnect(test, stream); // data comes in for unknown collection. var coll_name = Meteor.uuid(); @@ -33,6 +44,8 @@ Tinytest.add("livedata stub - receive data", function (test) { // break throught the black box and test internal state test.length(conn.queued[coll_name], 1); + // XXX: Test that the old signature of passing manager directly instead of in + // options works. var coll = new Meteor.Collection(coll_name, conn); // queue has been emptied and doc is in db. @@ -46,19 +59,11 @@ Tinytest.add("livedata stub - receive data", function (test) { test.isUndefined(conn.queued[coll_name]); }); - - -Tinytest.add("livedata stub - subscribe", function (test) { +Tinytest.addAsync("livedata stub - subscribe", function (test, onComplete) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); // subscribe var callback_fired = false; @@ -67,6 +72,7 @@ Tinytest.add("livedata stub - subscribe", function (test) { }); test.isFalse(callback_fired); + test.length(stream.sent, 1); var message = JSON.parse(stream.sent.shift()); var id = message.id; delete message.id; @@ -75,18 +81,42 @@ Tinytest.add("livedata stub - subscribe", function (test) { // get the sub satisfied. callback fires. stream.receive({msg: 'data', 'subs': [id]}); test.isTrue(callback_fired); + + // This defers the actual unsub message, so we need to set a timeout + // to observe the message. We also test that we can resubscribe even + // before the unsub has been sent. + // + // Note: it would be perfectly fine for livedata_connection to send the unsub + // synchronously, so if this test fails just because we've made that change, + // that's OK! This is a regression test for a failure case where it *never* + // sent the unsub if there was a quick resub afterwards. + // + // XXX rewrite Meteor.defer to guarantee ordered execution so we don't have to + // use setTimeout + sub.stop(); + conn.subscribe('my_data'); + + test.length(stream.sent, 1); + message = JSON.parse(stream.sent.shift()); + var id2 = message.id; + test.notEqual(id, id2); + delete message.id; + test.equal(message, {msg: 'sub', name: 'my_data', params: []}); + + setTimeout(function() { + test.length(stream.sent, 1); + var message = JSON.parse(stream.sent.shift()); + test.equal(message, {msg: 'unsub', id: id}); + onComplete(); + }, 10); }); Tinytest.add("livedata stub - this", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - test_got_message(test, stream, {msg: 'connect'}); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); conn.methods({test_this: function() { test.isTrue(this.isSimulation); @@ -112,18 +142,12 @@ Tinytest.add("livedata stub - this", function (test) { Tinytest.add("livedata stub - methods", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); var coll_name = Meteor.uuid(); - var coll = new Meteor.Collection(coll_name, conn); + var coll = new Meteor.Collection(coll_name, {manager: conn}); // setup method conn.methods({do_something: function (x) { @@ -211,18 +235,12 @@ Tinytest.add("livedata stub - methods", function (test) { // method calls another method in simulation. see not sent. Tinytest.add("livedata stub - sub methods", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); var coll_name = Meteor.uuid(); - var coll = new Meteor.Collection(coll_name, conn); + var coll = new Meteor.Collection(coll_name, {manager: conn}); // setup methods conn.methods({ @@ -287,18 +305,12 @@ Tinytest.add("livedata stub - sub methods", function (test) { // data is shown Tinytest.add("livedata stub - reconnect", function (test) { var stream = new Meteor._StubStream(); - var conn = new Meteor._LivedataConnection(stream); + var conn = newConnection(stream); - stream.reset(); // initial connection start. - - test_got_message(test, stream, {msg: 'connect'}); - test.length(stream.sent, 0); - - stream.receive({msg: 'connected', session: SESSION_ID}); - test.length(stream.sent, 0); + startAndConnect(test, stream); var coll_name = Meteor.uuid(); - var coll = new Meteor.Collection(coll_name, conn); + var coll = new Meteor.Collection(coll_name, {manager: conn}); // setup observers var counts = {added: 0, removed: 0, changed: 0, moved: 0}; @@ -344,9 +356,12 @@ Tinytest.add("livedata stub - reconnect", function (test) { conn.call('do_something', function () { method_callback_fired = true; }); + conn.apply('do_something', [], {wait: true}); + test.isFalse(method_callback_fired); var method_message = JSON.parse(stream.sent.shift()); + var wait_method_message = JSON.parse(stream.sent.shift()); test.equal(method_message, {msg: 'method', method: 'do_something', params: [], id:method_message.id}); @@ -356,13 +371,13 @@ Tinytest.add("livedata stub - reconnect", function (test) { test.equal(coll.find({c:3}).count(), 0); test.equal(counts, {added: 1, removed: 0, changed: 1, moved: 0}); - // stream reset. reconnect! // we send a connect, our pending messages, and our subs. stream.reset(); test_got_message(test, stream, {msg: 'connect', session: SESSION_ID}); test_got_message(test, stream, method_message); + test_got_message(test, stream, wait_method_message); test_got_message(test, stream, sub_message); // reconnect with different session id @@ -378,10 +393,12 @@ Tinytest.add("livedata stub - reconnect", function (test) { test.equal(counts, {added: 1, removed: 0, changed: 1, moved: 0}); // satisfy and return method callback - stream.receive({msg: 'data', methods: [method_message.id]}); + stream.receive({msg: 'data', + methods: [method_message.id, wait_method_message.id]}); test.isFalse(method_callback_fired); stream.receive({msg: 'result', id:method_message.id, result:"bupkis"}); + stream.receive({msg: 'result', id:wait_method_message.id, result:"bupkis"}); test.isTrue(method_callback_fired); // still no update. @@ -399,7 +416,195 @@ Tinytest.add("livedata stub - reconnect", function (test) { handle.stop(); }); +Tinytest.add("livedata connection - reactive userId", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + + test.equal(conn.userId(), null); + conn.setUserId(1337); + test.equal(conn.userId(), 1337); +}); + +Tinytest.add("livedata connection - two wait methods with reponse in order", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + var responses = []; + conn.apply('do_something', ['one!'], function() { responses.push('one'); }); + var one_message = JSON.parse(stream.sent.shift()); + test.equal(one_message.params, ['one!']); + + conn.apply('do_something', ['two!'], {wait: true}, function() { + responses.push('two'); + }); + var two_message = JSON.parse(stream.sent.shift()); + test.equal(two_message.params, ['two!']); + test.equal(responses, []); + + conn.apply('do_something', ['three!'], function() { + responses.push('three'); + }); + conn.apply('do_something', ['four!'], {wait: true}, function() { + responses.push('four'); + }); + + conn.apply('do_something', ['five!'], function() { responses.push('five'); }); + + // Verify that we did not send "three!" since we're waiting for + // "one!" and "two!" to send their response back + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: one_message.id}); + test.equal(responses, ['one']); + + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: two_message.id}); + test.equal(responses, ['one', 'two']); + + // Verify that we just sent "three!" and "four!" now that we got + // responses for "one!" and "two!" + test.equal(stream.sent.length, 2); + var three_message = JSON.parse(stream.sent.shift()); + test.equal(three_message.params, ['three!']); + var four_message = JSON.parse(stream.sent.shift()); + test.equal(four_message.params, ['four!']); + + stream.receive({msg: 'result', id: three_message.id}); + test.equal(responses, ['one', 'two', 'three']); + + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: four_message.id}); + test.equal(responses, ['one', 'two', 'three', 'four']); + + // Verify that we just sent "five!" + test.equal(stream.sent.length, 1); + var five_message = JSON.parse(stream.sent.shift()); + test.equal(five_message.params, ['five!']); +}); + +Tinytest.add("livedata connection - one wait method with response out of order", function (test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + var responses = []; + conn.apply('do_something', ['one!'], function() { responses.push('one'); }); + var one_message = JSON.parse(stream.sent.shift()); + test.equal(one_message.params, ['one!']); + + conn.apply('do_something', ['two!'], {wait: true}, function() { + responses.push('two'); + }); + var two_message = JSON.parse(stream.sent.shift()); + test.equal(two_message.params, ['two!']); + test.equal(responses, []); + + conn.apply('do_something', ['three!']); + + // Verify that we did not send "three!" since we're waiting for + // "one!" and "two!" to send their response back + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: two_message.id}); + test.equal(responses, []); + + test.equal(stream.sent.length, 0); + stream.receive({msg: 'result', id: one_message.id}); + test.equal(responses, ['one', 'two']); // Namely not two, one + + // Verify that we just sent "three!" now that we got responses for + // "one!" and "two!" + test.equal(stream.sent.length, 1); + var three_message = JSON.parse(stream.sent.shift()); + test.equal(three_message.params, ['three!']); + // Since we sent it, it should no longer be in "blocked_methods". + test.equal(conn.blocked_methods, []); +}); + +Tinytest.add("livedata connection - onReconnect prepends messages correctly with a wait method", function(test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + conn.onReconnect = function() { + conn.apply('do_something', ['reconnect one']); + conn.apply('do_something', ['reconnect two'], {wait: true}); + conn.apply('do_something', ['reconnect three']); + }; + + conn.apply('do_something', ['one']); + conn.apply('do_something', ['two'], {wait: true}); + conn.apply('do_something', ['three']); + + // reconnect + stream.sent = []; + stream.reset(); + test_got_message( + test, stream, {msg: 'connect', session: conn.last_session_id}); + + // Test that we sent what we expect to send, and we're blocked on + // what we expect to be blocked. The subsequent logic to correctly + // read the wait flag is tested separately. + test.equal(_.map(stream.sent, function(msg) { + return JSON.parse(msg).params[0]; + }), ['reconnect one', 'reconnect two']); + test.equal(_.map(conn.blocked_methods, function(method) { + return [method.msg.params[0], method.wait]; + }), [ + ['reconnect three', undefined/*==false*/], + ['one', undefined/*==false*/], + ['two', true], + ['three', undefined/*==false*/] + ]); +}); + +Tinytest.add("livedata connection - onReconnect prepends messages correctly without a wait method", function(test) { + var stream = new Meteor._StubStream(); + var conn = newConnection(stream); + startAndConnect(test, stream); + + // setup method + conn.methods({do_something: function (x) {}}); + + conn.onReconnect = function() { + conn.apply('do_something', ['reconnect one']); + conn.apply('do_something', ['reconnect two']); + conn.apply('do_something', ['reconnect three']); + }; + + conn.apply('do_something', ['one']); + conn.apply('do_something', ['two'], {wait: true}); + conn.apply('do_something', ['three']); + + // reconnect + stream.sent = []; + stream.reset(); + test_got_message( + test, stream, {msg: 'connect', session: conn.last_session_id}); + + // Test that we sent what we expect to send, and we're blocked on + // what we expect to be blocked. The subsequent logic to correctly + // read the wait flag is tested separately. + test.equal(_.map(stream.sent, function(msg) { + return JSON.parse(msg).params[0]; + }), ['reconnect one', 'reconnect two', 'reconnect three', 'one', 'two']); + test.equal(_.map(conn.blocked_methods, function(method) { + return [method.msg.params[0], method.wait]; + }), [ + ['three', undefined/*==false*/] + ]); +}); + // XXX also test: // - reconnect, with session resume. // - restart on update flag // - on_update event +// - reloading when the app changes, including session migration diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index e10c9d73de..348656cf5d 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -30,6 +30,18 @@ Meteor._LivedataSession = function (server) { // map from collection name -> id -> key -> subscription id -> true self.provides_key = {}; + + // if set, ignore flush requests on any subsubcription on this + // session. when set this back to false, don't forget to call flush + // manually. this is sometimes needed because subscriptions + // frequently call flush + self.dontFlush = false; + + self.userId = null; + + // Per-connection scratch area. This is only used internally, but we + // should have real and documented API for this sort of thing someday. + self.sessionData = {}; }; _.extend(Meteor._LivedataSession.prototype, { @@ -269,8 +281,16 @@ _.extend(Meteor._LivedataSession.prototype, { return; } - var invocation = new Meteor._MethodInvocation(false /* isSimulation */, - unblock); + var setUserId = function(userId) { + self._setUserId(userId); + }; + + var invocation = new Meteor._MethodInvocation({ + isSimulation: false, + userId: self.userId, setUserId: setUserId, + unblock: unblock, + sessionData: self.sessionData + }); try { var ret = Meteor._CurrentWriteFence.withValue(fence, function () { @@ -305,6 +325,20 @@ _.extend(Meteor._LivedataSession.prototype, { } }, + // Sets the current user id in all appropriate contexts and reruns + // all subscriptions + _setUserId: function(userId) { + var self = this; + self.userId = userId; + this._rerunAllSubscriptions(); + + // XXX figure out the login token that was just used, and set up an observe + // on the user doc so that deleting the user or the login token disconnects + // the session. For now, if you want to make sure that your deleted users + // don't have any continuing sessions, you can restart the server, but we + // should make it automatic. + }, + _startSubscription: function (handler, priority, sub_id, params) { var self = this; @@ -314,23 +348,29 @@ _.extend(Meteor._LivedataSession.prototype, { else self.universal_subs.push(sub); - try { - var res = handler.apply(sub, params || []); - } catch (e) { - Meteor._debug("Internal exception while starting subscription", sub_id, - e.stack); - return; - } + // Store a function to re-run the handler in case we want to rerun + // subscriptions, for example when the current user id changes + sub._runHandler = function() { + try { + var res = handler.apply(sub, params || []); + } catch (e) { + Meteor._debug("Internal exception while starting subscription", sub_id, + e.stack); + return; + } - // if Meteor._RemoteCollectionDriver is available (defined in - // mongo-livedata), automatically wire up handlers that return a - // Cursor. otherwise, the handler is completely responsible for - // delivering its own data messages and registering stop - // functions. - // - // XXX generalize - if (Meteor._RemoteCollectionDriver && (res instanceof Meteor._Mongo.Cursor)) - sub._publishCursor(res); + // if Meteor._RemoteCollectionDriver is available (defined in + // mongo-livedata), automatically wire up handlers that return a + // Cursor. otherwise, the handler is completely responsible for + // delivering its own data messages and registering stop + // functions. + // + // XXX generalize + if (Meteor._RemoteCollectionDriver && (res instanceof Meteor._Mongo.Cursor)) + sub._publishCursor(res); + }; + + sub._runHandler(); }, // tear down specified subscription @@ -358,7 +398,30 @@ _.extend(Meteor._LivedataSession.prototype, { self.universal_subs = []; }, - // return the current value for a particular key, as given by the + // Rerun all subscriptions without sending intermediate state down + // the wire + _rerunAllSubscriptions: function () { + var self = this; + + var rerunSub = function(sub) { + sub._teardown(); + sub.userId = self.userId; + sub._runHandler(); + }; + var flushSub = function(sub) { + sub.flush(); + }; + + self.dontFlush = true; + _.each(self.named_subs, rerunSub); + _.each(self.universal_subs, rerunSub); + + self.dontFlush = false; + _.each(self.named_subs, flushSub); + _.each(self.universal_subs, flushSub); + }, + + // RETURN the current value for a particular key, as given by the // current contents of each subscription's snapshot. _effectiveValueForKey: function (collection_name, id, key) { var self = this; @@ -388,6 +451,12 @@ Meteor._LivedataSubscription = function (session, sub_id, priority) { // LivedataSession this.session = session; + // Give access to sessionData in subscriptions as well as + // methods. This is not currently used, but is included for + // consistency. We should have real and documented API for this sort + // of thing someday. + this._sessionData = session.sessionData; + // my subscription ID (generated by client, null for universal subs). this.sub_id = sub_id; @@ -413,6 +482,8 @@ Meteor._LivedataSubscription = function (session, sub_id, priority) { // stop callbacks to g/c this sub. called w/ zero arguments. this.stop_callbacks = []; + + this.userId = session.userId; }; _.extend(Meteor._LivedataSubscription.prototype, { @@ -422,22 +493,7 @@ _.extend(Meteor._LivedataSubscription.prototype, { if (self.stopped) return; - // tell listeners, so they can clean up - for (var i = 0; i < this.stop_callbacks.length; i++) - (this.stop_callbacks[i])(); - - // remove our data from the client (possibly unshadowing data from - // lower priority subscriptions) - self.pending_data = {}; - self.pending_complete = false; - for (var name in self.snapshot) { - self.pending_data[name] = {}; - for (var id in self.snapshot[name]) { - self.pending_data[name][id] = {}; - for (var key in self.snapshot[name][id]) - self.pending_data[name][id][key] = undefined; - } - } + self._teardown(); self.flush(); self.stopped = true; }, @@ -478,6 +534,9 @@ _.extend(Meteor._LivedataSubscription.prototype, { flush: function () { var self = this; + if (self.session.dontFlush) + return; + if (self.stopped) return; @@ -546,6 +605,26 @@ _.extend(Meteor._LivedataSubscription.prototype, { self.pending_complete = false; }, + _teardown: function() { + var self = this; + // tell listeners, so they can clean up + for (var i = 0; i < self.stop_callbacks.length; i++) + (self.stop_callbacks[i])(); + + // remove our data from the client (possibly unshadowing data from + // lower priority subscriptions) + self.pending_data = {}; + self.pending_complete = false; + for (var name in self.snapshot) { + self.pending_data[name] = {}; + for (var id in self.snapshot[name]) { + self.pending_data[name][id] = {}; + for (var key in self.snapshot[name][id]) + self.pending_data[name][id][key] = undefined; + } + } + }, + _publishCursor: function (cursor, name) { var self = this; var collection = name || cursor.collection_name; @@ -767,9 +846,19 @@ _.extend(Meteor._LivedataServer.prototype, { return this.apply(name, args, callback); }, - apply: function (name, args, callback) { + // @param options {Optional Object} + // @param callback {Optional Function} + apply: function (name, args, options, callback) { var self = this; + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + if (callback) // It's not really necessary to do this, since we immediately // run the callback in this fiber before returning, but we do it @@ -785,7 +874,26 @@ _.extend(Meteor._LivedataServer.prototype, { if (!handler) var exception = new Meteor.Error(404, "Method not found"); else { - var invocation = new Meteor._MethodInvocation(false /* isSimulation */); + // If this is a method call from within another method, get the + // user state from the outer method, otherwise don't allow + // setUserId to be called + var userId = null; + var setUserId = function() { + throw new Error("Can't call setUserId on a server initiated method call"); + }; + var currentInvocation = Meteor._CurrentInvocation.get(); + if (currentInvocation) { + userId = currentInvocation.userId; + setUserId = function(userId) { + currentInvocation.setUserId(userId); + }; + } + + var invocation = new Meteor._MethodInvocation({ + isSimulation: false, + userId: userId, setUserId: setUserId, + sessionData: self.sessionData + }); try { var ret = Meteor._CurrentInvocation.withValue(invocation, function () { return handler.apply(invocation, args); diff --git a/packages/livedata/livedata_test_service.js b/packages/livedata/livedata_test_service.js index b98597c238..1d92d4e047 100644 --- a/packages/livedata/livedata_test_service.js +++ b/packages/livedata/livedata_test_service.js @@ -22,9 +22,45 @@ Meteor.methods({ } }); +// Methods to help test applying methods with `wait: true`: delayedTrue +// returns true 500ms after being run unless makeDelayedTrueImmediatelyReturnFalse +// was run in the meanwhile +if (Meteor.isServer) { + var delayed_true_future; + var delayed_true_times; + Meteor.methods({ + delayedTrue: function() { + delayed_true_future = new Future(); + delayed_true_times = Meteor.setTimeout(function() { + delayed_true_future['return'](true); + delayed_true_future = null; + delayed_true_times = null; + }, 500); + + this.unblock(); + return delayed_true_future.wait(); + }, + makeDelayedTrueImmediatelyReturnFalse: function() { + if (!delayed_true_future) + return; // since delayedTrue's timeout had already run + + if (delayed_true_times) clearTimeout(delayed_true_times); + delayed_true_future['return'](false); + delayed_true_future = null; + delayed_true_times = null; + } + }); +} + /*****/ Ledger = new Meteor.Collection("ledger"); +Ledger.allow({ + insert: function() { return true; }, + update: function() { return true; }, + remove: function() { return true; }, + fetch: [] +}); Meteor.startup(function () { if (Meteor.isServer) @@ -60,4 +96,57 @@ Meteor.methods({ Ledger.update({_id: to._id}, {$inc: {balance: amount}}); Meteor.refresh({collection: 'ledger', world: world}); } -}); \ No newline at end of file +}); + +/*****/ + +/// Helpers for "livedata - changing userid reruns subscriptions..." + +objectsWithUsers = new Meteor.Collection("objectsWithUsers"); + +if (Meteor.isServer) { + objectsWithUsers.remove({}); + objectsWithUsers.insert({name: "owned by none", ownerUserIds: [null]}); + objectsWithUsers.insert({name: "owned by one - a", ownerUserIds: [1]}); + objectsWithUsers.insert({name: "owned by one/two - a", ownerUserIds: [1, 2]}); + objectsWithUsers.insert({name: "owned by one/two - b", ownerUserIds: [1, 2]}); + objectsWithUsers.insert({name: "owned by two - a", ownerUserIds: [2]}); + objectsWithUsers.insert({name: "owned by two - b", ownerUserIds: [2]}); + + Meteor.publish("objectsWithUsers", function() { + return objectsWithUsers.find({ownerUserIds: this.userId}, + {fields: {ownerUserIds: 0}}); + }); + + userIdWhenStopped = null; + Meteor.publish("recordUserIdOnStop", function() { + var self = this; + self.onStop(function() { + userIdWhenStopped = self.userId; + }); + }); + + Meteor.methods({ + setUserId: function(userId) { + this.setUserId(userId); + }, + userIdWhenStopped: function() { + return userIdWhenStopped; + } + }); +} + +/*****/ + +/// Helper for "livedata - setUserId fails when called on server" + +if (Meteor.isServer) { + Meteor.startup(function() { + errorThrownWhenCallingSetUserIdDirectlyOnServer = null; + try { + Meteor.call("setUserId", 1000); + } catch (e) { + errorThrownWhenCallingSetUserIdDirectlyOnServer = e; + } + }); +} diff --git a/packages/livedata/livedata_tests.js b/packages/livedata/livedata_tests.js index 53cb7704f8..ff1e07b125 100644 --- a/packages/livedata/livedata_tests.js +++ b/packages/livedata/livedata_tests.js @@ -17,7 +17,7 @@ var failure = function (test, code, reason) { } } }; -} +}; Tinytest.add("livedata - Meteor.Error", function (test) { var error = new Meteor.Error(123, "kittens", "puppies"); @@ -105,6 +105,26 @@ testAsyncMulti("livedata - basic method invocation", [ expect(undefined, [12, {x: 13}])), undefined); }, + // test that `wait: false` is respected + function (test, expect) { + if (Meteor.isClient) { + Meteor.apply("delayedTrue", [], {wait: false}, expect(function(err, res) { + test.equal(res, false); + })); + Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", []); + } + }, + + // test that `wait: true` is respected + function(test, expect) { + if (Meteor.isClient) { + Meteor.apply("delayedTrue", [], {wait: true}, expect(function(err, res) { + test.equal(res, true); + })); + Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", []); + } + }, + function (test, expect) { // No callback @@ -184,12 +204,14 @@ testAsyncMulti("livedata - basic method invocation", [ ]); + + var checkBalances = function (test, a, b) { var alice = Ledger.findOne({name: "alice", world: test.runId()}); var bob = Ledger.findOne({name: "bob", world: test.runId()}); test.equal(alice.balance, a); test.equal(bob.balance, b); -} +}; var onQuiesce = function (f) { if (Meteor.isServer) @@ -204,6 +226,7 @@ testAsyncMulti("livedata - compound methods", [ function (test) { if (Meteor.isClient) Meteor.subscribe("ledger", test.runId()); + Ledger.insert({name: "alice", balance: 100, world: test.runId()}); Ledger.insert({name: "bob", balance: 50, world: test.runId()}); }, @@ -238,6 +261,127 @@ testAsyncMulti("livedata - compound methods", [ } ]); +// Replaces the LivedataConnection's `_livedata_data` method to push +// incoming messages on a given collection to an array. This can be +// used to verify that the right data is sent on the wire +// +// @param messages {Array} The array to which to append the messages +// @return {Function} A function to call to undo the eavesdropping +var eavesdropOnCollection = function(livedata_connection, + collection_name, messages) { + old_livedata_data = _.bind( + livedata_connection._livedata_data, livedata_connection); + + // Kind of gross since all tests past this one will run with this + // hook set up. That's probably fine since we only check a specific + // collection but still... + // + // Should we consider having a separate connection per Tinytest or + // some similar scheme? + livedata_connection._livedata_data = function(msg) { + if (msg.collection && msg.collection === collection_name) { + messages.push(msg); + } + old_livedata_data(msg); + }; + + return function() { + livedata_connection._livedata_data = old_livedata_data; + }; +}; + +testAsyncMulti("livedata - changing userid reruns subscriptions without flapping data on the wire", [ + function(test, expect) { + if (Meteor.isClient) { + var messages = []; + var undoEavesdrop = eavesdropOnCollection( + Meteor.default_connection, "objectsWithUsers", messages); + + // A helper for testing incoming set and unset messages + // XXX should this be extracted as a general helper together with + // eavesdropOnCollection? + var testSetAndUnset = function(expectation) { + test.equal(_.map(messages, function(msg) { + var result = {}; + if (msg.set) + result.set = msg.set.name; + if (msg.unset) + result.unset = true; + return result; + }), expectation); + messages.length = 0; // clear messages without creating a new object + }; + + Meteor.subscribe("objectsWithUsers", expect(function() { + testSetAndUnset([{set: "owned by none"}]); + test.equal(objectsWithUsers.find().count(), 1); + Meteor.defer(sendFirstSetUserId); + })); + + // Contorted since we need to call expect at the top level of a test + // (see comment at top of async_multi.js) + + var sendFirstSetUserId = expect(function() { + Meteor.apply("setUserId", [1], {wait: true}); + Meteor.default_connection.onQuiesce(afterFirstSetUserId); + }); + + var afterFirstSetUserId = expect(function() { + testSetAndUnset([ + {unset: true}, + {set: "owned by one - a"}, + {set: "owned by one/two - a"}, + {set: "owned by one/two - b"}]); + test.equal(objectsWithUsers.find().count(), 3); + Meteor.defer(sendSecondSetUserId); + }); + + var sendSecondSetUserId = expect(function() { + Meteor.apply("setUserId", [2], {wait: true}); + Meteor.default_connection.onQuiesce(afterSecondSetUserId); + }); + + var afterSecondSetUserId = expect(function() { + testSetAndUnset([ + {unset: true}, + {set: "owned by two - a"}, + {set: "owned by two - b"}]); + test.equal(objectsWithUsers.find().count(), 4); + Meteor.defer(sendThirdSetUserId); + }); + + var sendThirdSetUserId = expect(function() { + Meteor.apply("setUserId", [2], {wait: true}); + Meteor.default_connection.onQuiesce(afterThirdSetUserId); + }); + + var afterThirdSetUserId = expect(function() { + // Nothing should have been sent since the results of the + // query are the same ("don't flap data on the wire") + testSetAndUnset([]); + test.equal(objectsWithUsers.find().count(), 4); + undoEavesdrop(); + }); + } + }, function(test, expect) { + if (Meteor.isClient) { + Meteor.subscribe("recordUserIdOnStop"); + Meteor.apply("setUserId", [100], {wait: true}, expect(function() {})); + Meteor.apply("setUserId", [101], {wait: true}, expect(function() {})); + Meteor.call("userIdWhenStopped", expect(function(err, result) { + test.equal(result, 100); + })); + } + } +]); + +Tinytest.add("livedata - setUserId error when called from server", function(test) { + if (Meteor.isServer) { + test.equal(errorThrownWhenCallingSetUserIdDirectlyOnServer.message, + "Can't call setUserId on a server initiated method call"); + } +}); + // XXX some things to test in greater detail: // staying in simulation mode // time warp diff --git a/packages/localstorage-polyfill/localstorage_polyfill.js b/packages/localstorage-polyfill/localstorage_polyfill.js new file mode 100644 index 0000000000..98995137db --- /dev/null +++ b/packages/localstorage-polyfill/localstorage_polyfill.js @@ -0,0 +1,51 @@ +if (!window.localStorage) { + window.localStorage = (function () { + // XXX eliminate dependency on jQuery, detect browsers ourselves + if ($.browser.msie) { // If we are on IE, which support userData + var userdata = document.createElement('span'); // could be anything + userdata.style.behavior = 'url("#default#userData")'; + userdata.id = 'localstorage-polyfill-helper'; + userdata.style.display = 'none'; + document.getElementsByTagName("head")[0].appendChild(userdata); + + var userdataKey = 'localStorage'; + userdata.load(userdataKey); + + return { + setItem: function (key, val) { + userdata.setAttribute(key, val); + userdata.save(userdataKey); + }, + + removeItem: function (key) { + userdata.removeAttribute(key); + userdata.save(userdataKey); + }, + + getItem: function (key) { + userdata.load(userdataKey); + return userdata.getAttribute(key); + } + }; + } else { + Meteor._debug( + "You are running a browser with no localStorage or userData " + + "support. Logging in from one tab will not cause another " + + "tab to be logged in."); + + return { + _data: {}, + + setItem: function (key, val) { + this._data[key] = val; + }, + removeItem: function (key) { + delete this._data[key]; + }, + getItem: function (key) { + return this._data[key]; + } + }; + }; + })(); +} diff --git a/packages/localstorage-polyfill/localstorage_polyfill_tests.js b/packages/localstorage-polyfill/localstorage_polyfill_tests.js new file mode 100644 index 0000000000..af603d54b9 --- /dev/null +++ b/packages/localstorage-polyfill/localstorage_polyfill_tests.js @@ -0,0 +1,9 @@ +Tinytest.add("localStorage polyfill", function (test) { + // Doesn't actually test preservation across reloads since that is hard. + // userData should do that for us so it's unlikely this wouldn't work. + localStorage.setItem("key", "value"); + test.equal(localStorage.getItem("key"), "value"); + localStorage.removeItem("key"); + test.equal(localStorage.getItem("key"), null); +}); + diff --git a/packages/localstorage-polyfill/package.js b/packages/localstorage-polyfill/package.js new file mode 100644 index 0000000000..f6e269b2e2 --- /dev/null +++ b/packages/localstorage-polyfill/package.js @@ -0,0 +1,16 @@ +Package.describe({ + summary: "Simulates the localStorage API on IE 6,7 using userData", +}); + +Package.on_use(function (api) { + api.use('jquery', 'client'); // XXX only used for browser detection. remove. + + api.add_files('localstorage_polyfill.js', 'client'); +}); + +Package.on_test(function (api) { + api.use('localstorage-polyfill', 'client'); + api.use('tinytest'); + + api.add_files('localstorage_polyfill_tests.js', 'client'); +}); diff --git a/packages/madewith/madewith.js b/packages/madewith/madewith.js index 92fc13be5f..4b2c48844e 100644 --- a/packages/madewith/madewith.js +++ b/packages/madewith/madewith.js @@ -9,7 +9,7 @@ var sub = server.subscribe("myApp", hostname); // minimongo collection to hold my singleton app record. - var apps = new Meteor.Collection('madewith_apps', server); + var apps = new Meteor.Collection('madewith_apps', {manager: server}); server.methods({ vote: function (hostname) { diff --git a/packages/meteor/dynamics_browser.js b/packages/meteor/dynamics_browser.js index 9c61ff4e0e..ee85a4d336 100644 --- a/packages/meteor/dynamics_browser.js +++ b/packages/meteor/dynamics_browser.js @@ -27,6 +27,9 @@ }); Meteor.bindEnvironment = function (func, onException, _this) { + // needed in order to be able to create closures inside func and + // have the closed variables not change back to their original + // values var boundValues = _.clone(currentValues); if (!onException) diff --git a/packages/meteor/timers.js b/packages/meteor/timers.js index a7353b1a77..bdaa636ef8 100644 --- a/packages/meteor/timers.js +++ b/packages/meteor/timers.js @@ -42,10 +42,15 @@ _.extend(Meteor, { }, // won't be necessary once we clobber the global setTimeout + // + // XXX consider making this guarantee ordering of defer'd callbacks, like + // Meteor._atFlush or Node's nextTick (in practice). Then tests can do: + // callSomethingThatDefersSomeWork(); + // Meteor.defer(expect(somethingThatValidatesThatTheWorkHappened)); defer: function (f) { // Older Firefox will pass an argument to the setTimeout callback // function, indicating the "actual lateness." It's non-standard, // so for defer, standardize on not having it. Meteor.setTimeout(function () {f();}, 0); } -}); \ No newline at end of file +}); diff --git a/packages/mongo-livedata/allow_tests.js b/packages/mongo-livedata/allow_tests.js new file mode 100644 index 0000000000..15f92cde81 --- /dev/null +++ b/packages/mongo-livedata/allow_tests.js @@ -0,0 +1,591 @@ +(function () { + + // + // Set up a bunch of test collections + // + + // helper for defining a collection, subscribing to it, and defining + // a method to clear it + var defineCollection = function(name, insecure) { + var collection = new Meteor.Collection(name); + collection._insecure = insecure; + + if (Meteor.isServer) { + Meteor.publish("collection-" + name, function() { + return collection.find(); + }); + + var m = {}; + m["clear-collection-" + name] = function(runId) { + collection.remove({world: runId}); + }; + Meteor.methods(m); + } else { + Meteor.subscribe("collection-" + name); + } + + collection.callClearMethod = function (runId, callback) { + Meteor.call("clear-collection-" + name, runId, callback); + }; + return collection; + }; + + // totally insecure collection + var insecureCollection = defineCollection( + "collection-insecure", true /*insecure*/); + + // totally locked down collection + var lockedDownCollection = defineCollection( + "collection-locked-down", false /*insecure*/); + + // resticted collection with same allowed modifications, both with and + // without the `insecure` package + var restrictedCollectionDefaultSecure = defineCollection( + "collection-restrictedDefaultSecure", false /*insecure*/); + var restrictedCollectionDefaultInsecure = defineCollection( + "collection-restrictedDefaultInsecure", true /*insecure*/); + var restrictedCollectionForUpdateOptionsTest = defineCollection( + "collection-restrictedForUpdateOptionsTest", true /*insecure*/); + var restrictedCollectionForPartialAllowTest = defineCollection( + "collection-restrictedForPartialAllowTest", true /*insecure*/); + var restrictedCollectionForPartialDenyTest = defineCollection( + "collection-restrictedForPartialDenyTest", true /*insecure*/); + var restrictedCollectionForFetchTest = defineCollection( + "collection-restrictedForFetchTest", true /*insecure*/); + var restrictedCollectionForFetchAllTest = defineCollection( + "collection-restrictedForFetchAllTest", true /*insecure*/); + + + // + // Set up allow/deny rules for test collections + // + + + // two calls to allow to verify that either validator is sufficient. + var allows = [{ + insert: function(userId, doc) { + return doc.canInsert; + }, + update: function(userId, docs) { + return _.all(docs, function (doc) { + return doc.canUpdate; + }); + }, + remove: function (userId, docs) { + return _.all(docs, function (doc) { + return doc.canRemove; + }); + } + }, { + insert: function(userId, doc) { + return doc.canInsert2; + }, + update: function(userId, docs, fields, modifier) { + return -1 !== _.indexOf(fields, 'canUpdate2'); + }, + remove: function(userId, docs) { + return _.all(docs, function (doc) { + return doc.canRemove2; + }); + } + }]; + + // two calls to deny to verify that either one blocks the change. + var denies = [{ + insert: function(userId, doc) { + return doc.cantInsert; + }, + remove: function (userId, docs) { + return _.any(docs, function (doc) { + return doc.cantRemove; + }); + } + }, { + insert: function(userId, doc) { + return doc.cantInsert2; + }, + update: function(userId, docs, fields, modifier) { + return -1 !== _.indexOf(fields, 'verySecret'); + } + }]; + + + + + if (Meteor.isServer) { + _.each([ + restrictedCollectionDefaultSecure, + restrictedCollectionDefaultInsecure, + restrictedCollectionForUpdateOptionsTest + ], function (collection) { + _.each(allows, function (allow) { + collection.allow(allow); + }); + _.each(denies, function (deny) { + collection.deny(deny); + }); + }); + + // just restrict one operation so that we can verify that others + // fail + restrictedCollectionForPartialAllowTest.allow({ + insert: function() {} + }); + restrictedCollectionForPartialDenyTest.deny({ + insert: function() {} + }); + + + // verify that we only fetch the fields specified - we should + // be fetching just field1, field2, and field3. + restrictedCollectionForFetchTest.allow({ + insert: function() { return true; }, + update: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + remove: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + fetch: ['field1'] + }); + restrictedCollectionForFetchTest.allow({ + fetch: ['field2'] + }); + restrictedCollectionForFetchTest.deny({ + fetch: ['field3'] + }); + + // verify that not passing fetch to one of the calls to allow + // causes all fields to be fetched + restrictedCollectionForFetchAllTest.allow({ + insert: function() { return true; }, + update: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + remove: function(userId, docs) { + // throw fields in first doc so that we can inspect them in test + throw new Meteor.Error( + 999, "Test: Fields in doc: " + _.keys(docs[0]).join(',')); + }, + fetch: ['field1'] + }); + restrictedCollectionForFetchAllTest.allow({ + update: function() { return true; } + }); + } + + + // + // Begin actual tests + // + + if (Meteor.isServer) { + Tinytest.add("collection - calling allow restricts", function (test) { + var collection = new Meteor.Collection(null); + test.equal(collection._restricted, false); + collection.allow({ + insert: function() {} + }); + test.equal(collection._restricted, true); + }); + + Tinytest.add("collection - global insecure", function (test) { + // note: This test alters the global insecure status! This may + // collide with itself if run multiple times (but is better than + // the old test which had the same problem) + var oldGlobalInsecure = Meteor.Collection.insecure; + + Meteor.Collection.insecure = true; + var collection = new Meteor.Collection(null); + test.equal(collection._isInsecure(), true); + + Meteor.Collection.insecure = false; + test.equal(collection._isInsecure(), false); + + collection._insecure = true; + test.equal(collection._isInsecure(), true); + + Meteor.Collection.insecure = oldGlobalInsecure; + }); + + } + + if (Meteor.isClient) { + // test that if allow is called once then the collection is + // restricted, and that other mutations aren't allowed + testAsyncMulti("collection - partial allow", [ + function (test, expect) { + restrictedCollectionForPartialAllowTest.update( + {world: test.runId()}, {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.error, 403); + })); + } + ]); + + // test that if deny is called once then the collection is + // restricted, and that other mutations aren't allowed + testAsyncMulti("collection - partial deny", [ + function (test, expect) { + restrictedCollectionForPartialDenyTest.update( + {world: test.runId()}, {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.error, 403); + })); + } + ]); + + + // test that we only fetch the fields specified + testAsyncMulti("collection - fetch", [ + function (test, expect) { + restrictedCollectionForFetchTest.insert( + {field1: 1, field2: 1, field3: 1, field4: 1, + world: test.runId()}); + restrictedCollectionForFetchAllTest.insert( + {field1: 1, field2: 1, field3: 1, field4: 1, + world: test.runId()}); + + }, function (test, expect) { + restrictedCollectionForFetchTest.update( + {world: test.runId()}, + {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,_id"); + })); + restrictedCollectionForFetchTest.remove( + {world: test.runId()}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,_id"); + })); + + restrictedCollectionForFetchAllTest.update( + {world: test.runId()}, + {$set: {updated: true}}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,field4,world,_id"); + })); + restrictedCollectionForFetchAllTest.remove( + {world: test.runId()}, expect(function (err, res) { + test.equal(err.reason, + "Test: Fields in doc: field1,field2,field3,field4,world,_id"); + })); + + } + ]); + } + + if (Meteor.isClient) { + testAsyncMulti("collection - insecure", [ + function (test, expect) { + insecureCollection.callClearMethod(test.runId(), expect(function () { + test.equal(lockedDownCollection.find({world: test.runId()}).count(), 0); + })); + }, + function (test, expect) { + insecureCollection.insert({world: test.runId(), foo: 'bar'}, expect(function(err, res) { + test.equal(insecureCollection.find({world: test.runId()}).count(), 1); + test.equal(insecureCollection.findOne({world: test.runId()}).foo, 'bar'); + })); + test.equal(insecureCollection.find({world: test.runId()}).count(), 1); + test.equal(insecureCollection.findOne({world: test.runId()}).foo, 'bar'); + } + ]); + + testAsyncMulti("collection - locked down", [ + function (test, expect) { + lockedDownCollection.callClearMethod(test.runId(), expect(function() { + test.equal(lockedDownCollection.find({world: test.runId()}).count(), 0); + })); + }, + function (test, expect) { + lockedDownCollection.insert({world: test.runId(), foo: 'bar'}, expect(function (err, res) { + test.equal(err.error, 403); + })); + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(lockedDownCollection.find({world: test.runId()}).count(), 0); + })); + } + ]); + + (function () { + var collection = restrictedCollectionForUpdateOptionsTest; + var id1; + testAsyncMulti("collection - update options", [ + // init + function (test, expect) { + collection.callClearMethod(test.runId()); + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // put a few objects + function (test, expect) { + var doc = {canInsert: true, canUpdate: true, world: test.runId()}; + id1 = collection.insert(doc); + collection.insert(doc); + collection.insert(doc); + collection.insert(doc, expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 4); + })); + }, + // update by id + function (test, expect) { + collection.update( + id1, + {$set: {updated: true}}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 1); + })); + }, + // update without the `multi` option + function (test, expect) { + collection.update( + {updated: {$exists: false}, world: test.runId()}, + {$set: {updated: true}}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 2); + })); + }, + // update with the `multi` option + function (test, expect) { + collection.update( + {world: test.runId()}, + {$set: {updated: true}}, + {multi: true}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 4); + })); + } + ]); + }) (); + + _.each( + [restrictedCollectionDefaultInsecure, restrictedCollectionDefaultSecure], + function(collection) { + testAsyncMulti("collection - " + collection._name, [ + // init + function (test, expect) { + collection.callClearMethod(test.runId()); + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + + // insert with no allows passing. request is denied. + function (test, expect) { + collection.insert( + {world: test.runId()}, + expect(function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // insert with one allow and one deny. denied. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert: true, cantInsert: true}, + expect(function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // insert with one allow and other deny. denied. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert: true, cantInsert2: true}, + expect(function (err, res) { + test.equal(err.error, 403); + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + }, + // insert one allow passes. allowed. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert: true}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 1); + })); + }, + // insert other allow passes. allowed. + // includes canUpdate for later. + function (test, expect) { + collection.insert( + {world: test.runId(), canInsert2: true, canUpdate: true}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 2); + })); + }, + // yet a third insert executes. this one has canRemove and + // cantRemove set for later. + function (test, expect) { + collection.insert( + {canInsert: true, canRemove: true, cantRemove: true, + world: test.runId()}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId()}).count(), 3); + })); + }, + + // can't update to a new object + function (test, expect) { + collection.update( + {canUpdate:true, world: test.runId()}, + {newObject: 1}, + expect(function (err, res) { + test.equal(err.error, 403); + })); + // onQuiesce needed to wait for results to snap back. the + // update worked locally, and dropped the document out of + // this world. + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(collection.find({world:test.runId()}).count(), 3); + })); + }, + + // updating dotted fields works as if we are changing their + // top part + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {"dotted.field": 1}}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), canUpdate: true}).count(), 1); + test.equal(collection.findOne({world: test.runId(), canUpdate: true}).dotted.field, 1); + })); + }, + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {"verySecret.field": 1}}, + expect(function (err, res) { + test.equal(err.error, 403); + })); + }, + + // update doesn't do anything if no docs match + function (test, expect) { + collection.update( + {world: test.runId(), doesntExist: true}, + {$set: {updated: true}}, + expect(function (err, res) { + test.isFalse(err); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update fails when access is denied trying to set `verySecret` + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {verySecret: true}}, + expect(function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update fails when trying to set two fields, one of which is + // `verySecret` + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {updated: true, verySecret: true}}, + expect(function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update fails when trying to modify docs that don't + // have `canUpdate` set + function (test, expect) { + collection.update( + {world: test.runId(), canRemove: true}, + {$set: {updated: true}}, + expect(function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 0); + })); + }, + // update executes when it should + function (test, expect) { + collection.update( + {world: test.runId(), canUpdate: true}, + {$set: {updated: true}}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), updated: true}).count(), 1); + })); + }, + + // remove fails when trying to modify an doc with no + // `canRemove` set + function (test, expect) { + collection.remove({world: test.runId(), canUpdate: true}, expect(function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + })); + }, + // remove fails when trying to modify an doc with `cantRemove` + // set + function (test, expect) { + collection.remove({world: test.runId(), canRemove: true}, expect(function (err, res) { + test.equal(err.error, 403); + // nothing has changed + test.equal(collection.find({world: test.runId()}).count(), 3); + })); + }, + + // update the doc to remove cantRemove. + function (test, expect) { + collection.update( + {world: test.runId(), canRemove: true}, + {$set: {cantRemove: false, canUpdate2: true}}, + expect(function (err, res) { + test.isFalse(err); + test.equal(collection.find({world: test.runId(), cantRemove: true}).count(), 0); + })); + }, + + // now remove can remove it. + function (test, expect) { + collection.remove({world: test.runId(), canRemove: true}, expect(function (err, res) { + test.isFalse(err); + // successfully removed + test.equal(collection.find({world: test.runId()}).count(), 2); + })); + }, + + // methods can still bypass restrictions + function (test, expect) { + collection.callClearMethod(test.runId(), expect(function (err, res) { + test.isFalse(err); + // successfully removed + })); + Meteor.default_connection.onQuiesce(expect(function () { + test.equal(collection.find({world: test.runId()}).count(), 0); + })); + } + ]); + }); + } +}) (); diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index b8bb29837f..7ebfdc8136 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -1,7 +1,19 @@ // manager, if given, is a LivedataClient or LivedataServer // XXX presently there is no way to destroy/clean up a Collection -Meteor.Collection = function (name, manager, driver) { +Meteor.Collection = function (name, options) { var self = this; + if (options && options.methods) { + // Backwards compatibility hack with original signature (which passed + // "manager" directly instead of in options. (Managers must have a "methods" + // method.) + // XXX remove before 1.0 + options = {manager: options}; + } + options = _.extend({ + manager: undefined, + _driver: undefined, + _preventAutopublish: false + }, options); if (!name && (name !== null)) { Meteor._debug("Warning: creating anonymous collection. It will not be " + @@ -10,28 +22,27 @@ Meteor.Collection = function (name, manager, driver) { } // note: nameless collections never have a manager - manager = name && (manager || - (Meteor.isClient ? - Meteor.default_connection : Meteor.default_server)); + self._manager = name && (options.manager || + (Meteor.isClient ? + Meteor.default_connection : Meteor.default_server)); - if (!driver) { - if (name && manager === Meteor.default_server && + if (!options._driver) { + if (name && self._manager === Meteor.default_server && Meteor._RemoteCollectionDriver) - driver = Meteor._RemoteCollectionDriver; + options._driver = Meteor._RemoteCollectionDriver; else - driver = Meteor._LocalCollectionDriver; + options._driver = Meteor._LocalCollectionDriver; } - self._manager = manager; - self._driver = driver; - self._collection = driver.open(name); + self._collection = options._driver.open(name); self._was_snapshot = false; + self._name = name; - if (name && manager.registerStore) { + if (name && self._manager.registerStore) { // OK, we're going to be a slave, replicating some remote // database, except possibly with some temporary divergence while // we have unacknowledged RPC's. - var ok = manager.registerStore(name, { + var ok = self._manager.registerStore(name, { // Called at the beginning of a batch of updates. We're supposed to start // by backing out any local writes and returning to the last state // delivered by the server. batchSize is the number of update calls to @@ -90,40 +101,22 @@ Meteor.Collection = function (name, manager, driver) { throw new Error("There is already a collection named '" + name + "'"); } - // mutation methods - if (manager) { - var m = {}; - // XXX what if name has illegal characters in it? - self._prefix = '/' + name + '/'; - m[self._prefix + 'insert'] = function (/* selector, options */) { - self._maybe_snapshot(); - // insert returns nothing. allow exceptions to propagate. - self._collection.insert.apply(self._collection, _.toArray(arguments)); - }; - - m[self._prefix + 'update'] = function (/* selector, mutator, options */) { - self._maybe_snapshot(); - // update returns nothing. allow exceptions to propagate. - self._collection.update.apply(self._collection, _.toArray(arguments)); - }; - - m[self._prefix + 'remove'] = function (/* selector */) { - self._maybe_snapshot(); - // remove returns nothing. allow exceptions to propagate. - self._collection.remove.apply(self._collection, _.toArray(arguments)); - }; - - manager.methods(m); - } + self._defineMutationMethods(); // autopublish - if (manager && manager.onAutopublish) - manager.onAutopublish(function () { + if (!options._preventAutopublish && + self._manager && self._manager.onAutopublish) + self._manager.onAutopublish(function () { var handler = function () { return self.find(); }; - manager.publish(null, handler, {is_auto: true}); + self._manager.publish(null, handler, {is_auto: true}); }); }; +/// +/// Main collection API +/// + + _.extend(Meteor.Collection.prototype, { find: function (/* selector, options */) { // Collection.find() (return all docs) behaves differently @@ -148,6 +141,7 @@ _.extend(Meteor.Collection.prototype, { }); + // 'insert' immediately returns the inserted document's new _id. The // others return nothing. // @@ -182,7 +176,7 @@ _.each(["insert", "update", "remove"], function (name) { if (args.length && args[args.length - 1] instanceof Function) callback = args.pop(); - if (Meteor.isClient && !callback) + if (Meteor.isClient && !callback) { // Client can't block, so it can't report errors by exception, // only by callback. If they forget the callback, give them a // default one that logs the error, so they aren't totally @@ -192,6 +186,7 @@ _.each(["insert", "update", "remove"], function (name) { if (err) Meteor._debug(name + " failed: " + err.error + " -- " + err.reason); }; + } if (name === "insert") { if (!args.length) @@ -206,16 +201,17 @@ _.each(["insert", "update", "remove"], function (name) { if (self._manager && self._manager !== Meteor.default_server) { // just remote to another endpoint, propagate return value or // exception. - if (callback) + if (callback) { // asynchronous: on success, callback should return ret // (document ID for insert, undefined for update and // remove), not the method's result. self._manager.apply(self._prefix + name, args, function (error, result) { callback(error, !error && ret); }); - else + } else { // synchronous: propagate exception self._manager.apply(self._prefix + name, args); + } } else { // it's my collection. descend into the collection object @@ -239,3 +235,303 @@ _.each(["insert", "update", "remove"], function (name) { return ret; }; }); + +// We'll actually design an index API later. For now, we just pass through to +// Mongo's, but make it synchronous. +Meteor.Collection.prototype._ensureIndex = function (index, options) { + var self = this; + if (!self._collection._ensureIndex) + throw new Error("Can only call _ensureIndex on server collections"); + self._collection._ensureIndex(index, options); +}; + +/// +/// Remote methods and access control. +/// + +// Restrict default mutators on collection. allow() and deny() take the +// same options: +// +// options.insert {Function(userId, doc)} +// return true to allow/deny adding this document +// +// options.update {Function(userId, docs, fields, modifier)} +// return true to allow/deny updating these documents. +// `fields` is passed as an array of fields that are to be modified +// +// options.remove {Function(userId, docs)} +// return true to allow/deny removing these documents +// +// options.fetch {Array} +// Fields to fetch for these validators. If any call to allow or deny +// does not have this option then all fields are loaded. +// +// allow and deny can be called multiple times. The validators are +// evaluated as follows: +// - If neither deny() nor allow() has been called on the collection, +// then the request is allowed if and only if the "insecure" smart +// package is in use. +// - Otherwise, if any deny() function returns true, the request is denied. +// - Otherwise, if any allow() function returns true, the request is allowed. +// - Otherwise, the request is denied. +// +// Meteor may call your deny() and allow() functions in any order, and may not +// call all of them if it is able to make a decision without calling them all +// (so don't include side effects). + +(function () { + var addValidator = function(allowOrDeny, options) { + var self = this; + self._restricted = true; + + _.each(['insert', 'update', 'remove'], function (name) { + if (options[name]) + self._validators[name][allowOrDeny].push(options[name]); + }); + + // Only update the fetch fields if we're passed things that affect + // fetching. This way allow({}) and allow({insert: f}) don't result in + // setting fetchAllFields + if (options.update || options.remove || options.fetch) + self._updateFetch(options.fetch); + }; + + Meteor.Collection.prototype.allow = function(options) { + addValidator.call(this, 'allow', options); + }; + Meteor.Collection.prototype.deny = function(options) { + addValidator.call(this, 'deny', options); + }; +})(); + +Meteor.Collection.prototype._defineMutationMethods = function() { + var self = this; + + // set to true once we call any allow or deny methods. If true, use + // allow/deny semantics. If false, use insecure mode semantics. + self._restricted = false; + + // Insecure mode (default to allowing writes). Defaults to 'undefined' + // which means use the global Meteor.Collection.insecure. This + // property can be overriden by tests or packages wishing to change + // insecure mode behavior of their collections. + self._insecure = undefined; + + self._validators = { + insert: {allow: [], deny: []}, + update: {allow: [], deny: []}, + remove: {allow: [], deny: []}, + fetch: [], + fetchAllFields: false + }; + + if (!self._name) + return; // anonymous collection + + // XXX Think about method namespacing. Maybe methods should be + // "Meteor:Mongo:insert/NAME"? + self._prefix = '/' + self._name + '/'; + + // mutation methods + if (self._manager) { + var m = {}; + + _.each(['insert', 'update', 'remove'], function (method) { + m[self._prefix + method] = function (/* ... */) { + self._maybe_snapshot(); + + if (this.isSimulation || (!self._restricted && self._isInsecure())) { + self._collection[method].apply( + self._collection, _.toArray(arguments)); + } else if (self._restricted) { + // short circuit if there is no way it will pass. + if (self._validators[method].allow.length === 0) { + throw new Meteor.Error( + 403, "Access denied. No allow validators set on restricted " + + "collection."); + } + + var validatedMethodName = + '_validated' + method.charAt(0).toUpperCase() + method.slice(1); + var argsWithUserId = [this.userId].concat(_.toArray(arguments)); + self[validatedMethodName].apply(self, argsWithUserId); + } else { + throw new Meteor.Error(403, "Access denied"); + } + }; + }); + + self._manager.methods(m); + } +}; + + +Meteor.Collection.prototype._updateFetch = function (fields) { + var self = this; + + if (!self._validators.fetchAllFields) { + if (fields) { + self._validators.fetch = _.union(self._validators.fetch, fields); + } else { + self._validators.fetchAllFields = true; + // clear fetch just to make sure we don't accidentally read it + self._validators.fetch = null; + } + } +}; + +Meteor.Collection.prototype._isInsecure = function () { + var self = this; + if (self._insecure === undefined) + return Meteor.Collection.insecure; + return self._insecure; +}; + +Meteor.Collection.prototype._validatedInsert = function(userId, doc) { + var self = this; + + // call user validators. + // Any deny returns true means denied. + if (_.any(self._validators.insert.deny, function(validator) { + return validator(userId, doc); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (_.all(self._validators.insert.allow, function(validator) { + return !validator(userId, doc); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + self._collection.insert.call(self._collection, doc); +}; + +// Simulate a mongo `update` operation while validating that the access +// control rules set by calls to `allow/deny` are satisfied. If all +// pass, rewrite the mongo operation to use $in to set the list of +// document ids to change ##ValidatedChange +Meteor.Collection.prototype._validatedUpdate = function( + userId, selector, mutator, options) { + var self = this; + + // compute modified fields + var fields = []; + _.each(mutator, function (params, op) { + if (op[0] !== '$') { + throw new Meteor.Error( + 403, "Access denied. Can't replace document in restricted collection."); + } else { + _.each(_.keys(params), function (field) { + // treat dotted fields as if they are replacing their + // top-level part + if (field.indexOf('.') !== -1) + field = field.substring(0, field.indexOf('.')); + + // record the field we are trying to change + if (!_.contains(fields, field)) + fields.push(field); + }); + } + }); + + var findOptions = {}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + _.each(self._validators.fetch, function(fieldName) { + findOptions.fields[fieldName] = 1; + }); + } + + var docs; + if (options && options.multi) { + docs = self._collection.find(selector, findOptions).fetch(); + if (docs.length === 0) // none satisfied! + return; + } else { + var doc = self._collection.findOne(selector, findOptions); + if (!doc) // none satisfied! + return; + docs = [doc]; + } + + // call user validators. + // Any deny returns true means denied. + if (_.any(self._validators.update.deny, function(validator) { + return validator(userId, docs, fields, mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (_.all(self._validators.update.allow, function(validator) { + return !validator(userId, docs, fields, mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // Construct new $in selector to augment the original one. This means we'll + // never update any doc we didn't validate. We keep around the original + // selector so that we don't mutate any docs that have been updated to no + // longer match the original selector. + var idInClause = {}; + idInClause.$in = _.map(docs, function(doc) { + return doc._id; + }); + var idSelector = {_id: idInClause}; + + var fullSelector; + if (LocalCollection._selectorIsId(selector)) { + // If the original selector was just a lookup by _id, no need to "and" it + // with the idSelector (and it won't work anyway without explicitly + // comparing with _id). + if (docs.length !== 1 || docs[0]._id !== selector) + throw new Error("Lookup by ID " + selector + " found something else"); + fullSelector = selector; + } else { + fullSelector = {$and: [selector, idSelector]}; + } + + self._collection.update.call( + self._collection, fullSelector, mutator, options); +}; + +// Simulate a mongo `remove` operation while validating access control +// rules. See #ValidatedChange +Meteor.Collection.prototype._validatedRemove = function(userId, selector) { + var self = this; + + var findOptions = {}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + _.each(self._validators.fetch, function(fieldName) { + findOptions.fields[fieldName] = 1; + }); + } + + var docs = self._collection.find(selector, findOptions).fetch(); + if (docs.length === 0) // none satisfied! + return; + + // call user validators. + // Any deny returns true means denied. + if (_.any(self._validators.remove.deny, function(validator) { + return validator(userId, docs); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (_.all(self._validators.remove.allow, function(validator) { + return !validator(userId, docs); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // construct new $in selector to replace the original one + var idInClause = {}; + idInClause.$in = _.map(docs, function(doc) { + return doc._id; + }); + var idSelector = {_id: idInClause}; + + self._collection.remove.call(self._collection, idSelector); +}; diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 47d057befd..8a82cb8fc5 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -105,13 +105,6 @@ _Mongo.prototype.insert = function (collection_name, document) { var write = self._maybeBeginWrite(); - var finish = Meteor.bindEnvironment(function () { - Meteor.refresh({collection: collection_name}); - write.committed(); - }, function (e) { - Meteor._debug("Exception while completing insert: " + e.stack); - }); - var future = new Future; self._withCollection(collection_name, function (err, collection) { if (err) { @@ -120,17 +113,13 @@ _Mongo.prototype.insert = function (collection_name, document) { } collection.insert(document, {safe: true}, function (err) { - if (err) { - future.ret(err); - return; - } - - finish(); - future.ret(); + future.ret(err); }); }); var err = future.wait(); + Meteor.refresh({collection: collection_name}); + write.committed(); if (err) throw err; }; @@ -147,13 +136,6 @@ _Mongo.prototype.remove = function (collection_name, selector) { var write = self._maybeBeginWrite(); - var finish = Meteor.bindEnvironment(function () { - Meteor.refresh({collection: collection_name}); - write.committed(); - }, function (e) { - Meteor._debug("Exception while completing remove: " + e.stack); - }); - // XXX does not allow options. matches the client. selector = _Mongo._rewriteSelector(selector); @@ -165,17 +147,13 @@ _Mongo.prototype.remove = function (collection_name, selector) { } collection.remove(selector, {safe: true}, function (err) { - if (err) { - future.ret(err); - return; - } - - finish(); - future.ret(); + future.ret(err); }); }); var err = future.wait(); + Meteor.refresh({collection: collection_name}); + write.committed(); if (err) throw err; }; @@ -192,13 +170,6 @@ _Mongo.prototype.update = function (collection_name, selector, mod, options) { var write = self._maybeBeginWrite(); - var finish = Meteor.bindEnvironment(function () { - Meteor.refresh({collection: collection_name}); - write.committed(); - }, function (e) { - Meteor._debug("Exception while completing update: " + e.stack); - }); - selector = _Mongo._rewriteSelector(selector); if (!options) options = {}; @@ -215,17 +186,13 @@ _Mongo.prototype.update = function (collection_name, selector, mod, options) { if (options.multi) opts.multi = true; collection.update(selector, mod, opts, function (err) { - if (err) { - future.ret(err); - return; - } - - finish(); - future.ret(); + future.ret(err); }); }); var err = future.wait(); + Meteor.refresh({collection: collection_name}); + write.committed(); if (err) throw err; }; @@ -248,6 +215,32 @@ _Mongo.prototype.findOne = function (collection_name, selector, options) { return self.find(collection_name, selector, options).fetch()[0]; }; +// We'll actually design an index API later. For now, we just pass through to +// Mongo's, but make it synchronous. +_Mongo.prototype._ensureIndex = function (collectionName, index, options) { + var self = this; + options = _.extend({safe: true}, options); + + // We expect this function to be called at startup, not from within a method, + // so we don't interact with the write fence. + var future = new Future; + self._withCollection(collectionName, function (err, collection) { + if (err) { + future.throw(err); + return; + } + // XXX do we have to bindEnv or Fiber.run this callback? + collection.ensureIndex(index, options, function (err, indexName) { + if (err) { + future.throw(err); + return; + } + future.ret(); + }); + }); + future.wait(); +}; + // Cursors // Returns a _Mongo.Cursor, or throws an exception on diff --git a/packages/mongo-livedata/package.js b/packages/mongo-livedata/package.js index 89a3813aad..98ff164b35 100644 --- a/packages/mongo-livedata/package.js +++ b/packages/mongo-livedata/package.js @@ -27,4 +27,5 @@ Package.on_test(function (api) { api.use('tinytest'); api.use('test-helpers'); api.add_files('mongo_livedata_tests.js', ['client', 'server']); + api.add_files('allow_tests.js', ['client', 'server']); }); \ No newline at end of file diff --git a/packages/mongo-livedata/remote_collection_driver.js b/packages/mongo-livedata/remote_collection_driver.js index a827fe38ad..50e372309f 100644 --- a/packages/mongo-livedata/remote_collection_driver.js +++ b/packages/mongo-livedata/remote_collection_driver.js @@ -8,9 +8,11 @@ _.extend(Meteor._RemoteCollectionDriver.prototype, { open: function (name) { var self = this; var ret = {}; - _.each(['find', 'findOne', 'insert', 'update', 'remove'], function (m) { - ret[m] = _.bind(self.mongo[m], self.mongo, name); - }); + _.each( + ['find', 'findOne', 'insert', 'update', 'remove', '_ensureIndex'], + function (m) { + ret[m] = _.bind(self.mongo[m], self.mongo, name); + }); return ret; } }); diff --git a/packages/srp/biginteger.js b/packages/srp/biginteger.js new file mode 100644 index 0000000000..676e01f9a8 --- /dev/null +++ b/packages/srp/biginteger.js @@ -0,0 +1,1279 @@ +/// METEOR WRAPPER +if (typeof Meteor._srp === "undefined") + Meteor._srp = {}; +Meteor._srp.BigInteger = (function () { + + +/// BEGIN jsbn.js + +/* + * Copyright (c) 2003-2005 Tom Wu + * All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * + * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * In addition, the following condition applies: + * + * All redistributions must retain an intact copy of this copyright notice + * and disclaimer. + */ + +// Basic JavaScript BN library - subset useful for RSA encryption. + +// Bits per digit +var dbits; + +// JavaScript engine analysis +var canary = 0xdeadbeefcafe; +var j_lm = ((canary&0xffffff)==0xefcafe); + +// (public) Constructor +function BigInteger(a,b,c) { + if(a != null) + if("number" == typeof a) this.fromNumber(a,b,c); + else if(b == null && "string" != typeof a) this.fromString(a,256); + else this.fromString(a,b); +} + +// return new, unset BigInteger +function nbi() { return new BigInteger(null); } + +// am: Compute w_j += (x*this_i), propagate carries, +// c is initial carry, returns final carry. +// c < 3*dvalue, x < 2*dvalue, this_i < dvalue +// We need to select the fastest one that works in this environment. + +// am1: use a single mult and divide to get the high bits, +// max digit bits should be 26 because +// max internal value = 2*dvalue^2-2*dvalue (< 2^53) +function am1(i,x,w,j,c,n) { + while(--n >= 0) { + var v = x*this[i++]+w[j]+c; + c = Math.floor(v/0x4000000); + w[j++] = v&0x3ffffff; + } + return c; +} +// am2 avoids a big mult-and-extract completely. +// Max digit bits should be <= 30 because we do bitwise ops +// on values up to 2*hdvalue^2-hdvalue-1 (< 2^31) +function am2(i,x,w,j,c,n) { + var xl = x&0x7fff, xh = x>>15; + while(--n >= 0) { + var l = this[i]&0x7fff; + var h = this[i++]>>15; + var m = xh*l+h*xl; + l = xl*l+((m&0x7fff)<<15)+w[j]+(c&0x3fffffff); + c = (l>>>30)+(m>>>15)+xh*h+(c>>>30); + w[j++] = l&0x3fffffff; + } + return c; +} +// Alternately, set max digit bits to 28 since some +// browsers slow down when dealing with 32-bit numbers. +function am3(i,x,w,j,c,n) { + var xl = x&0x3fff, xh = x>>14; + while(--n >= 0) { + var l = this[i]&0x3fff; + var h = this[i++]>>14; + var m = xh*l+h*xl; + l = xl*l+((m&0x3fff)<<14)+w[j]+c; + c = (l>>28)+(m>>14)+xh*h; + w[j++] = l&0xfffffff; + } + return c; +} + +/* XXX METEOR XXX +if(j_lm && (navigator.appName == "Microsoft Internet Explorer")) { + BigInteger.prototype.am = am2; + dbits = 30; +} +else if(j_lm && (navigator.appName != "Netscape")) { + BigInteger.prototype.am = am1; + dbits = 26; +} +else +*/ + +{ // Mozilla/Netscape seems to prefer am3 + BigInteger.prototype.am = am3; + dbits = 28; +} + +BigInteger.prototype.DB = dbits; +BigInteger.prototype.DM = ((1<= 0; --i) r[i] = this[i]; + r.t = this.t; + r.s = this.s; +} + +// (protected) set from integer value x, -DV <= x < DV +function bnpFromInt(x) { + this.t = 1; + this.s = (x<0)?-1:0; + if(x > 0) this[0] = x; + else if(x < -1) this[0] = x+DV; + else this.t = 0; +} + +// return bigint initialized to value +function nbv(i) { var r = nbi(); r.fromInt(i); return r; } + +// (protected) set from string and radix +function bnpFromString(s,b) { + var k; + if(b == 16) k = 4; + else if(b == 8) k = 3; + else if(b == 256) k = 8; // byte array + else if(b == 2) k = 1; + else if(b == 32) k = 5; + else if(b == 4) k = 2; + else { this.fromRadix(s,b); return; } + this.t = 0; + this.s = 0; + var i = s.length, mi = false, sh = 0; + while(--i >= 0) { + var x = (k==8)?s[i]&0xff:intAt(s,i); + if(x < 0) { + if(s.charAt(i) == "-") mi = true; + continue; + } + mi = false; + if(sh == 0) + this[this.t++] = x; + else if(sh+k > this.DB) { + this[this.t-1] |= (x&((1<<(this.DB-sh))-1))<>(this.DB-sh)); + } + else + this[this.t-1] |= x<= this.DB) sh -= this.DB; + } + if(k == 8 && (s[0]&0x80) != 0) { + this.s = -1; + if(sh > 0) this[this.t-1] |= ((1<<(this.DB-sh))-1)< 0 && this[this.t-1] == c) --this.t; +} + +// (public) return string representation in given radix +function bnToString(b) { + if(this.s < 0) return "-"+this.negate().toString(b); + var k; + if(b == 16) k = 4; + else if(b == 8) k = 3; + else if(b == 2) k = 1; + else if(b == 32) k = 5; + else if(b == 4) k = 2; + else return this.toRadix(b); + var km = (1< 0) { + if(p < this.DB && (d = this[i]>>p) > 0) { m = true; r = int2char(d); } + while(i >= 0) { + if(p < k) { + d = (this[i]&((1<>(p+=this.DB-k); + } + else { + d = (this[i]>>(p-=k))&km; + if(p <= 0) { p += this.DB; --i; } + } + if(d > 0) m = true; + if(m) r += int2char(d); + } + } + return m?r:"0"; +} + +// (public) -this +function bnNegate() { var r = nbi(); BigInteger.ZERO.subTo(this,r); return r; } + +// (public) |this| +function bnAbs() { return (this.s<0)?this.negate():this; } + +// (public) return + if this > a, - if this < a, 0 if equal +function bnCompareTo(a) { + var r = this.s-a.s; + if(r != 0) return r; + var i = this.t; + r = i-a.t; + if(r != 0) return r; + while(--i >= 0) if((r=this[i]-a[i]) != 0) return r; + return 0; +} + +// returns bit length of the integer x +function nbits(x) { + var r = 1, t; + if((t=x>>>16) != 0) { x = t; r += 16; } + if((t=x>>8) != 0) { x = t; r += 8; } + if((t=x>>4) != 0) { x = t; r += 4; } + if((t=x>>2) != 0) { x = t; r += 2; } + if((t=x>>1) != 0) { x = t; r += 1; } + return r; +} + +// (public) return the number of bits in "this" +function bnBitLength() { + if(this.t <= 0) return 0; + return this.DB*(this.t-1)+nbits(this[this.t-1]^(this.s&this.DM)); +} + +// (protected) r = this << n*DB +function bnpDLShiftTo(n,r) { + var i; + for(i = this.t-1; i >= 0; --i) r[i+n] = this[i]; + for(i = n-1; i >= 0; --i) r[i] = 0; + r.t = this.t+n; + r.s = this.s; +} + +// (protected) r = this >> n*DB +function bnpDRShiftTo(n,r) { + for(var i = n; i < this.t; ++i) r[i-n] = this[i]; + r.t = Math.max(this.t-n,0); + r.s = this.s; +} + +// (protected) r = this << n +function bnpLShiftTo(n,r) { + var bs = n%this.DB; + var cbs = this.DB-bs; + var bm = (1<= 0; --i) { + r[i+ds+1] = (this[i]>>cbs)|c; + c = (this[i]&bm)<= 0; --i) r[i] = 0; + r[ds] = c; + r.t = this.t+ds+1; + r.s = this.s; + r.clamp(); +} + +// (protected) r = this >> n +function bnpRShiftTo(n,r) { + r.s = this.s; + var ds = Math.floor(n/this.DB); + if(ds >= this.t) { r.t = 0; return; } + var bs = n%this.DB; + var cbs = this.DB-bs; + var bm = (1<>bs; + for(var i = ds+1; i < this.t; ++i) { + r[i-ds-1] |= (this[i]&bm)<>bs; + } + if(bs > 0) r[this.t-ds-1] |= (this.s&bm)<>= this.DB; + } + if(a.t < this.t) { + c -= a.s; + while(i < this.t) { + c += this[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c += this.s; + } + else { + c += this.s; + while(i < a.t) { + c -= a[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c -= a.s; + } + r.s = (c<0)?-1:0; + if(c < -1) r[i++] = this.DV+c; + else if(c > 0) r[i++] = c; + r.t = i; + r.clamp(); +} + +// (protected) r = this * a, r != this,a (HAC 14.12) +// "this" should be the larger one if appropriate. +function bnpMultiplyTo(a,r) { + var x = this.abs(), y = a.abs(); + var i = x.t; + r.t = i+y.t; + while(--i >= 0) r[i] = 0; + for(i = 0; i < y.t; ++i) r[i+x.t] = x.am(0,y[i],r,i,0,x.t); + r.s = 0; + r.clamp(); + if(this.s != a.s) BigInteger.ZERO.subTo(r,r); +} + +// (protected) r = this^2, r != this (HAC 14.16) +function bnpSquareTo(r) { + var x = this.abs(); + var i = r.t = 2*x.t; + while(--i >= 0) r[i] = 0; + for(i = 0; i < x.t-1; ++i) { + var c = x.am(i,x[i],r,2*i,0,1); + if((r[i+x.t]+=x.am(i+1,2*x[i],r,2*i+1,c,x.t-i-1)) >= x.DV) { + r[i+x.t] -= x.DV; + r[i+x.t+1] = 1; + } + } + if(r.t > 0) r[r.t-1] += x.am(i,x[i],r,2*i,0,1); + r.s = 0; + r.clamp(); +} + +// (protected) divide this by m, quotient and remainder to q, r (HAC 14.20) +// r != q, this != m. q or r may be null. +function bnpDivRemTo(m,q,r) { + var pm = m.abs(); + if(pm.t <= 0) return; + var pt = this.abs(); + if(pt.t < pm.t) { + if(q != null) q.fromInt(0); + if(r != null) this.copyTo(r); + return; + } + if(r == null) r = nbi(); + var y = nbi(), ts = this.s, ms = m.s; + var nsh = this.DB-nbits(pm[pm.t-1]); // normalize modulus + if(nsh > 0) { pm.lShiftTo(nsh,y); pt.lShiftTo(nsh,r); } + else { pm.copyTo(y); pt.copyTo(r); } + var ys = y.t; + var y0 = y[ys-1]; + if(y0 == 0) return; + var yt = y0*(1<1)?y[ys-2]>>this.F2:0); + var d1 = this.FV/yt, d2 = (1<= 0) { + r[r.t++] = 1; + r.subTo(t,r); + } + BigInteger.ONE.dlShiftTo(ys,t); + t.subTo(y,y); // "negative" y so we can replace sub with am later + while(y.t < ys) y[y.t++] = 0; + while(--j >= 0) { + // Estimate quotient digit + var qd = (r[--i]==y0)?this.DM:Math.floor(r[i]*d1+(r[i-1]+e)*d2); + if((r[i]+=y.am(0,qd,r,j,0,ys)) < qd) { // Try it out + y.dlShiftTo(j,t); + r.subTo(t,r); + while(r[i] < --qd) r.subTo(t,r); + } + } + if(q != null) { + r.drShiftTo(ys,q); + if(ts != ms) BigInteger.ZERO.subTo(q,q); + } + r.t = ys; + r.clamp(); + if(nsh > 0) r.rShiftTo(nsh,r); // Denormalize remainder + if(ts < 0) BigInteger.ZERO.subTo(r,r); +} + +// (public) this mod a +function bnMod(a) { + var r = nbi(); + this.abs().divRemTo(a,null,r); + if(this.s < 0 && r.compareTo(BigInteger.ZERO) > 0) a.subTo(r,r); + return r; +} + +// Modular reduction using "classic" algorithm +function Classic(m) { this.m = m; } +function cConvert(x) { + if(x.s < 0 || x.compareTo(this.m) >= 0) return x.mod(this.m); + else return x; +} +function cRevert(x) { return x; } +function cReduce(x) { x.divRemTo(this.m,null,x); } +function cMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); } +function cSqrTo(x,r) { x.squareTo(r); this.reduce(r); } + +Classic.prototype.convert = cConvert; +Classic.prototype.revert = cRevert; +Classic.prototype.reduce = cReduce; +Classic.prototype.mulTo = cMulTo; +Classic.prototype.sqrTo = cSqrTo; + +// (protected) return "-1/this % 2^DB"; useful for Mont. reduction +// justification: +// xy == 1 (mod m) +// xy = 1+km +// xy(2-xy) = (1+km)(1-km) +// x[y(2-xy)] = 1-k^2m^2 +// x[y(2-xy)] == 1 (mod m^2) +// if y is 1/x mod m, then y(2-xy) is 1/x mod m^2 +// should reduce x and y(2-xy) by m^2 at each step to keep size bounded. +// JS multiply "overflows" differently from C/C++, so care is needed here. +function bnpInvDigit() { + if(this.t < 1) return 0; + var x = this[0]; + if((x&1) == 0) return 0; + var y = x&3; // y == 1/x mod 2^2 + y = (y*(2-(x&0xf)*y))&0xf; // y == 1/x mod 2^4 + y = (y*(2-(x&0xff)*y))&0xff; // y == 1/x mod 2^8 + y = (y*(2-(((x&0xffff)*y)&0xffff)))&0xffff; // y == 1/x mod 2^16 + // last step - calculate inverse mod DV directly; + // assumes 16 < DB <= 32 and assumes ability to handle 48-bit ints + y = (y*(2-x*y%this.DV))%this.DV; // y == 1/x mod 2^dbits + // we really want the negative inverse, and -DV < y < DV + return (y>0)?this.DV-y:-y; +} + +// Montgomery reduction +function Montgomery(m) { + this.m = m; + this.mp = m.invDigit(); + this.mpl = this.mp&0x7fff; + this.mph = this.mp>>15; + this.um = (1<<(m.DB-15))-1; + this.mt2 = 2*m.t; +} + +// xR mod m +function montConvert(x) { + var r = nbi(); + x.abs().dlShiftTo(this.m.t,r); + r.divRemTo(this.m,null,r); + if(x.s < 0 && r.compareTo(BigInteger.ZERO) > 0) this.m.subTo(r,r); + return r; +} + +// x/R mod m +function montRevert(x) { + var r = nbi(); + x.copyTo(r); + this.reduce(r); + return r; +} + +// x = x/R mod m (HAC 14.32) +function montReduce(x) { + while(x.t <= this.mt2) // pad x so am has enough room later + x[x.t++] = 0; + for(var i = 0; i < this.m.t; ++i) { + // faster way of calculating u0 = x[i]*mp mod DV + var j = x[i]&0x7fff; + var u0 = (j*this.mpl+(((j*this.mph+(x[i]>>15)*this.mpl)&this.um)<<15))&x.DM; + // use am to combine the multiply-shift-add into one call + j = i+this.m.t; + x[j] += this.m.am(0,u0,x,i,0,this.m.t); + // propagate carry + while(x[j] >= x.DV) { x[j] -= x.DV; x[++j]++; } + } + x.clamp(); + x.drShiftTo(this.m.t,x); + if(x.compareTo(this.m) >= 0) x.subTo(this.m,x); +} + +// r = "x^2/R mod m"; x != r +function montSqrTo(x,r) { x.squareTo(r); this.reduce(r); } + +// r = "xy/R mod m"; x,y != r +function montMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); } + +Montgomery.prototype.convert = montConvert; +Montgomery.prototype.revert = montRevert; +Montgomery.prototype.reduce = montReduce; +Montgomery.prototype.mulTo = montMulTo; +Montgomery.prototype.sqrTo = montSqrTo; + +// (protected) true iff this is even +function bnpIsEven() { return ((this.t>0)?(this[0]&1):this.s) == 0; } + +// (protected) this^e, e < 2^32, doing sqr and mul with "r" (HAC 14.79) +function bnpExp(e,z) { + if(e > 0xffffffff || e < 1) return BigInteger.ONE; + var r = nbi(), r2 = nbi(), g = z.convert(this), i = nbits(e)-1; + g.copyTo(r); + while(--i >= 0) { + z.sqrTo(r,r2); + if((e&(1< 0) z.mulTo(r2,g,r); + else { var t = r; r = r2; r2 = t; } + } + return z.revert(r); +} + +// (public) this^e % m, 0 <= e < 2^32 +function bnModPowInt(e,m) { + var z; + if(e < 256 || m.isEven()) z = new Classic(m); else z = new Montgomery(m); + return this.exp(e,z); +} + +// protected +BigInteger.prototype.copyTo = bnpCopyTo; +BigInteger.prototype.fromInt = bnpFromInt; +BigInteger.prototype.fromString = bnpFromString; +BigInteger.prototype.clamp = bnpClamp; +BigInteger.prototype.dlShiftTo = bnpDLShiftTo; +BigInteger.prototype.drShiftTo = bnpDRShiftTo; +BigInteger.prototype.lShiftTo = bnpLShiftTo; +BigInteger.prototype.rShiftTo = bnpRShiftTo; +BigInteger.prototype.subTo = bnpSubTo; +BigInteger.prototype.multiplyTo = bnpMultiplyTo; +BigInteger.prototype.squareTo = bnpSquareTo; +BigInteger.prototype.divRemTo = bnpDivRemTo; +BigInteger.prototype.invDigit = bnpInvDigit; +BigInteger.prototype.isEven = bnpIsEven; +BigInteger.prototype.exp = bnpExp; + +// public +BigInteger.prototype.toString = bnToString; +BigInteger.prototype.negate = bnNegate; +BigInteger.prototype.abs = bnAbs; +BigInteger.prototype.compareTo = bnCompareTo; +BigInteger.prototype.bitLength = bnBitLength; +BigInteger.prototype.mod = bnMod; +BigInteger.prototype.modPowInt = bnModPowInt; + +// "constants" +BigInteger.ZERO = nbv(0); +BigInteger.ONE = nbv(1); + + +/// BEGIN jsbn2.js + +/* + * Copyright (c) 2003-2005 Tom Wu + * All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * + * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * In addition, the following condition applies: + * + * All redistributions must retain an intact copy of this copyright notice + * and disclaimer. + */ + +// Extended JavaScript BN functions, required for RSA private ops. + +// (public) +function bnClone() { var r = nbi(); this.copyTo(r); return r; } + +// (public) return value as integer +function bnIntValue() { + if(this.s < 0) { + if(this.t == 1) return this[0]-this.DV; + else if(this.t == 0) return -1; + } + else if(this.t == 1) return this[0]; + else if(this.t == 0) return 0; + // assumes 16 < DB < 32 + return ((this[1]&((1<<(32-this.DB))-1))<>24; } + +// (public) return value as short (assumes DB>=16) +function bnShortValue() { return (this.t==0)?this.s:(this[0]<<16)>>16; } + +// (protected) return x s.t. r^x < DV +function bnpChunkSize(r) { return Math.floor(Math.LN2*this.DB/Math.log(r)); } + +// (public) 0 if this == 0, 1 if this > 0 +function bnSigNum() { + if(this.s < 0) return -1; + else if(this.t <= 0 || (this.t == 1 && this[0] <= 0)) return 0; + else return 1; +} + +// (protected) convert to radix string +function bnpToRadix(b) { + if(b == null) b = 10; + if(this.signum() == 0 || b < 2 || b > 36) return "0"; + var cs = this.chunkSize(b); + var a = Math.pow(b,cs); + var d = nbv(a), y = nbi(), z = nbi(), r = ""; + this.divRemTo(d,y,z); + while(y.signum() > 0) { + r = (a+z.intValue()).toString(b).substr(1) + r; + y.divRemTo(d,y,z); + } + return z.intValue().toString(b) + r; +} + +// (protected) convert from radix string +function bnpFromRadix(s,b) { + this.fromInt(0); + if(b == null) b = 10; + var cs = this.chunkSize(b); + var d = Math.pow(b,cs), mi = false, j = 0, w = 0; + for(var i = 0; i < s.length; ++i) { + var x = intAt(s,i); + if(x < 0) { + if(s.charAt(i) == "-" && this.signum() == 0) mi = true; + continue; + } + w = b*w+x; + if(++j >= cs) { + this.dMultiply(d); + this.dAddOffset(w,0); + j = 0; + w = 0; + } + } + if(j > 0) { + this.dMultiply(Math.pow(b,j)); + this.dAddOffset(w,0); + } + if(mi) BigInteger.ZERO.subTo(this,this); +} + +// (protected) alternate constructor +function bnpFromNumber(a,b,c) { + if("number" == typeof b) { + // new BigInteger(int,int,RNG) + if(a < 2) this.fromInt(1); + else { + this.fromNumber(a,c); + if(!this.testBit(a-1)) // force MSB set + this.bitwiseTo(BigInteger.ONE.shiftLeft(a-1),op_or,this); + if(this.isEven()) this.dAddOffset(1,0); // force odd + while(!this.isProbablePrime(b)) { + this.dAddOffset(2,0); + if(this.bitLength() > a) this.subTo(BigInteger.ONE.shiftLeft(a-1),this); + } + } + } + else { + // new BigInteger(int,RNG) + var x = new Array(), t = a&7; + x.length = (a>>3)+1; + b.nextBytes(x); + if(t > 0) x[0] &= ((1< 0) { + if(p < this.DB && (d = this[i]>>p) != (this.s&this.DM)>>p) + r[k++] = d|(this.s<<(this.DB-p)); + while(i >= 0) { + if(p < 8) { + d = (this[i]&((1<>(p+=this.DB-8); + } + else { + d = (this[i]>>(p-=8))&0xff; + if(p <= 0) { p += this.DB; --i; } + } + if((d&0x80) != 0) d |= -256; + if(k == 0 && (this.s&0x80) != (d&0x80)) ++k; + if(k > 0 || d != this.s) r[k++] = d; + } + } + return r; +} + +function bnEquals(a) { return(this.compareTo(a)==0); } +function bnMin(a) { return(this.compareTo(a)<0)?this:a; } +function bnMax(a) { return(this.compareTo(a)>0)?this:a; } + +// (protected) r = this op a (bitwise) +function bnpBitwiseTo(a,op,r) { + var i, f, m = Math.min(a.t,this.t); + for(i = 0; i < m; ++i) r[i] = op(this[i],a[i]); + if(a.t < this.t) { + f = a.s&this.DM; + for(i = m; i < this.t; ++i) r[i] = op(this[i],f); + r.t = this.t; + } + else { + f = this.s&this.DM; + for(i = m; i < a.t; ++i) r[i] = op(f,a[i]); + r.t = a.t; + } + r.s = op(this.s,a.s); + r.clamp(); +} + +// (public) this & a +function op_and(x,y) { return x&y; } +function bnAnd(a) { var r = nbi(); this.bitwiseTo(a,op_and,r); return r; } + +// (public) this | a +function op_or(x,y) { return x|y; } +function bnOr(a) { var r = nbi(); this.bitwiseTo(a,op_or,r); return r; } + +// (public) this ^ a +function op_xor(x,y) { return x^y; } +function bnXor(a) { var r = nbi(); this.bitwiseTo(a,op_xor,r); return r; } + +// (public) this & ~a +function op_andnot(x,y) { return x&~y; } +function bnAndNot(a) { var r = nbi(); this.bitwiseTo(a,op_andnot,r); return r; } + +// (public) ~this +function bnNot() { + var r = nbi(); + for(var i = 0; i < this.t; ++i) r[i] = this.DM&~this[i]; + r.t = this.t; + r.s = ~this.s; + return r; +} + +// (public) this << n +function bnShiftLeft(n) { + var r = nbi(); + if(n < 0) this.rShiftTo(-n,r); else this.lShiftTo(n,r); + return r; +} + +// (public) this >> n +function bnShiftRight(n) { + var r = nbi(); + if(n < 0) this.lShiftTo(-n,r); else this.rShiftTo(n,r); + return r; +} + +// return index of lowest 1-bit in x, x < 2^31 +function lbit(x) { + if(x == 0) return -1; + var r = 0; + if((x&0xffff) == 0) { x >>= 16; r += 16; } + if((x&0xff) == 0) { x >>= 8; r += 8; } + if((x&0xf) == 0) { x >>= 4; r += 4; } + if((x&3) == 0) { x >>= 2; r += 2; } + if((x&1) == 0) ++r; + return r; +} + +// (public) returns index of lowest 1-bit (or -1 if none) +function bnGetLowestSetBit() { + for(var i = 0; i < this.t; ++i) + if(this[i] != 0) return i*this.DB+lbit(this[i]); + if(this.s < 0) return this.t*this.DB; + return -1; +} + +// return number of 1 bits in x +function cbit(x) { + var r = 0; + while(x != 0) { x &= x-1; ++r; } + return r; +} + +// (public) return number of set bits +function bnBitCount() { + var r = 0, x = this.s&this.DM; + for(var i = 0; i < this.t; ++i) r += cbit(this[i]^x); + return r; +} + +// (public) true iff nth bit is set +function bnTestBit(n) { + var j = Math.floor(n/this.DB); + if(j >= this.t) return(this.s!=0); + return((this[j]&(1<<(n%this.DB)))!=0); +} + +// (protected) this op (1<>= this.DB; + } + if(a.t < this.t) { + c += a.s; + while(i < this.t) { + c += this[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c += this.s; + } + else { + c += this.s; + while(i < a.t) { + c += a[i]; + r[i++] = c&this.DM; + c >>= this.DB; + } + c += a.s; + } + r.s = (c<0)?-1:0; + if(c > 0) r[i++] = c; + else if(c < -1) r[i++] = this.DV+c; + r.t = i; + r.clamp(); +} + +// (public) this + a +function bnAdd(a) { var r = nbi(); this.addTo(a,r); return r; } + +// (public) this - a +function bnSubtract(a) { var r = nbi(); this.subTo(a,r); return r; } + +// (public) this * a +function bnMultiply(a) { var r = nbi(); this.multiplyTo(a,r); return r; } + +// (public) this / a +function bnDivide(a) { var r = nbi(); this.divRemTo(a,r,null); return r; } + +// (public) this % a +function bnRemainder(a) { var r = nbi(); this.divRemTo(a,null,r); return r; } + +// (public) [this/a,this%a] +function bnDivideAndRemainder(a) { + var q = nbi(), r = nbi(); + this.divRemTo(a,q,r); + return new Array(q,r); +} + +// (protected) this *= n, this >= 0, 1 < n < DV +function bnpDMultiply(n) { + this[this.t] = this.am(0,n-1,this,0,0,this.t); + ++this.t; + this.clamp(); +} + +// (protected) this += n << w words, this >= 0 +function bnpDAddOffset(n,w) { + while(this.t <= w) this[this.t++] = 0; + this[w] += n; + while(this[w] >= this.DV) { + this[w] -= this.DV; + if(++w >= this.t) this[this.t++] = 0; + ++this[w]; + } +} + +// A "null" reducer +function NullExp() {} +function nNop(x) { return x; } +function nMulTo(x,y,r) { x.multiplyTo(y,r); } +function nSqrTo(x,r) { x.squareTo(r); } + +NullExp.prototype.convert = nNop; +NullExp.prototype.revert = nNop; +NullExp.prototype.mulTo = nMulTo; +NullExp.prototype.sqrTo = nSqrTo; + +// (public) this^e +function bnPow(e) { return this.exp(e,new NullExp()); } + +// (protected) r = lower n words of "this * a", a.t <= n +// "this" should be the larger one if appropriate. +function bnpMultiplyLowerTo(a,n,r) { + var i = Math.min(this.t+a.t,n); + r.s = 0; // assumes a,this >= 0 + r.t = i; + while(i > 0) r[--i] = 0; + var j; + for(j = r.t-this.t; i < j; ++i) r[i+this.t] = this.am(0,a[i],r,i,0,this.t); + for(j = Math.min(a.t,n); i < j; ++i) this.am(0,a[i],r,i,0,n-i); + r.clamp(); +} + +// (protected) r = "this * a" without lower n words, n > 0 +// "this" should be the larger one if appropriate. +function bnpMultiplyUpperTo(a,n,r) { + --n; + var i = r.t = this.t+a.t-n; + r.s = 0; // assumes a,this >= 0 + while(--i >= 0) r[i] = 0; + for(i = Math.max(n-this.t,0); i < a.t; ++i) + r[this.t+i-n] = this.am(n-i,a[i],r,0,0,this.t+i-n); + r.clamp(); + r.drShiftTo(1,r); +} + +// Barrett modular reduction +function Barrett(m) { + // setup Barrett + this.r2 = nbi(); + this.q3 = nbi(); + BigInteger.ONE.dlShiftTo(2*m.t,this.r2); + this.mu = this.r2.divide(m); + this.m = m; +} + +function barrettConvert(x) { + if(x.s < 0 || x.t > 2*this.m.t) return x.mod(this.m); + else if(x.compareTo(this.m) < 0) return x; + else { var r = nbi(); x.copyTo(r); this.reduce(r); return r; } +} + +function barrettRevert(x) { return x; } + +// x = x mod m (HAC 14.42) +function barrettReduce(x) { + x.drShiftTo(this.m.t-1,this.r2); + if(x.t > this.m.t+1) { x.t = this.m.t+1; x.clamp(); } + this.mu.multiplyUpperTo(this.r2,this.m.t+1,this.q3); + this.m.multiplyLowerTo(this.q3,this.m.t+1,this.r2); + while(x.compareTo(this.r2) < 0) x.dAddOffset(1,this.m.t+1); + x.subTo(this.r2,x); + while(x.compareTo(this.m) >= 0) x.subTo(this.m,x); +} + +// r = x^2 mod m; x != r +function barrettSqrTo(x,r) { x.squareTo(r); this.reduce(r); } + +// r = x*y mod m; x,y != r +function barrettMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); } + +Barrett.prototype.convert = barrettConvert; +Barrett.prototype.revert = barrettRevert; +Barrett.prototype.reduce = barrettReduce; +Barrett.prototype.mulTo = barrettMulTo; +Barrett.prototype.sqrTo = barrettSqrTo; + +// (public) this^e % m (HAC 14.85) +function bnModPow(e,m) { + var i = e.bitLength(), k, r = nbv(1), z; + if(i <= 0) return r; + else if(i < 18) k = 1; + else if(i < 48) k = 3; + else if(i < 144) k = 4; + else if(i < 768) k = 5; + else k = 6; + if(i < 8) + z = new Classic(m); + else if(m.isEven()) + z = new Barrett(m); + else + z = new Montgomery(m); + + // precomputation + var g = new Array(), n = 3, k1 = k-1, km = (1< 1) { + var g2 = nbi(); + z.sqrTo(g[1],g2); + while(n <= km) { + g[n] = nbi(); + z.mulTo(g2,g[n-2],g[n]); + n += 2; + } + } + + var j = e.t-1, w, is1 = true, r2 = nbi(), t; + i = nbits(e[j])-1; + while(j >= 0) { + if(i >= k1) w = (e[j]>>(i-k1))&km; + else { + w = (e[j]&((1<<(i+1))-1))<<(k1-i); + if(j > 0) w |= e[j-1]>>(this.DB+i-k1); + } + + n = k; + while((w&1) == 0) { w >>= 1; --n; } + if((i -= n) < 0) { i += this.DB; --j; } + if(is1) { // ret == 1, don't bother squaring or multiplying it + g[w].copyTo(r); + is1 = false; + } + else { + while(n > 1) { z.sqrTo(r,r2); z.sqrTo(r2,r); n -= 2; } + if(n > 0) z.sqrTo(r,r2); else { t = r; r = r2; r2 = t; } + z.mulTo(r2,g[w],r); + } + + while(j >= 0 && (e[j]&(1< 0) { + x.rShiftTo(g,x); + y.rShiftTo(g,y); + } + while(x.signum() > 0) { + if((i = x.getLowestSetBit()) > 0) x.rShiftTo(i,x); + if((i = y.getLowestSetBit()) > 0) y.rShiftTo(i,y); + if(x.compareTo(y) >= 0) { + x.subTo(y,x); + x.rShiftTo(1,x); + } + else { + y.subTo(x,y); + y.rShiftTo(1,y); + } + } + if(g > 0) y.lShiftTo(g,y); + return y; +} + +// (protected) this % n, n < 2^26 +function bnpModInt(n) { + if(n <= 0) return 0; + var d = this.DV%n, r = (this.s<0)?n-1:0; + if(this.t > 0) + if(d == 0) r = this[0]%n; + else for(var i = this.t-1; i >= 0; --i) r = (d*r+this[i])%n; + return r; +} + +// (public) 1/this % m (HAC 14.61) +function bnModInverse(m) { + var ac = m.isEven(); + if((this.isEven() && ac) || m.signum() == 0) return BigInteger.ZERO; + var u = m.clone(), v = this.clone(); + var a = nbv(1), b = nbv(0), c = nbv(0), d = nbv(1); + while(u.signum() != 0) { + while(u.isEven()) { + u.rShiftTo(1,u); + if(ac) { + if(!a.isEven() || !b.isEven()) { a.addTo(this,a); b.subTo(m,b); } + a.rShiftTo(1,a); + } + else if(!b.isEven()) b.subTo(m,b); + b.rShiftTo(1,b); + } + while(v.isEven()) { + v.rShiftTo(1,v); + if(ac) { + if(!c.isEven() || !d.isEven()) { c.addTo(this,c); d.subTo(m,d); } + c.rShiftTo(1,c); + } + else if(!d.isEven()) d.subTo(m,d); + d.rShiftTo(1,d); + } + if(u.compareTo(v) >= 0) { + u.subTo(v,u); + if(ac) a.subTo(c,a); + b.subTo(d,b); + } + else { + v.subTo(u,v); + if(ac) c.subTo(a,c); + d.subTo(b,d); + } + } + if(v.compareTo(BigInteger.ONE) != 0) return BigInteger.ZERO; + if(d.compareTo(m) >= 0) return d.subtract(m); + if(d.signum() < 0) d.addTo(m,d); else return d; + if(d.signum() < 0) return d.add(m); else return d; +} + +var lowprimes = [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97,101,103,107,109,113,127,131,137,139,149,151,157,163,167,173,179,181,191,193,197,199,211,223,227,229,233,239,241,251,257,263,269,271,277,281,283,293,307,311,313,317,331,337,347,349,353,359,367,373,379,383,389,397,401,409,419,421,431,433,439,443,449,457,461,463,467,479,487,491,499,503,509]; +var lplim = (1<<26)/lowprimes[lowprimes.length-1]; + +// (public) test primality with certainty >= 1-.5^t +function bnIsProbablePrime(t) { + var i, x = this.abs(); + if(x.t == 1 && x[0] <= lowprimes[lowprimes.length-1]) { + for(i = 0; i < lowprimes.length; ++i) + if(x[0] == lowprimes[i]) return true; + return false; + } + if(x.isEven()) return false; + i = 1; + while(i < lowprimes.length) { + var m = lowprimes[i], j = i+1; + while(j < lowprimes.length && m < lplim) m *= lowprimes[j++]; + m = x.modInt(m); + while(i < j) if(m%lowprimes[i++] == 0) return false; + } + return x.millerRabin(t); +} + +// (protected) true if probably prime (HAC 4.24, Miller-Rabin) +function bnpMillerRabin(t) { + var n1 = this.subtract(BigInteger.ONE); + var k = n1.getLowestSetBit(); + if(k <= 0) return false; + var r = n1.shiftRight(k); + t = (t+1)>>1; + if(t > lowprimes.length) t = lowprimes.length; + var a = nbi(); + for(var i = 0; i < t; ++i) { + a.fromInt(lowprimes[i]); + var y = a.modPow(r,this); + if(y.compareTo(BigInteger.ONE) != 0 && y.compareTo(n1) != 0) { + var j = 1; + while(j++ < k && y.compareTo(n1) != 0) { + y = y.modPowInt(2,this); + if(y.compareTo(BigInteger.ONE) == 0) return false; + } + if(y.compareTo(n1) != 0) return false; + } + } + return true; +} + +// protected +BigInteger.prototype.chunkSize = bnpChunkSize; +BigInteger.prototype.toRadix = bnpToRadix; +BigInteger.prototype.fromRadix = bnpFromRadix; +BigInteger.prototype.fromNumber = bnpFromNumber; +BigInteger.prototype.bitwiseTo = bnpBitwiseTo; +BigInteger.prototype.changeBit = bnpChangeBit; +BigInteger.prototype.addTo = bnpAddTo; +BigInteger.prototype.dMultiply = bnpDMultiply; +BigInteger.prototype.dAddOffset = bnpDAddOffset; +BigInteger.prototype.multiplyLowerTo = bnpMultiplyLowerTo; +BigInteger.prototype.multiplyUpperTo = bnpMultiplyUpperTo; +BigInteger.prototype.modInt = bnpModInt; +BigInteger.prototype.millerRabin = bnpMillerRabin; + +// public +BigInteger.prototype.clone = bnClone; +BigInteger.prototype.intValue = bnIntValue; +BigInteger.prototype.byteValue = bnByteValue; +BigInteger.prototype.shortValue = bnShortValue; +BigInteger.prototype.signum = bnSigNum; +BigInteger.prototype.toByteArray = bnToByteArray; +BigInteger.prototype.equals = bnEquals; +BigInteger.prototype.min = bnMin; +BigInteger.prototype.max = bnMax; +BigInteger.prototype.and = bnAnd; +BigInteger.prototype.or = bnOr; +BigInteger.prototype.xor = bnXor; +BigInteger.prototype.andNot = bnAndNot; +BigInteger.prototype.not = bnNot; +BigInteger.prototype.shiftLeft = bnShiftLeft; +BigInteger.prototype.shiftRight = bnShiftRight; +BigInteger.prototype.getLowestSetBit = bnGetLowestSetBit; +BigInteger.prototype.bitCount = bnBitCount; +BigInteger.prototype.testBit = bnTestBit; +BigInteger.prototype.setBit = bnSetBit; +BigInteger.prototype.clearBit = bnClearBit; +BigInteger.prototype.flipBit = bnFlipBit; +BigInteger.prototype.add = bnAdd; +BigInteger.prototype.subtract = bnSubtract; +BigInteger.prototype.multiply = bnMultiply; +BigInteger.prototype.divide = bnDivide; +BigInteger.prototype.remainder = bnRemainder; +BigInteger.prototype.divideAndRemainder = bnDivideAndRemainder; +BigInteger.prototype.modPow = bnModPow; +BigInteger.prototype.modInverse = bnModInverse; +BigInteger.prototype.pow = bnPow; +BigInteger.prototype.gcd = bnGCD; +BigInteger.prototype.isProbablePrime = bnIsProbablePrime; + +// BigInteger interfaces not implemented in jsbn: + +// BigInteger(int signum, byte[] magnitude) +// double doubleValue() +// float floatValue() +// int hashCode() +// long longValue() +// static BigInteger valueOf(long val) + +/// METEOR WRAPPER +return BigInteger; +})(); diff --git a/packages/srp/package.js b/packages/srp/package.js new file mode 100644 index 0000000000..d861368abe --- /dev/null +++ b/packages/srp/package.js @@ -0,0 +1,15 @@ +Package.describe({ + summary: "Library for Secure Remote Password (SRP) exchanges", + internal: true +}); + +Package.on_use(function (api) { + api.use('uuid', ['client', 'server']); + api.add_files(['biginteger.js', 'sha256.js', 'srp.js'], + ['client', 'server']); +}); + +Package.on_test(function (api) { + api.use('srp', ['client', 'server']); + api.add_files(['srp_tests.js'], ['client', 'server']); +}); diff --git a/packages/srp/sha256.js b/packages/srp/sha256.js new file mode 100644 index 0000000000..89e4476e66 --- /dev/null +++ b/packages/srp/sha256.js @@ -0,0 +1,140 @@ +/// METEOR WRAPPER +// +// XXX this should get packaged and moved into the Meteor.crypto +// namespace, along with other hash functions. +if (typeof Meteor._srp === "undefined") + Meteor._srp = {}; +Meteor._srp.SHA256 = (function () { + + +/** +* +* Secure Hash Algorithm (SHA256) +* http://www.webtoolkit.info/ +* +* Original code by Angel Marin, Paul Johnston. +* +**/ + +function SHA256(s){ + + var chrsz = 8; + var hexcase = 0; + + function safe_add (x, y) { + var lsw = (x & 0xFFFF) + (y & 0xFFFF); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xFFFF); + } + + function S (X, n) { return ( X >>> n ) | (X << (32 - n)); } + function R (X, n) { return ( X >>> n ); } + function Ch(x, y, z) { return ((x & y) ^ ((~x) & z)); } + function Maj(x, y, z) { return ((x & y) ^ (x & z) ^ (y & z)); } + function Sigma0256(x) { return (S(x, 2) ^ S(x, 13) ^ S(x, 22)); } + function Sigma1256(x) { return (S(x, 6) ^ S(x, 11) ^ S(x, 25)); } + function Gamma0256(x) { return (S(x, 7) ^ S(x, 18) ^ R(x, 3)); } + function Gamma1256(x) { return (S(x, 17) ^ S(x, 19) ^ R(x, 10)); } + + function core_sha256 (m, l) { + var K = new Array(0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, 0xE49B69C1, 0xEFBE4786, 0xFC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x6CA6351, 0x14292967, 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2); + var HASH = new Array(0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19); + var W = new Array(64); + var a, b, c, d, e, f, g, h, i, j; + var T1, T2; + + m[l >> 5] |= 0x80 << (24 - l % 32); + m[((l + 64 >> 9) << 4) + 15] = l; + + for ( var i = 0; i>5] |= (str.charCodeAt(i / chrsz) & mask) << (24 - i%32); + } + return bin; + } + + function Utf8Encode(string) { + string = string.replace(/\r\n/g,"\n"); + var utftext = ""; + + for (var n = 0; n < string.length; n++) { + + var c = string.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } + else if((c > 127) && (c < 2048)) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } + else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + + } + + return utftext; + } + + function binb2hex (binarray) { + var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + var str = ""; + for(var i = 0; i < binarray.length * 4; i++) { + str += hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8+4)) & 0xF) + + hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8 )) & 0xF); + } + return str; + } + + s = Utf8Encode(s); + return binb2hex(core_sha256(str2binb(s), s.length * chrsz)); + +} + +/// METEOR WRAPPER +return SHA256; +})(); diff --git a/packages/srp/srp.js b/packages/srp/srp.js new file mode 100644 index 0000000000..25716f3554 --- /dev/null +++ b/packages/srp/srp.js @@ -0,0 +1,341 @@ +(function () { + + if (typeof Meteor._srp === "undefined") + Meteor._srp = {}; + + + /////// PUBLIC CLIENT + + /** + * Generate a new SRP verifier. Password is the plaintext password. + * + * options is optional and can include: + * - identity: String. The SRP username to user. Mostly this is passed + * in for testing. Random UUID if not provided. + * - salt: String. A salt to use. Mostly this is passed in for + * testing. Random UUID if not provided. + * - SRP parameters (see _defaults and paramsFromOptions below) + */ + Meteor._srp.generateVerifier = function (password, options) { + var params = paramsFromOptions(options); + + var identity = (options && options.identity) || Meteor.uuid(); + var salt = (options && options.salt) || Meteor.uuid(); + + var x = params.hash(salt + params.hash(identity + ":" + password)); + var xi = new Meteor._srp.BigInteger(x, 16); + var v = params.g.modPow(xi, params.N); + + + return { + identity: identity, + salt: salt, + verifier: v.toString(16) + }; + }; + + + + /** + * Generate a new SRP client object. Password is the plaintext password. + * + * options is optional and can include: + * - a: client's private ephemeral value. String or + * BigInteger. Normally, this is picked randomly, but it can be + * passed in for testing. + * - SRP parameters (see _defaults and paramsFromOptions below) + */ + Meteor._srp.Client = function (password, options) { + var self = this; + self.params = paramsFromOptions(options); + self.password = password; + + // shorthand + var N = self.params.N; + var g = self.params.g; + + // construct public and private keys. + var a, A; + if (options && options.a) { + if (typeof options.a === "string") + a = new Meteor._srp.BigInteger(options.a, 16); + else if (options.a instanceof Meteor._srp.BigInteger) + a = options.a; + else + throw new Error("Invalid parameter: a"); + + A = g.modPow(a, N); + + if (A.mod(N) === 0) + throw new Error("Invalid parameter: a: A mod N == 0."); + + } else { + while (!A || A.mod(N) === 0) { + a = randInt(); + A = g.modPow(a, N); + } + } + + self.a = a; + self.A = A; + self.Astr = A.toString(16); + }; + + + /** + * Initiate an SRP exchange. + * + * returns { A: 'client public ephemeral key. hex encoded integer.' } + */ + Meteor._srp.Client.prototype.startExchange = function () { + var self = this; + + return { + A: self.Astr + }; + }; + + /** + * Respond to the server's challenge with a proof of password. + * + * challenge is an object with + * - B: server public ephemeral key. hex encoded integer. + * - identity: user's identity (SRP username). + * - salt: user's salt. + * + * returns { M: 'client proof of password. hex encoded integer.' } + * throws an error if it got an invalid challenge. + */ + Meteor._srp.Client.prototype.respondToChallenge = function (challenge) { + var self = this; + + // shorthand + var N = self.params.N; + var g = self.params.g; + var k = self.params.k; + var H = self.params.hash; + + // XXX check for missing / bad parameters. + self.identity = challenge.identity; + self.salt = challenge.salt; + self.Bstr = challenge.B; + self.B = new Meteor._srp.BigInteger(self.Bstr, 16); + + if (self.B.mod(N) === 0) + throw new Error("Server sent invalid key: B mod N == 0."); + + var u = new Meteor._srp.BigInteger(H(self.Astr + self.Bstr), 16); + var x = new Meteor._srp.BigInteger( + H(self.salt + H(self.identity + ":" + self.password)), 16); + + var kgx = k.multiply(g.modPow(x, N)); + var aux = self.a.add(u.multiply(x)); + var S = self.B.subtract(kgx).modPow(aux, N); + var M = H(self.Astr + self.Bstr + S.toString(16)); + var HAMK = H(self.Astr + M + S.toString(16)); + + self.S = S; + self.HAMK = HAMK; + + return { + M: M + }; + }; + + + /** + * Verify server's confirmation message. + * + * confirmation is an object with + * - HAMK: server's proof of password. + * + * returns true or false. + */ + Meteor._srp.Client.prototype.verifyConfirmation = function (confirmation) { + var self = this; + + return (self.HAMK && (confirmation.HAMK === self.HAMK)); + }; + + + + /////// PUBLIC SERVER + + + /** + * Generate a new SRP server object. Password is the plaintext password. + * + * options is optional and can include: + * - b: server's private ephemeral value. String or + * BigInteger. Normally, this is picked randomly, but it can be + * passed in for testing. + * - SRP parameters (see _defaults and paramsFromOptions below) + */ + Meteor._srp.Server = function (verifier, options) { + var self = this; + self.params = paramsFromOptions(options); + self.verifier = verifier; + + // shorthand + var N = self.params.N; + var g = self.params.g; + var k = self.params.k; + var v = new Meteor._srp.BigInteger(self.verifier.verifier, 16); + + // construct public and private keys. + var b, B; + if (options && options.b) { + if (typeof options.b === "string") + b = new Meteor._srp.BigInteger(options.b, 16); + else if (options.b instanceof Meteor._srp.BigInteger) + b = options.b; + else + throw new Error("Invalid parameter: b"); + + B = k.multiply(v).add(g.modPow(b, N)).mod(N); + + if (B.mod(N) === 0) + throw new Error("Invalid parameter: b: B mod N == 0."); + + } else { + while (!B || B.mod(N) === 0) { + b = randInt(); + B = k.multiply(v).add(g.modPow(b, N)).mod(N); + } + } + + self.b = b; + self.B = B; + self.Bstr = B.toString(16); + + }; + + + /** + * Issue a challenge to the client. + * + * Takes a request from the client containing: + * - A: hex encoded int. + * + * Returns a challenge with: + * - B: server public ephemeral key. hex encoded integer. + * - identity: user's identity (SRP username). + * - salt: user's salt. + * + * Throws an error if issued a bad request. + */ + Meteor._srp.Server.prototype.issueChallenge = function (request) { + var self = this; + + // XXX check for missing / bad parameters. + self.Astr = request.A; + self.A = new Meteor._srp.BigInteger(self.Astr, 16); + + if (self.A.mod(self.params.N) === 0) + throw new Error("Client sent invalid key: A mod N == 0."); + + // shorthand + var N = self.params.N; + var H = self.params.hash; + + // Compute M and HAMK in advance. Don't send to client yet. + var u = new Meteor._srp.BigInteger(H(self.Astr + self.Bstr), 16); + var v = new Meteor._srp.BigInteger(self.verifier.verifier, 16); + var avu = self.A.multiply(v.modPow(u, N)); + self.S = avu.modPow(self.b, N); + self.M = H(self.Astr + self.Bstr + self.S.toString(16)); + self.HAMK = H(self.Astr + self.M + self.S.toString(16)); + + return { + identity: self.verifier.identity, + salt: self.verifier.salt, + B: self.Bstr + }; + }; + + + /** + * Verify a response from the client and return confirmation. + * + * Takes a challenge response from the client containing: + * - M: client proof of password. hex encoded int. + * + * Returns a confirmation if the client's proof is good: + * - HAMK: server proof of password. hex encoded integer. + * OR null if the client's proof doesn't match. + */ + Meteor._srp.Server.prototype.verifyResponse = function (response) { + var self = this; + + if (response.M !== self.M) + return null; + + return { + HAMK: self.HAMK + }; + }; + + + + /////// INTERNAL + + /** + * Default parameter values for SRP. + * + */ + Meteor._srp._defaults = { + hash: function (x) { return Meteor._srp.SHA256(x).toLowerCase(); }, + N: new Meteor._srp.BigInteger("EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD15DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3", 16), + g: new Meteor._srp.BigInteger("2") + }; + Meteor._srp._defaults.k = new Meteor._srp.BigInteger( + Meteor._srp._defaults.hash( + Meteor._srp._defaults.N.toString(16) + + Meteor._srp._defaults.g.toString(16)), + 16); + + /** + * Process an options hash to create SRP parameters. + * + * Options can include: + * - hash: Function. Defaults to SHA256. + * - N: String or BigInteger. Defaults to 1024 bit value from RFC 5054 + * - g: String or BigInteger. Defaults to 2. + * - k: String or BigInteger. Defaults to hash(N, g) + */ + var paramsFromOptions = function (options) { + if (!options) // fast path + return Meteor._srp._defaults; + + var ret = _.extend({}, Meteor._srp._defaults); + + _.each(['N', 'g', 'k'], function (p) { + if (options[p]) { + if (typeof options[p] === "string") + ret[p] = new Meteor._srp.BigInteger(options[p], 16); + else if (options[p] instanceof Meteor._srp.BigInteger) + ret[p] = options[p]; + else + throw new Error("Invalid parameter: " + p); + } + }); + + if (options.hash) + ret.hash = function (x) { return options.hash(x).toLowerCase(); }; + + if (!options.k && (options.N || options.g || options.hash)) { + ret.k = ret.hash(ret.N.toString(16) + ret.g.toString(16)); + } + + return ret; + }; + + + var randInt = function () { + // XXX XXX need a better implementation! + return new Meteor._srp.BigInteger(Meteor.uuid().replace(/-/g, ''), 16); + }; + + + +})(); diff --git a/packages/srp/srp_tests.js b/packages/srp/srp_tests.js new file mode 100644 index 0000000000..df032a0a5a --- /dev/null +++ b/packages/srp/srp_tests.js @@ -0,0 +1,119 @@ +(function() { + + Tinytest.add("srp - good exchange", function(test) { + var password = 'hi there!'; + var verifier = Meteor._srp.generateVerifier(password); + + var C = new Meteor._srp.Client(password); + var S = new Meteor._srp.Server(verifier); + + var request = C.startExchange(); + var challenge = S.issueChallenge(request); + var response = C.respondToChallenge(challenge); + var confirmation = S.verifyResponse(response); + + test.isTrue(confirmation); + test.isTrue(C.verifyConfirmation(confirmation)); + + }); + + Tinytest.add("srp - bad exchange", function(test) { + var verifier = Meteor._srp.generateVerifier('one password'); + + var C = new Meteor._srp.Client('another password'); + var S = new Meteor._srp.Server(verifier); + + var request = C.startExchange(); + var challenge = S.issueChallenge(request); + var response = C.respondToChallenge(challenge); + var confirmation = S.verifyResponse(response); + + test.isFalse(confirmation); + }); + + + Tinytest.add("srp - fixed values", function(test) { + // Test exact values during the exchange. We have to be very careful + // about changing the SRP code, because changes could render + // people's existing user database unusable. This test is + // intentionally brittle to catch change that could affect the + // validity of user passwords. + + var identity = "b73d9af9-4e74-4ce0-879c-484828b08436"; + var salt = "85f8b9d3-744a-487d-8982-a50e4c9f552a"; + var password = "95109251-3d8a-4777-bdec-44ffe8d86dfb"; + var a = "dc99c646fa4cb7c24314bb6f4ca2d391297acd0dacb0430a13bbf1e37dcf8071"; + var b = "cf878e00c9f2b6aa48a10f66df9706e64fef2ca399f396d65f5b0a27cb8ae237"; + + var verifier = Meteor._srp.generateVerifier( + password, {identity: identity, salt: salt}); + + var C = new Meteor._srp.Client(password, {a: a}); + var S = new Meteor._srp.Server(verifier, {b: b}); + + var request = C.startExchange(); + test.equal(request.A, "8a75aa61471a92d4c3b5d53698c910af5ef013c42799876c40612d1d5e0dc41d01f669bc022fadcd8a704030483401a1b86b8670191bd9dfb1fb506dd11c688b2f08e9946756263954db2040c1df1894af7af5f839c9215bb445268439157e65e8f100469d575d5d0458e19e8bd4dd4ea2c0b30b1b3f4f39264de4ec596e0bb7"); + + var challenge = S.issueChallenge(request); + test.equal(challenge.B, "77ab0a40ef428aa2fa2bc257c905f352c7f75fbcfdb8761393c9dc0f730bbb0270ba9f837545b410c955c3f761494b329ad23c6efdec7e63509e538c2f68a3526e072550a11dac46017718362205e0c698b5bed67d6ff475aa92c191ca169f865c81a1a577373c449b98df720c7b7ff50536f9919d781e698025fd7164932ba7"); + + var response = C.respondToChallenge(challenge); + test.equal(response.M, "8705d31bb61497279adf44eef6c167dcb7e03aa7a42102c1ea7e73025fbd4cd9"); + + var confirmation = S.verifyResponse(response); + test.equal(confirmation.HAMK, "07a0f200392fa9a084db7acc2021fbc174bfb36956b46835cc12506b68b27bba"); + + test.isTrue(C.verifyConfirmation(confirmation)); + }); + + + Tinytest.add("srp - options", function(test) { + // test that all options are respected. + // + // Note, all test strings here should be hex, because the 'hash' + // function needs to output numbers. + + var baseOptions = { + hash: function (x) { return x; }, + N: 'b', + g: '2', + k: '1' + }; + var verifierOptions = _.extend({ + identity: 'a', + salt: 'b' + }, baseOptions); + var clientOptions = _.extend({ + a: "2" + }, baseOptions); + var serverOptions = _.extend({ + b: "2" + }, baseOptions); + + var verifier = Meteor._srp.generateVerifier('c', verifierOptions);; + + test.equal(verifier.identity, 'a'); + test.equal(verifier.salt, 'b'); + test.equal(verifier.verifier, '3'); + + var C = new Meteor._srp.Client('c', clientOptions); + var S = new Meteor._srp.Server(verifier, serverOptions); + + var request = C.startExchange(); + test.equal(request.A, '4'); + + var challenge = S.issueChallenge(request); + test.equal(challenge.identity, 'a'); + test.equal(challenge.salt, 'b'); + test.equal(challenge.B, '7'); + + var response = C.respondToChallenge(challenge); + test.equal(response.M, '471'); + + var confirmation = S.verifyResponse(response); + test.isTrue(confirmation); + test.equal(confirmation.HAMK, '44711'); + + }); + +})(); diff --git a/packages/stream/stream_client.js b/packages/stream/stream_client.js index 6b03f33c76..a7e8401bd1 100644 --- a/packages/stream/stream_client.js +++ b/packages/stream/stream_client.js @@ -8,7 +8,7 @@ Meteor._Stream = function (url) { self.event_callbacks = {}; // name -> [callback] self.server_id = null; self.sent_update_available = false; - self.force_fail = false; + self.force_fail = false; // for debugging. //// Constants @@ -102,8 +102,6 @@ _.extend(Meteor._Stream.prototype, { if (!self.event_callbacks[name]) self.event_callbacks[name] = []; self.event_callbacks[name].push(callback); - if (self.current_status.connected) - self.socket.on(name, callback); }, // data is a utf8 string. Data sent while not connected is dropped on @@ -125,10 +123,16 @@ _.extend(Meteor._Stream.prototype, { }, // Trigger a reconnect. - reconnect: function () { + reconnect: function (options) { var self = this; - if (self.current_status.connected) - return; // already connected. noop. + + if (self.current_status.connected) { + if (options && options.force) { + // force reconnect. + self._disconnected(); + } // else, noop. + return; + } // if we're mid-connection, stop it. if (self.current_status.status === "connecting") { diff --git a/packages/stream/stream_tests.js b/packages/stream/stream_tests.js index a08919b0e3..dda5e39ff2 100644 --- a/packages/stream/stream_tests.js +++ b/packages/stream/stream_tests.js @@ -9,6 +9,30 @@ Tinytest.add("stream - status", function (test) { test.equal(status.retryTime, status.retry_time); }); +testAsyncMulti("stream - reconnect", [ + function (test, expect) { + var callback = _.once(expect(function() { + var status; + status = Meteor.status(); + test.equal(status.status, "connected"); + + Meteor.reconnect(); + status = Meteor.status(); + test.equal(status.status, "connected"); + + Meteor.reconnect({force: true}); + status = Meteor.status(); + test.equal(status.status, "waiting"); + })); + + if (Meteor.status().status !== "connected") + Meteor.default_connection.stream.on('reset', callback); + else + callback(); + } +]); + + Tinytest.add("stream - sockjs urls are computed correctly", function(test) { var testHasSockjsUrl = function(raw, expectedSockjsUrl) { test.equal(Meteor._Stream._toSockjsUrl(raw), expectedSockjsUrl); diff --git a/packages/tinytest/model.js b/packages/tinytest/model.js index c2590a2ae5..b24d5b0f68 100644 --- a/packages/tinytest/model.js +++ b/packages/tinytest/model.js @@ -1 +1,6 @@ Meteor._ServerTestResults = new Meteor.Collection('tinytest_results'); +Meteor._ServerTestResults.allow({ + insert: function() { return true; }, + update: function() { return true; }, + remove: function() { return true; } +}); diff --git a/packages/tinytest/tinytest_server.js b/packages/tinytest/tinytest_server.js index 3e0d1c3987..3718f95aab 100644 --- a/packages/tinytest/tinytest_server.js +++ b/packages/tinytest/tinytest_server.js @@ -1,5 +1,7 @@ Meteor.startup(function () { Meteor._ServerTestResults.remove(); + // Index is definitely not unique and doesn't need to be sparse. + Meteor._ServerTestResults._ensureIndex('run_id'); }); Meteor.publish('tinytest/results', function (run_id) {