-
Notifications
You must be signed in to change notification settings - Fork 87
/
phone_server.js
711 lines (610 loc) · 23.3 KB
/
phone_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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
/// Default Accounts Config vars
var AccountGlobalConfigs = {
verificationRetriesWaitTime : 10 * 60 * 1000,
verificationWaitTime : 20 * 1000,
verificationCodeLength : 4,
verificationMaxRetries : 2,
forbidClientAccountCreation : false,
sendPhoneVerificationCodeOnCreation: true
};
_.defaults(Accounts._options, AccountGlobalConfigs);
/// Phone
var Phone = Npm.require('phone');
/// BCRYPT
var bcrypt = NpmModuleBcrypt;
var bcryptHash = Meteor.wrapAsync(bcrypt.hash);
var bcryptCompare = Meteor.wrapAsync(bcrypt.compare);
// User records have a 'services.phone.bcrypt' field on them to hold
// their hashed passwords (unless they have a 'services.phone.srp'
// field, in which case they will be upgraded to bcrypt the next time
// they log in).
//
// When the client sends a password to the server, it can either be a
// string (the plaintext password) or an object with keys 'digest' and
// 'algorithm' (must be "sha-256" for now). The Meteor client always sends
// password objects { digest: *, algorithm: "sha-256" }, but DDP clients
// that don't have access to SHA can just send plaintext passwords as
// strings.
//
// When the server receives a plaintext password as a string, it always
// hashes it with SHA256 before passing it into bcrypt. When the server
// receives a password as an object, it asserts that the algorithm is
// "sha-256" and then passes the digest to bcrypt.
Accounts._bcryptRounds = 10;
// Given a 'password' from the client, extract the string that we should
// bcrypt. 'password' can be one of:
// - String (the plaintext password)
// - Object with 'digest' and 'algorithm' keys. 'algorithm' must be "sha-256".
//
var getPasswordString = function (password) {
if (typeof password === "string") {
password = SHA256(password);
} else { // 'password' is an object
if (password.algorithm !== "sha-256") {
throw new Error("Invalid password hash algorithm. " +
"Only 'sha-256' is allowed.");
}
password = password.digest;
}
return password;
};
// Use bcrypt to hash the password for storage in the database.
// `password` can be a string (in which case it will be run through
// SHA256 before bcrypt) or an object with properties `digest` and
// `algorithm` (in which case we bcrypt `password.digest`).
//
var hashPassword = function (password) {
password = getPasswordString(password);
return bcryptHash(password, Accounts._bcryptRounds);
};
// Check whether the provided password matches the bcrypt'ed password in
// the database user record. `password` can be a string (in which case
// it will be run through SHA256 before bcrypt) or an object with
// properties `digest` and `algorithm` (in which case we bcrypt
// `password.digest`).
//
Accounts._checkPhonePassword = function (user, password) {
var result = {
userId: user._id
};
password = getPasswordString(password);
if (!bcryptCompare(password, user.services.phone.bcrypt)) {
result.error = new Meteor.Error(403, "Incorrect password");
}
return result;
};
var checkPassword = Accounts._checkPhonePassword;
///
/// LOGIN
///
// Users can specify various keys to identify themselves with.
// @param user {Object} with `id` or `phone`.
// @returns A selector to pass to mongo to get the user record.
var selectorFromUserQuery = function (user) {
if (user.id)
return {_id: user.id};
else if (user.phone)
return {'phone.number': user.phone};
throw new Error("shouldn't happen (validation missed something)");
};
var findUserFromUserQuery = function (user) {
var selector = selectorFromUserQuery(user);
var user = Meteor.users.findOne(selector);
if (!user)
throw new Meteor.Error(403, "User not found");
return user;
};
// XXX maybe this belongs in the check package
var NonEmptyString = Match.Where(function (x) {
check(x, String);
return x.length > 0;
});
var userQueryValidator = Match.Where(function (user) {
check(user, {
id : Match.Optional(NonEmptyString),
phone: Match.Optional(NonEmptyString)
});
if (_.keys(user).length !== 1)
throw new Match.Error("User property must have exactly one field");
return true;
});
var passwordValidator = Match.OneOf(
String,
{ digest: String, algorithm: String }
);
// Handler to login with a phone.
//
// The Meteor client sets options.password to an object with keys
// 'digest' (set to SHA256(password)) and 'algorithm' ("sha-256").
//
// For other DDP clients which don't have access to SHA, the handler
// also accepts the plaintext password in options.password as a string.
//
// (It might be nice if servers could turn the plaintext password
// option off. Or maybe it should be opt-in, not opt-out?
// Accounts.config option?)
//
// Note that neither password option is secure without SSL.
//
Accounts.registerLoginHandler("phone", function (options) {
if (!options.password || options.srp)
return undefined; // don't handle
check(options, {
user : userQueryValidator,
password: passwordValidator
});
var user = findUserFromUserQuery(options.user);
if (!user.services || !user.services.phone || !(user.services.phone.bcrypt || user.services.phone.srp))
throw new Meteor.Error(403, "User has no password set");
if (!user.services.phone.bcrypt) {
if (typeof options.password === "string") {
// The client has presented a plaintext password, and the user is
// not upgraded to bcrypt yet. We don't attempt to tell the client
// to upgrade to bcrypt, because it might be a standalone DDP
// client doesn't know how to do such a thing.
var verifier = user.services.phone.srp;
var newVerifier = SRP.generateVerifier(options.password, {
identity: verifier.identity, salt: verifier.salt});
if (verifier.verifier !== newVerifier.verifier) {
return {
userId: user._id,
error : new Meteor.Error(403, "Incorrect password")
};
}
return {userId: user._id};
} else {
// Tell the client to use the SRP upgrade process.
throw new Meteor.Error(400, "old password format", EJSON.stringify({
format : 'srp',
identity: user.services.phone.srp.identity
}));
}
}
return checkPassword(
user,
options.password
);
});
// Handler to login using the SRP upgrade path. To use this login
// handler, the client must provide:
// - srp: H(identity + ":" + password)
// - password: a string or an object with properties 'digest' and 'algorithm'
//
// We use `options.srp` to verify that the client knows the correct
// password without doing a full SRP flow. Once we've checked that, we
// upgrade the user to bcrypt and remove the SRP information from the
// user document.
//
// The client ends up using this login handler after trying the normal
// login handler (above), which throws an error telling the client to
// try the SRP upgrade path.
//
// XXX COMPAT WITH 0.8.1.3
Accounts.registerLoginHandler("phone", function (options) {
if (!options.srp || !options.password)
return undefined; // don't handle
check(options, {
user : userQueryValidator,
srp : String,
password: passwordValidator
});
var user = findUserFromUserQuery(options.user);
// Check to see if another simultaneous login has already upgraded
// the user record to bcrypt.
if (user.services && user.services.phone &&
user.services.phone.bcrypt)
return checkPassword(user, options.password);
if (!(user.services && user.services.phone
&& user.services.phone.srp))
throw new Meteor.Error(403, "User has no password set");
var v1 = user.services.phone.srp.verifier;
var v2 = SRP.generateVerifier(
null,
{
hashedIdentityAndPassword: options.srp,
salt : user.services.phone.srp.salt
}
).verifier;
if (v1 !== v2)
return {
userId: user._id,
error : new Meteor.Error(403, "Incorrect password")
};
// Upgrade to bcrypt on successful login.
var salted = hashPassword(options.password);
Meteor.users.update(
user._id,
{
$unset: { 'services.phone.srp': 1 },
$set : { 'services.phone.bcrypt': salted }
}
);
return {userId: user._id};
});
// Force change the users phone password.
/**
* @summary Forcibly change the password for a user.
* @locus Server
* @param {String} userId The id of the user to update.
* @param {String} newPassword A new password for the user.
*/
Accounts.setPhonePassword = function (userId, newPlaintextPassword) {
var user = Meteor.users.findOne(userId);
if (!user)
throw new Meteor.Error(403, "User not found");
Meteor.users.update(
{_id: user._id},
{
$unset: {
'services.phone.srp' : 1, // XXX COMPAT WITH 0.8.1.3
'services.phone.verify' : 1,
'services.resume.loginTokens': 1
},
$set : {'services.phone.bcrypt': hashPassword(newPlaintextPassword)} }
);
};
///
/// Send phone VERIFICATION code
///
// send the user a sms with a code that can be used to verify number
/**
* @summary Send an SMS with a code the user can use verify their phone number with.
* @locus Server
* @param {String} userId The id of the user to send email to.
* @param {String} [phone] Optional. Which phone of the user's to send the SMS to. This phone must be in the user's `phones` list. Defaults to the first unverified phone in the list.
*/
Accounts.sendPhoneVerificationCode = function (userId, phone) {
// XXX Also generate a link using which someone can delete this
// account if they own said number but weren't those who created
// this account.
// Make sure the user exists, and phone is one of their phones.
var user = Meteor.users.findOne(userId);
if (!user)
throw new Error("Can't find user");
// pick the first unverified phone if we weren't passed an phone.
if (!phone && user.phone) {
phone = user.phone && user.phone.number;
}
// make sure we have a valid phone
if (!phone)
throw new Error("No such phone for user.");
// If sent more than max retry wait
var waitTimeBetweenRetries = Accounts._options.verificationWaitTime;
var maxRetryCounts = Accounts._options.verificationMaxRetries;
var verifyObject = {numOfRetries: 0};
if (user.services && user.services.phone && user.services.phone.verify) {
verifyObject = user.services.phone.verify;
}
var curTime = new Date();
// Check if last retry was too soon
var nextRetryDate = verifyObject && verifyObject.lastRetry && new Date(verifyObject.lastRetry.getTime() + waitTimeBetweenRetries);
if (nextRetryDate && nextRetryDate > curTime) {
var waitTimeInSec = Math.ceil(Math.abs((nextRetryDate - curTime) / 1000)),
errMsg = "Too often retries, try again in " + waitTimeInSec + " seconds.";
throw new Error(errMsg);
}
// Check if there where too many retries
if (verifyObject.numOfRetries > maxRetryCounts) {
// Check if passed enough time since last retry
var waitTimeBetweenMaxRetries = Accounts._options.verificationRetriesWaitTime;
nextRetryDate = new Date(verifyObject.lastRetry.getTime() + waitTimeBetweenMaxRetries);
if (nextRetryDate > curTime) {
var waitTimeInMin = Math.ceil(Math.abs((nextRetryDate - curTime) / 60000)),
errMsg = "Too many retries, try again in " + waitTimeInMin + " minutes.";
throw new Error(errMsg);
}
}
verifyObject.code = getRandomCode(Accounts._options.verificationCodeLength);
verifyObject.phone = phone;
verifyObject.lastRetry = curTime;
verifyObject.numOfRetries++;
Meteor.users.update(
{_id: userId},
{$set: {'services.phone.verify': verifyObject}});
// before passing to template, update user object with new token
Meteor._ensure(user, 'services', 'phone');
user.services.phone.verify = verifyObject;
var options = {
to : phone,
from: SMS.phoneTemplates.from,
body: SMS.phoneTemplates.text(user, verifyObject.code)
};
try {
SMS.send(options);
} catch (e) {
console.log('SMS Failed, Something bad happened!', e);
}
};
// Send SMS with code to user.
Meteor.methods({requestPhoneVerification: function (phone) {
if (phone) {
check(phone, String);
// Change phone format to international SMS format
phone = normalizePhone(phone);
}
if (!phone) {
throw new Meteor.Error(403, "Not a valid phone");
}
var userId = this.userId;
if (!userId) {
// Get user by phone number
var existingUser = Meteor.users.findOne({'phone.number': phone}, {fields: {'_id': 1}});
if (existingUser) {
userId = existingUser && existingUser._id;
} else {
// Create new user with phone number
userId = createUser({phone:phone});
}
}
Accounts.sendPhoneVerificationCode(userId, phone);
}});
// Take code from sendVerificationPhone SMS, mark the phone as verified,
// Change password if needed
// and log them in.
Meteor.methods({verifyPhone: function (phone, code, newPassword) {
var self = this;
// Check if needs to change password
return Accounts._loginMethod(
self,
"verifyPhone",
arguments,
"phone",
function () {
check(code, String);
check(phone, String);
if (!code) {
throw new Meteor.Error(403, "Code is must be provided to method");
}
// Change phone format to international SMS format
phone = normalizePhone(phone);
var user = Meteor.users.findOne({
"phone.number": phone
});
if (!user)
throw new Meteor.Error(403, "Not a valid phone");
// Verify code is accepted or master code
if (!user.services.phone || !user.services.phone.verify || !user.services.phone.verify.code ||
(user.services.phone.verify.code != code && !isMasterCode(code))) {
throw new Meteor.Error(403, "Not a valid code");
}
var setOptions = {'phone.verified': true},
unSetOptions = {'services.phone.verify': 1};
// If needs to update password
if (newPassword) {
check(newPassword, passwordValidator);
var hashed = hashPassword(newPassword);
// NOTE: We're about to invalidate tokens on the user, who we might be
// logged in as. Make sure to avoid logging ourselves out if this
// happens. But also make sure not to leave the connection in a state
// of having a bad token set if things fail.
var oldToken = Accounts._getLoginToken(self.connection.id);
Accounts._setLoginToken(user._id, self.connection, null);
var resetToOldToken = function () {
Accounts._setLoginToken(user._id, self.connection, oldToken);
};
setOptions['services.phone.bcrypt'] = hashed;
unSetOptions['services.phone.srp'] = 1;
}
try {
var query = {
_id : user._id,
'phone.number' : phone,
'services.phone.verify.code': code
};
// Allow master code from settings
if (isMasterCode(code)) {
delete query['services.phone.verify.code'];
}
// Update the user record by:
// - Changing the password to the new one
// - Forgetting about the verification code that was just used
// - Verifying the phone, since they got the code via sms to phone.
var affectedRecords = Meteor.users.update(
query,
{$set : setOptions,
$unset: unSetOptions});
if (affectedRecords !== 1)
return {
userId: user._id,
error : new Meteor.Error(403, "Invalid phone")
};
successfulVerification(user._id);
} catch (err) {
resetToOldToken();
throw err;
}
// Replace all valid login tokens with new ones (changing
// password should invalidate existing sessions).
Accounts._clearAllLoginTokens(user._id);
return {userId: user._id};
}
);
}});
///
/// 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 the user id
var createUser = function (options) {
// Unknown keys allowed, because a onCreateUserHook can take arbitrary
// options.
check(options, Match.ObjectIncluding({
phone : Match.Optional(String),
password: Match.Optional(passwordValidator)
}));
var phone = options.phone;
if (!phone)
throw new Meteor.Error(400, "Need to set phone");
var existingUser = Meteor.users.findOne(
{'phone.number': phone});
if (existingUser) {
throw new Meteor.Error(403, "User with this phone number already exists");
}
var user = {services: {}};
if (options.password) {
var hashed = hashPassword(options.password);
user.services.phone = { bcrypt: hashed };
}
user.phone = {number: phone, verified: false};
try {
return Accounts.insertUserDoc(options, user);
} 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('users.$phone.number') !== -1)
throw new Meteor.Error(403, "Phone number already exists, failed on creation.");
throw e;
}
};
// method for create user. Requests come from the client.
Meteor.methods({createUserWithPhone: function (options) {
var self = this;
check(options, Object);
if (options.phone) {
check(options.phone, String);
// Change phone format to international SMS format
options.phone = normalizePhone(options.phone);
}
return Accounts._loginMethod(
self,
"createUserWithPhone",
arguments,
"phone",
function () {
if (Accounts._options.forbidClientAccountCreation)
return {
error: new Meteor.Error(403, "Signups forbidden")
};
// Create user. result contains id and token.
var userId = createUser(options);
// safety belt. createUser is supposed to throw on error. send 500 error
// instead of sending a verification email with empty userid.
if (!userId)
throw new Error("createUser failed to insert new user");
// If `Accounts._options.sendPhoneVerificationCodeOnCreation` is set, register
// a token to verify the user's primary phone, and send it to
// by sms.
if (options.phone && Accounts._options.sendPhoneVerificationCodeOnCreation) {
Accounts.sendPhoneVerificationCode(userId, options.phone);
}
// client gets logged in as the new user afterwards.
return {userId: userId};
}
);
}});
// 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.createUserWithPhone = function (options, callback) {
options = _.clone(options);
// XXX allow an optional callback?
if (callback) {
throw new Error("Accounts.createUser with callback not supported on the server yet.");
}
return createUser(options);
};
///
/// PASSWORD-SPECIFIC INDEXES ON USERS
///
Meteor.users._ensureIndex('phone.number',
{unique: 1, sparse: 1});
Meteor.users._ensureIndex('services.phone.verify.code',
{unique: 1, sparse: 1});
/*** Control published data *********/
Meteor.startup(function () {
/** Publish phones to the client **/
Meteor.publish(null, function () {
if (this.userId) {
return Meteor.users.find({_id: this.userId},
{fields: {'phone': 1}});
} else {
this.ready();
}
});
/** Disable user profile editing **/
Meteor.users.deny({
update: function () {
return true;
}
});
});
/************* Phone verification hook *************/
// Callback exceptions are printed with Meteor._debug and ignored.
var onPhoneVerificationHook = new Hook({
debugPrintExceptions: "onPhoneVerification callback"
});
/**
* @summary Register a callback to be called after a phone verification attempt succeeds.
* @locus Server
* @param {Function} func The callback to be called when phone verification is successful.
*/
Accounts.onPhoneVerification = function (func) {
return onPhoneVerificationHook.register(func);
};
var successfulVerification = function (userId) {
onPhoneVerificationHook.each(function (callback) {
callback(userId);
return true;
});
};
// Give each login hook callback a fresh cloned copy of the attempt
// object, but don't clone the connection.
//
var cloneAttemptWithConnection = function (connection, attempt) {
var clonedAttempt = EJSON.clone(attempt);
clonedAttempt.connection = connection;
return clonedAttempt;
};
/************* Helper functions ********************/
// Return normalized phone format
var normalizePhone = function (phone) {
// If phone equals to one of admin phone numbers return it as-is
if (phone && Accounts._options.adminPhoneNumbers && Accounts._options.adminPhoneNumbers.indexOf(phone) != -1) {
return phone;
}
return Phone(phone)[0];
};
/**
* Check whether the given code is the defined master code
* @param code
* @returns {*|boolean}
*/
var isMasterCode = function (code) {
return code && Accounts._options.phoneVerificationMasterCode &&
code == Accounts._options.phoneVerificationMasterCode;
}
/**
* Get random phone verification code
* @param length
* @returns {string}
*/
var getRandomCode = function (length) {
length = length || 4;
var output = "";
while (length-- > 0) {
output += getRandomDigit();
}
return output;
}
/**
* Return random 1-9 digit
* @returns {number}
*/
var getRandomDigit = function () {
return Math.floor((Math.random() * 9) + 1);
}