-
Notifications
You must be signed in to change notification settings - Fork 5.2k
/
Copy pathaccounts_server.js
1848 lines (1645 loc) · 63.6 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
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
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import crypto from 'crypto';
import { Meteor } from 'meteor/meteor';
import {
AccountsCommon,
EXPIRE_TOKENS_INTERVAL_MS,
} from './accounts_common.js';
import { URL } from 'meteor/url';
const hasOwn = Object.prototype.hasOwnProperty;
// XXX maybe this belongs in the check package
const NonEmptyString = Match.Where(x => {
check(x, String);
return x.length > 0;
});
/**
* @summary Constructor for the `Accounts` namespace on the server.
* @locus Server
* @class AccountsServer
* @extends AccountsCommon
* @instancename accountsServer
* @param {Object} server A server object such as `Meteor.server`.
*/
export class AccountsServer extends AccountsCommon {
// Note that this constructor is less likely to be instantiated multiple
// times than the `AccountsClient` constructor, because a single server
// can provide only one set of methods.
constructor(server, options) {
super(options || {});
this._server = server || Meteor.server;
// Set up the server's methods, as if by calling Meteor.methods.
this._initServerMethods();
this._initAccountDataHooks();
// If autopublish is on, publish these user fields. Login service
// packages (eg accounts-google) add to these by calling
// addAutopublishFields. Notably, this isn't implemented with multiple
// publishes since DDP only merges only across top-level fields, not
// subfields (such as 'services.facebook.accessToken')
this._autopublishFields = {
loggedInUser: ['profile', 'username', 'emails'],
otherUsers: ['profile', 'username']
};
// use object to keep the reference when used in functions
// where _defaultPublishFields is destructured into lexical scope
// for publish callbacks that need `this`
this._defaultPublishFields = {
projection: {
profile: 1,
username: 1,
emails: 1,
}
};
this._initServerPublications();
// connectionId -> {connection, loginToken}
this._accountData = {};
// connection id -> observe handle for the login token that this connection is
// currently associated with, or a number. The number indicates that we are in
// the process of setting up the observe (using a number instead of a single
// sentinel allows multiple attempts to set up the observe to identify which
// one was theirs).
this._userObservesForConnections = {};
this._nextUserObserveNumber = 1; // for the number described above.
// list of all registered handlers.
this._loginHandlers = [];
setupDefaultLoginHandlers(this);
setExpireTokensInterval(this);
this._validateLoginHook = new Hook({ bindEnvironment: false });
this._validateNewUserHooks = [
defaultValidateNewUserHook.bind(this)
];
this._deleteSavedTokensForAllUsersOnStartup();
this._skipCaseInsensitiveChecksForTest = {};
this.urls = {
resetPassword: (token, extraParams) => this.buildEmailUrl(`#/reset-password/${token}`, extraParams),
verifyEmail: (token, extraParams) => this.buildEmailUrl(`#/verify-email/${token}`, extraParams),
loginToken: (selector, token, extraParams) =>
this.buildEmailUrl(`/?loginToken=${token}&selector=${selector}`, extraParams),
enrollAccount: (token, extraParams) => this.buildEmailUrl(`#/enroll-account/${token}`, extraParams),
};
this.addDefaultRateLimit();
this.buildEmailUrl = (path, extraParams = {}) => {
const url = new URL(Meteor.absoluteUrl(path));
const params = Object.entries(extraParams);
if (params.length > 0) {
// Add additional parameters to the url
for (const [key, value] of params) {
url.searchParams.append(key, value);
}
}
return url.toString();
};
}
///
/// CURRENT USER
///
// @override of "abstract" non-implementation in accounts_common.js
userId() {
// This function only works if called inside a method or a pubication.
// Using any of the information from Meteor.user() in a method or
// 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 method or publish function is to do Meteor.find(this.userId).observe
// and recompute when the user record changes.
const currentInvocation = DDP._CurrentMethodInvocation.get() || DDP._CurrentPublicationInvocation.get();
if (!currentInvocation)
throw new Error("Meteor.userId can only be invoked in method calls or publications.");
return currentInvocation.userId;
}
async init() {
await setupUsersCollection(this.users);
}
///
/// LOGIN HOOKS
///
/**
* @summary Validate login attempts.
* @locus Server
* @param {Function} func Called whenever a login is attempted (either successful or unsuccessful). A login can be aborted by returning a falsy value or throwing an exception.
*/
validateLoginAttempt(func) {
// Exceptions inside the hook callback are passed up to us.
return this._validateLoginHook.register(func);
}
/**
* @summary Set restrictions on new user creation.
* @locus Server
* @param {Function} func Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort.
*/
validateNewUser(func) {
this._validateNewUserHooks.push(func);
}
/**
* @summary Validate login from external service
* @locus Server
* @param {Function} func Called whenever login/user creation from external service is attempted. Login or user creation based on this login can be aborted by passing a falsy value or throwing an exception.
*/
beforeExternalLogin(func) {
if (this._beforeExternalLoginHook) {
throw new Error("Can only call beforeExternalLogin once");
}
this._beforeExternalLoginHook = func;
}
///
/// CREATE USER HOOKS
///
/**
* @summary Customize login token creation.
* @locus Server
* @param {Function} func Called whenever a new token is created.
* Return the sequence and the user object. Return true to keep sending the default email, or false to override the behavior.
*/
onCreateLoginToken = function(func) {
if (this._onCreateLoginTokenHook) {
throw new Error('Can only call onCreateLoginToken once');
}
this._onCreateLoginTokenHook = func;
}
/**
* @summary Customize new user creation.
* @locus Server
* @param {Function} func Called whenever a new user is created. Return the new user object, or throw an `Error` to abort the creation.
*/
onCreateUser(func) {
if (this._onCreateUserHook) {
throw new Error("Can only call onCreateUser once");
}
this._onCreateUserHook = Meteor.wrapFn(func);
}
/**
* @summary Customize oauth user profile updates
* @locus Server
* @param {Function} func Called whenever a user is logged in via oauth. Return the profile object to be merged, or throw an `Error` to abort the creation.
*/
onExternalLogin(func) {
if (this._onExternalLoginHook) {
throw new Error("Can only call onExternalLogin once");
}
this._onExternalLoginHook = func;
}
/**
* @summary Customize user selection on external logins
* @locus Server
* @param {Function} func Called whenever a user is logged in via oauth and a
* user is not found with the service id. Return the user or undefined.
*/
setAdditionalFindUserOnExternalLogin(func) {
if (this._additionalFindUserOnExternalLogin) {
throw new Error("Can only call setAdditionalFindUserOnExternalLogin once");
}
this._additionalFindUserOnExternalLogin = func;
}
async _validateLogin(connection, attempt) {
await this._validateLoginHook.forEachAsync(async (callback) => {
let ret;
try {
ret = await callback(cloneAttemptWithConnection(connection, attempt));
}
catch (e) {
attempt.allowed = false;
// XXX this means the last thrown error overrides previous error
// messages. Maybe this is surprising to users and we should make
// overriding errors more explicit. (see
// https://github.com/meteor/meteor/issues/1960)
attempt.error = e;
return true;
}
if (! ret) {
attempt.allowed = false;
// don't override a specific error provided by a previous
// validator or the initial attempt (eg "incorrect password").
if (!attempt.error)
attempt.error = new Meteor.Error(403, "Login forbidden");
}
return true;
});
};
async _successfulLogin(connection, attempt) {
await this._onLoginHook.forEachAsync(async (callback) => {
await callback(cloneAttemptWithConnection(connection, attempt));
return true;
});
};
async _failedLogin(connection, attempt) {
await this._onLoginFailureHook.forEachAsync(async (callback) => {
await callback(cloneAttemptWithConnection(connection, attempt));
return true;
});
};
async _successfulLogout(connection, userId) {
// don't fetch the user object unless there are some callbacks registered
let user;
await this._onLogoutHook.forEachAsync(async callback => {
if (!user && userId) user = await this.users.findOneAsync(userId, { fields: this._options.defaultFieldSelector });
callback({ user, connection });
return true;
});
};
// Generates a MongoDB selector that can be used to perform a fast case
// insensitive lookup for the given fieldName and string. Since MongoDB does
// not support case insensitive indexes, and case insensitive regex queries
// are slow, we construct a set of prefix selectors for all permutations of
// the first 4 characters ourselves. We first attempt to matching against
// these, and because 'prefix expression' regex queries do use indexes (see
// http://docs.mongodb.org/v2.6/reference/operator/query/regex/#index-use),
// this has been found to greatly improve performance (from 1200ms to 5ms in a
// test with 1.000.000 users).
_selectorForFastCaseInsensitiveLookup = (fieldName, string) => {
// Performance seems to improve up to 4 prefix characters
const prefix = string.substring(0, Math.min(string.length, 4));
const orClause = generateCasePermutationsForString(prefix).map(
prefixPermutation => {
const selector = {};
selector[fieldName] =
new RegExp(`^${Meteor._escapeRegExp(prefixPermutation)}`);
return selector;
});
const caseInsensitiveClause = {};
caseInsensitiveClause[fieldName] =
new RegExp(`^${Meteor._escapeRegExp(string)}$`, 'i')
return {$and: [{$or: orClause}, caseInsensitiveClause]};
}
_findUserByQuery = async (query, options) => {
let user = null;
if (query.id) {
// default field selector is added within getUserById()
user = await Meteor.users.findOneAsync(query.id, this._addDefaultFieldSelector(options));
} else {
options = this._addDefaultFieldSelector(options);
let fieldName;
let fieldValue;
if (query.username) {
fieldName = 'username';
fieldValue = query.username;
} else if (query.email) {
fieldName = 'emails.address';
fieldValue = query.email;
} else {
throw new Error("shouldn't happen (validation missed something)");
}
let selector = {};
selector[fieldName] = fieldValue;
user = await Meteor.users.findOneAsync(selector, options);
// If user is not found, try a case insensitive lookup
if (!user) {
selector = this._selectorForFastCaseInsensitiveLookup(fieldName, fieldValue);
const candidateUsers = await Meteor.users.find(selector, { ...options, limit: 2 }).fetchAsync();
// No match if multiple candidates are found
if (candidateUsers.length === 1) {
user = candidateUsers[0];
}
}
}
return user;
}
///
/// LOGIN METHODS
///
// Login methods return to the client an object containing these
// fields when the user was logged in successfully:
//
// id: userId
// token: *
// tokenExpires: *
//
// tokenExpires is optional and intends to provide a hint to the
// client as to when the token will expire. If not provided, the
// client will call Accounts._tokenExpiration, passing it the date
// that it received the token.
//
// The login method will throw an error back to the client if the user
// failed to log in.
//
//
// Login handlers and service specific login methods such as
// `createUser` internally return a `result` object containing these
// fields:
//
// type:
// optional string; the service name, overrides the handler
// default if present.
//
// error:
// exception; if the user is not allowed to login, the reason why.
//
// userId:
// string; the user id of the user attempting to login (if
// known), required for an allowed login.
//
// options:
// optional object merged into the result returned by the login
// method; used by HAMK from SRP.
//
// stampedLoginToken:
// optional object with `token` and `when` indicating the login
// token is already present in the database, returned by the
// "resume" login handler.
//
// For convenience, login methods can also throw an exception, which
// is converted into an {error} result. However, if the id of the
// user attempting the login is known, a {userId, error} result should
// be returned instead since the user id is not captured when an
// exception is thrown.
//
// This internal `result` object is automatically converted into the
// public {id, token, tokenExpires} object returned to the client.
// Try a login method, converting thrown exceptions into an {error}
// result. The `type` argument is a default, inserted into the result
// object if not explicitly returned.
//
// Log in a user on a connection.
//
// We use the method invocation to set the user id on the connection,
// not the connection object directly. setUserId is tied to methods to
// enforce clear ordering of method application (using wait methods on
// the client, and a no setUserId after unblock restriction on the
// server)
//
// The `stampedLoginToken` parameter is optional. When present, it
// indicates that the login token has already been inserted into the
// database and doesn't need to be inserted again. (It's used by the
// "resume" login handler).
async _loginUser(methodInvocation, userId, stampedLoginToken) {
if (! stampedLoginToken) {
stampedLoginToken = this._generateStampedLoginToken();
await this._insertLoginToken(userId, stampedLoginToken);
}
// This order (and the avoidance of yields) is important to make
// sure that when publish functions are rerun, they see a
// consistent view of the world: the userId is set and matches
// the login token on the connection (not that there is
// currently a public API for reading the login token on a
// connection).
Meteor._noYieldsAllowed(() =>
this._setLoginToken(
userId,
methodInvocation.connection,
this._hashLoginToken(stampedLoginToken.token)
)
);
await methodInvocation.setUserId(userId);
return {
id: userId,
token: stampedLoginToken.token,
tokenExpires: this._tokenExpiration(stampedLoginToken.when)
};
};
// After a login method has completed, call the login hooks. Note
// that `attemptLogin` is called for *all* login attempts, even ones
// which aren't successful (such as an invalid password, etc).
//
// If the login is allowed and isn't aborted by a validate login hook
// callback, log in the user.
//
async _attemptLogin(
methodInvocation,
methodName,
methodArgs,
result
) {
if (!result)
throw new Error("result is required");
// XXX A programming error in a login handler can lead to this occurring, and
// then we don't call onLogin or onLoginFailure callbacks. Should
// tryLoginMethod catch this case and turn it into an error?
if (!result.userId && !result.error)
throw new Error("A login method must specify a userId or an error");
let user;
if (result.userId)
user = await this.users.findOneAsync(result.userId, {fields: this._options.defaultFieldSelector});
const attempt = {
type: result.type || "unknown",
allowed: !! (result.userId && !result.error),
methodName: methodName,
methodArguments: Array.from(methodArgs)
};
if (result.error) {
attempt.error = result.error;
}
if (user) {
attempt.user = user;
}
// _validateLogin may mutate `attempt` by adding an error and changing allowed
// to false, but that's the only change it can make (and the user's callbacks
// only get a clone of `attempt`).
await this._validateLogin(methodInvocation.connection, attempt);
if (attempt.allowed) {
const o = await this._loginUser(
methodInvocation,
result.userId,
result.stampedLoginToken
)
const ret = {
...o,
...result.options
};
ret.type = attempt.type;
await this._successfulLogin(methodInvocation.connection, attempt);
return ret;
}
else {
await this._failedLogin(methodInvocation.connection, attempt);
throw attempt.error;
}
};
// All service specific login methods should go through this function.
// Ensure that thrown exceptions are caught and that login hook
// callbacks are still called.
//
async _loginMethod(
methodInvocation,
methodName,
methodArgs,
type,
fn
) {
return await this._attemptLogin(
methodInvocation,
methodName,
methodArgs,
await tryLoginMethod(type, fn)
);
};
// Report a login attempt failed outside the context of a normal login
// method. This is for use in the case where there is a multi-step login
// procedure (eg SRP based password login). If a method early in the
// chain fails, it should call this function to report a failure. There
// is no corresponding method for a successful login; methods that can
// succeed at logging a user in should always be actual login methods
// (using either Accounts._loginMethod or Accounts.registerLoginHandler).
async _reportLoginFailure(
methodInvocation,
methodName,
methodArgs,
result
) {
const attempt = {
type: result.type || "unknown",
allowed: false,
error: result.error,
methodName: methodName,
methodArguments: Array.from(methodArgs)
};
if (result.userId) {
attempt.user = this.users.findOneAsync(result.userId, {fields: this._options.defaultFieldSelector});
}
await this._validateLogin(methodInvocation.connection, attempt);
await this._failedLogin(methodInvocation.connection, attempt);
// _validateLogin may mutate attempt to set a new error message. Return
// the modified version.
return attempt;
};
///
/// LOGIN HANDLERS
///
/**
* @summary Registers a new login handler.
* @locus Server
* @param {String} [name] The type of login method like oauth, password, etc.
* @param {Function} handler 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 or a login method result object.
*/
registerLoginHandler(name, handler) {
if (! handler) {
handler = name;
name = null;
}
this._loginHandlers.push({
name: name,
handler: Meteor.wrapFn(handler)
});
};
// Checks a user's credentials against all the registered login
// handlers, and returns a login token if the credentials are valid. It
// is like the login method, except that it doesn't set the logged-in
// user on the connection. Throws a Meteor.Error if logging in fails,
// including the case where none of the login handlers handled the login
// request. Otherwise, returns {id: userId, token: *, tokenExpires: *}.
//
// For example, if you want to login with a plaintext password, `options` could be
// { user: { username: <username> }, password: <password> }, or
// { user: { email: <email> }, password: <password> }.
// 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.
async _runLoginHandlers(methodInvocation, options) {
for (let handler of this._loginHandlers) {
const result = await tryLoginMethod(handler.name, async () =>
await handler.handler.call(methodInvocation, options)
);
if (result) {
return result;
}
if (result !== undefined) {
throw new Meteor.Error(
400,
'A login handler should return a result or undefined'
);
}
}
return {
type: null,
error: new Meteor.Error(400, "Unrecognized options for login request")
};
};
// Deletes the given loginToken from the database.
//
// For new-style hashed token, this will cause all connections
// associated with the token to be closed.
//
// Any connections associated with old-style unhashed tokens will be
// in the process of becoming associated with hashed tokens and then
// they'll get closed.
async destroyToken(userId, loginToken) {
await this.users.updateAsync(userId, {
$pull: {
"services.resume.loginTokens": {
$or: [
{ hashedToken: loginToken },
{ token: loginToken }
]
}
}
});
};
_initServerMethods() {
// The methods created in this function need to be created here so that
// this variable is available in their scope.
const accounts = this;
// This object will be populated with methods and then passed to
// accounts._server.methods further below.
const methods = {};
// @returns {Object|null}
// If successful, returns {token: reconnectToken, id: userId}
// If unsuccessful (for example, if the user closed the oauth login popup),
// throws an error describing the reason
methods.login = async function (options) {
// Login handlers should really also check whatever field they look at in
// options, but we don't enforce it.
check(options, Object);
const result = await accounts._runLoginHandlers(this, options);
//console.log({result});
return await accounts._attemptLogin(this, "login", arguments, result);
};
methods.logout = async function () {
const token = accounts._getLoginToken(this.connection.id);
accounts._setLoginToken(this.userId, this.connection, null);
if (token && this.userId) {
await accounts.destroyToken(this.userId, token);
}
await accounts._successfulLogout(this.connection, this.userId);
await this.setUserId(null);
};
// Generates a new login token with the same expiration as the
// connection's current token and saves it to the database. Associates
// the connection with this new token and returns it. Throws an error
// if called on a connection that isn't logged in.
//
// @returns Object
// If successful, returns { token: <new token>, id: <user id>,
// tokenExpires: <expiration date> }.
methods.getNewToken = async function () {
const user = await accounts.users.findOneAsync(this.userId, {
fields: { "services.resume.loginTokens": 1 }
});
if (! this.userId || ! user) {
throw new Meteor.Error("You are not logged in.");
}
// Be careful not to generate a new token that has a later
// expiration than the curren token. Otherwise, a bad guy with a
// stolen token could use this method to stop his stolen token from
// ever expiring.
const currentHashedToken = accounts._getLoginToken(this.connection.id);
const currentStampedToken = user.services.resume.loginTokens.find(
stampedToken => stampedToken.hashedToken === currentHashedToken
);
if (! currentStampedToken) { // safety belt: this should never happen
throw new Meteor.Error("Invalid login token");
}
const newStampedToken = accounts._generateStampedLoginToken();
newStampedToken.when = currentStampedToken.when;
await accounts._insertLoginToken(this.userId, newStampedToken);
return await accounts._loginUser(this, this.userId, newStampedToken);
};
// Removes all tokens except the token associated with the current
// connection. Throws an error if the connection is not logged
// in. Returns nothing on success.
methods.removeOtherTokens = async function () {
if (! this.userId) {
throw new Meteor.Error("You are not logged in.");
}
const currentToken = accounts._getLoginToken(this.connection.id);
await accounts.users.updateAsync(this.userId, {
$pull: {
"services.resume.loginTokens": { hashedToken: { $ne: currentToken } }
}
});
};
// Allow a one-time configuration for a login service. Modifications
// to this collection are also allowed in insecure mode.
methods.configureLoginService = async (options) => {
check(options, Match.ObjectIncluding({service: String}));
// 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).
// XXX if service configuration is oauth-specific then this code should
// be in accounts-oauth; if it's not then the registry should be
// in this package
if (!(accounts.oauth
&& accounts.oauth.serviceNames().includes(options.service))) {
throw new Meteor.Error(403, "Service unknown");
}
if (Package['service-configuration']) {
const { ServiceConfiguration } = Package['service-configuration'];
const service = await ServiceConfiguration.configurations.findOneAsync({service: options.service})
if (service)
throw new Meteor.Error(403, `Service ${options.service} already configured`);
if (Package["oauth-encryption"]) {
const { OAuthEncryption } = Package["oauth-encryption"]
if (hasOwn.call(options, 'secret') && OAuthEncryption.keyIsLoaded())
options.secret = OAuthEncryption.seal(options.secret);
}
await ServiceConfiguration.configurations.insertAsync(options);
}
};
accounts._server.methods(methods);
};
_initAccountDataHooks() {
this._server.onConnection(connection => {
this._accountData[connection.id] = {
connection: connection
};
connection.onClose(() => {
this._removeTokenFromConnection(connection.id);
delete this._accountData[connection.id];
});
});
};
_initServerPublications() {
// Bring into lexical scope for publish callbacks that need `this`
const { users, _autopublishFields, _defaultPublishFields } = this;
// Publish all login service configuration fields other than secret.
this._server.publish("meteor.loginServiceConfiguration", function() {
if (Package['service-configuration']) {
const { ServiceConfiguration } = Package['service-configuration'];
return ServiceConfiguration.configurations.find({}, {fields: {secret: 0}});
}
this.ready();
}, {is_auto: true}); // not technically autopublish, but stops the warning.
// Use Meteor.startup to give other packages a chance to call
// setDefaultPublishFields.
Meteor.startup(() => {
// Merge custom fields selector and default publish fields so that the client
// gets all the necessary fields to run properly
const customFields = this._addDefaultFieldSelector().fields || {};
const keys = Object.keys(customFields);
// If the custom fields are negative, then ignore them and only send the necessary fields
const fields = keys.length > 0 && customFields[keys[0]] ? {
...this._addDefaultFieldSelector().fields,
..._defaultPublishFields.projection
} : _defaultPublishFields.projection
// Publish the current user's record to the client.
this._server.publish(null, function () {
if (this.userId) {
return users.find({
_id: this.userId
}, {
fields,
});
} else {
return null;
}
}, /*suppress autopublish warning*/{is_auto: true});
});
// Use Meteor.startup to give other packages a chance to call
// addAutopublishFields.
Package.autopublish && Meteor.startup(() => {
// ['profile', 'username'] -> {profile: 1, username: 1}
const toFieldSelector = fields => fields.reduce((prev, field) => (
{ ...prev, [field]: 1 }),
{}
);
this._server.publish(null, function () {
if (this.userId) {
return users.find({ _id: this.userId }, {
fields: toFieldSelector(_autopublishFields.loggedInUser),
})
} else {
return null;
}
}, /*suppress autopublish warning*/{is_auto: true});
// XXX this publish is neither dedup-able nor is it optimized by our special
// treatment of queries on a specific _id. Therefore this will have O(n^2)
// run-time performance every time a user document is changed (eg someone
// logging in). If this is a problem, we can instead write a manual publish
// function which filters out fields based on 'this.userId'.
this._server.publish(null, function () {
const selector = this.userId ? { _id: { $ne: this.userId } } : {};
return users.find(selector, {
fields: toFieldSelector(_autopublishFields.otherUsers),
})
}, /*suppress autopublish warning*/{is_auto: true});
});
};
// Add to the list of fields or subfields to be automatically
// published if autopublish is on. Must be called from top-level
// code (ie, before Meteor.startup hooks run).
//
// @param opts {Object} with:
// - forLoggedInUser {Array} Array of fields published to the logged-in user
// - forOtherUsers {Array} Array of fields published to users that aren't logged in
addAutopublishFields(opts) {
this._autopublishFields.loggedInUser.push.apply(
this._autopublishFields.loggedInUser, opts.forLoggedInUser);
this._autopublishFields.otherUsers.push.apply(
this._autopublishFields.otherUsers, opts.forOtherUsers);
};
// Replaces the fields to be automatically
// published when the user logs in
//
// @param {MongoFieldSpecifier} fields Dictionary of fields to return or exclude.
setDefaultPublishFields(fields) {
this._defaultPublishFields.projection = fields;
};
///
/// ACCOUNT DATA
///
// HACK: This is used by 'meteor-accounts' to get the loginToken for a
// connection. Maybe there should be a public way to do that.
_getAccountData(connectionId, field) {
const data = this._accountData[connectionId];
return data && data[field];
};
_setAccountData(connectionId, field, value) {
const data = this._accountData[connectionId];
// safety belt. shouldn't happen. accountData is set in onConnection,
// we don't have a connectionId until it is set.
if (!data)
return;
if (value === undefined)
delete data[field];
else
data[field] = value;
};
///
/// RECONNECT TOKENS
///
/// support reconnecting using a meteor login token
_hashLoginToken(loginToken) {
const hash = crypto.createHash('sha256');
hash.update(loginToken);
return hash.digest('base64');
};
// {token, when} => {hashedToken, when}
_hashStampedToken(stampedToken) {
const { token, ...hashedStampedToken } = stampedToken;
return {
...hashedStampedToken,
hashedToken: this._hashLoginToken(token)
};
};
// Using $addToSet avoids getting an index error if another client
// logging in simultaneously has already inserted the new hashed
// token.
async _insertHashedLoginToken(userId, hashedToken, query) {
query = query ? { ...query } : {};
query._id = userId;
await this.users.updateAsync(query, {
$addToSet: {
"services.resume.loginTokens": hashedToken
}
});
};
// Exported for tests.
async _insertLoginToken(userId, stampedToken, query) {
await this._insertHashedLoginToken(
userId,
this._hashStampedToken(stampedToken),
query
);
};
/**
*
* @param userId
* @private
* @returns {Promise<void>}
*/
_clearAllLoginTokens(userId) {
this.users.updateAsync(userId, {
$set: {
'services.resume.loginTokens': []
}
});
};
// test hook
_getUserObserve(connectionId) {
return this._userObservesForConnections[connectionId];
};
// Clean up this connection's association with the token: that is, stop
// the observe that we started when we associated the connection with
// this token.
_removeTokenFromConnection(connectionId) {
if (hasOwn.call(this._userObservesForConnections, connectionId)) {
const observe = this._userObservesForConnections[connectionId];
if (typeof observe === 'number') {
// We're in the process of setting up an observe for this connection. We
// can't clean up that observe yet, but if we delete the placeholder for
// this connection, then the observe will get cleaned up as soon as it has
// been set up.
delete this._userObservesForConnections[connectionId];
} else {
delete this._userObservesForConnections[connectionId];
observe.stop();
}
}
};
_getLoginToken(connectionId) {
return this._getAccountData(connectionId, 'loginToken');
};
// newToken is a hashed token.
_setLoginToken(userId, connection, newToken) {
this._removeTokenFromConnection(connection.id);
this._setAccountData(connection.id, 'loginToken', newToken);
if (newToken) {
// Set up an observe for this token. If the token goes away, we need
// to close the connection. We defer the observe because there's
// no need for it to be on the critical path for login; we just need
// to ensure that the connection will get closed at some point if
// the token gets deleted.
//
// Initially, we set the observe for this connection to a number; this
// signifies to other code (which might run while we yield) that we are in
// the process of setting up an observe for this connection. Once the
// observe is ready to go, we replace the number with the real observe
// handle (unless the placeholder has been deleted or replaced by a
// different placehold number, signifying that the connection was closed
// already -- in this case we just clean up the observe that we started).
const myObserveNumber = ++this._nextUserObserveNumber;
this._userObservesForConnections[connection.id] = myObserveNumber;
Meteor.defer(async () => {
// If something else happened on this connection in the meantime (it got
// closed, or another call to _setLoginToken happened), just do
// nothing. We don't need to start an observe for an old connection or old
// token.
if (this._userObservesForConnections[connection.id] !== myObserveNumber) {
return;
}
let foundMatchingUser;
// Because we upgrade unhashed login tokens to hashed tokens at
// login time, sessions will only be logged in with a hashed
// token. Thus we only need to observe hashed tokens here.
const observe = await this.users.find({