forked from meteor/meteor
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathaccounts_server.js
349 lines (304 loc) · 12 KB
/
accounts_server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
(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`, meaning it handled this call to `login`. Return that return
// value, which ought to be a {id/token} pair.
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;
// - {id: userId, token: *}, if the user logged in successfully.
// - throw an error, if the user failed to log in.
Accounts.registerLoginHandler = function(handler) {
Accounts._loginHandlers.push(handler);
};
// support reconnecting using a meteor login token
Accounts._generateStampedLoginToken = function () {
return {token: Random.id(), 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
// second "server options" argument.
var defaultCreateUserHook = function (options, user) {
if (options.profile)
user.profile = options.profile;
return user;
};
Accounts.insertUserDoc = function (options, user) {
// - clone user document, to protect from modification
// - add createdAt timestamp
// - prepare an _id, so that you can modify other collections (eg
// create a first task for every new user)
//
// XXX If the onCreateUser or validateNewUser hooks fail, we might
// end up having modified some other collection
// inappropriately. The solution is probably to have onCreateUser
// accept two callbacks - one that gets called before inserting
// the user document (in which you can modify its contents), and
// one that gets called after (in which you should change other
// collections)
user = _.extend({createdAt: +(new Date), _id: Random.id()}, user);
var result = {};
if (options.generateLoginToken) {
var stampedToken = Accounts._generateStampedLoginToken();
result.token = stampedToken.token;
Meteor._ensure(user, 'services', 'resume');
if (_.has(user.services.resume, 'loginTokens'))
user.services.resume.loginTokens.push(stampedToken);
else
user.services.resume.loginTokens = [stampedToken];
}
var fullUser;
if (onCreateUserHook) {
fullUser = onCreateUserHook(options, 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, user);
} else {
fullUser = defaultCreateUserHook(options, user);
}
_.each(validateNewUserHooks, function (hook) {
if (!hook(fullUser))
throw new Meteor.Error(403, "User validation failed");
});
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 options {Object, optional} Other options to pass to insertUserDoc
// (eg, profile)
// @returns {Object} Object with token and id keys, like the result
// of the "login" method.
Accounts.updateOrCreateUserFromExternalService = function(
serviceName, serviceData, options) {
options = _.clone(options || {});
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 = {};
var serviceIdKey = "services." + serviceName + ".id";
// XXX Temporary special case for Twitter. (Issue #629)
// The serviceData.id will be a string representation of an integer.
// We want it to match either a stored string or int representation.
// This is to cater to earlier versions of Meteor storing twitter
// user IDs in number form, and recent versions storing them as strings.
// This can be removed once migration technology is in place, and twitter
// users stored with integer IDs have been migrated to string IDs.
if (serviceName === "twitter" && !isNaN(serviceData.id)) {
selector["$or"] = [{},{}];
selector["$or"][0][serviceIdKey] = serviceData.id;
selector["$or"][1][serviceIdKey] = parseInt(serviceData.id, 10);
} else {
selector[serviceIdKey] = serviceData.id;
}
var user = Meteor.users.findOne(selector);
if (user) {
// We *don't* process options (eg, profile) for update, but we do replace
// the serviceData (eg, so that we keep an unexpired access token and
// don't cache old email addresses in serviceData.email).
// XXX provide an onUpdateUser hook which would let apps update
// the profile too
var stampedToken = Accounts._generateStampedLoginToken();
var setAttrs = {};
_.each(serviceData, function(value, key) {
setAttrs["services." + serviceName + "." + key] = value;
});
// XXX Maybe we should re-use the selector above and notice if the update
// touches nothing?
Meteor.users.update(
user._id,
{$set: setAttrs,
$push: {'services.resume.loginTokens': stampedToken}});
return {token: stampedToken.token, id: user._id};
} else {
// Create a new user with the service data. Pass other options through to
// insertUserDoc.
user = {services: {}};
user.services[serviceName] = serviceData;
options.generateLoginToken = true;
return Accounts.insertUserDoc(options, user);
}
};
///
/// PUBLISHING DATA
///
// Publish the current user's record to the client.
Meteor.publish(null, function() {
if (this.userId)
return Meteor.users.find(
{_id: this.userId},
{fields: {profile: 1, username: 1, emails: 1}});
else {
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;
},
fetch: ['_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});
}) ();