apostrophe 3.10.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/index.js +37 -1
  3. package/lib/moog.js +2 -2
  4. package/modules/@apostrophecms/asset/lib/globalIcons.js +2 -0
  5. package/modules/@apostrophecms/attachment/index.js +1 -1
  6. package/modules/@apostrophecms/doc/index.js +2 -2
  7. package/modules/@apostrophecms/doc-type/index.js +2 -2
  8. package/modules/@apostrophecms/express/index.js +7 -1
  9. package/modules/@apostrophecms/i18n/i18n/en.json +1 -0
  10. package/modules/@apostrophecms/i18n/i18n/sk.json +23 -1
  11. package/modules/@apostrophecms/i18n/index.js +62 -13
  12. package/modules/@apostrophecms/login/index.js +295 -52
  13. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +245 -76
  14. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +108 -0
  15. package/modules/@apostrophecms/module/index.js +12 -2
  16. package/modules/@apostrophecms/page/index.js +7 -8
  17. package/modules/@apostrophecms/permission/index.js +1 -1
  18. package/modules/@apostrophecms/piece-type/index.js +1 -1
  19. package/modules/@apostrophecms/schema/index.js +18 -2
  20. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +12 -5
  21. package/modules/@apostrophecms/schema/ui/apos/components/AposInputCheckboxes.vue +4 -0
  22. package/modules/@apostrophecms/ui/ui/apos/lib/i18next.js +18 -0
  23. package/modules/@apostrophecms/util/index.js +3 -9
  24. package/package.json +9 -9
  25. package/test/login-requirements.js +328 -0
  26. package/test/modules/base-type/i18n/custom/en.json +4 -0
  27. package/test/modules/base-type/i18n/en.json +3 -0
  28. package/test/modules/nested-module-subdirs/example1/index.js +5 -0
  29. package/test/modules/nested-module-subdirs/modules.js +7 -0
  30. package/test/modules/subtype/i18n/custom/en.json +4 -0
  31. package/test/modules/subtype/index.js +7 -0
  32. package/test/pages-rest.js +39 -0
  33. package/test/static-i18n.js +28 -0
  34. package/test/with-nested-module-subdirs.js +32 -0
  35. package/test/without-nested-module-subdirs.js +31 -0
  36. package/test-lib/util.js +4 -2
  37. package/.github/pull_request_template.md +0 -8
@@ -37,13 +37,6 @@
37
37
  //
38
38
  // Apostrophe's instance of the [passport](https://npmjs.org/package/passport) npm module.
39
39
  // You may access this object if you need to implement additional passport "strategies."
40
- //
41
- // ## callAll method: loginAfterLogin
42
- //
43
- // The method `loginAfterLogin` is invoked on **all modules that have one**. This method
44
- // is a good place to set `req.redirect` to the URL of your choice. If no module sets
45
- // `req.redirect`, the newly logged-in user is redirected to the home page. `loginAfterLogin`
46
- // is invoked with `req` and may be an async function.
47
40
 
48
41
  const Passport = require('passport').Passport;
49
42
  const LocalStrategy = require('passport-local');
@@ -51,6 +44,7 @@ const Promise = require('bluebird');
51
44
  const cuid = require('cuid');
52
45
 
53
46
  module.exports = {
47
+ cascades: [ 'requirements' ],
54
48
  options: {
55
49
  alias: 'login',
56
50
  localLogin: true,
@@ -113,34 +107,13 @@ module.exports = {
113
107
  return {
114
108
  post: {
115
109
  async login(req) {
116
- const username = self.apos.launder.string(req.body.username);
117
- const password = self.apos.launder.string(req.body.password);
118
- const session = self.apos.launder.boolean(req.body.session);
119
- if (!(username && password)) {
120
- throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
121
- }
122
- const user = await self.apos.login.verifyLogin(username, password);
123
- if (!user) {
124
- // For security reasons we may not tell the user which case applies
125
- throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
126
- }
127
- if (session) {
128
- const passportLogin = (user) => {
129
- return require('util').promisify(function(user, callback) {
130
- return req.login(user, callback);
131
- })(user);
132
- };
133
- await passportLogin(user);
110
+ // Don't make verify functions worry about whether this object
111
+ // is present, just the value of their own sub-property
112
+ req.body.requirements = req.body.requirements || {};
113
+ if (req.body.incompleteToken) {
114
+ return self.finalizeIncompleteLogin(req);
134
115
  } else {
135
- const token = cuid();
136
- await self.bearerTokens.insert({
137
- _id: token,
138
- userId: user._id,
139
- expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
140
- });
141
- return {
142
- token
143
- };
116
+ return self.initialLogin(req);
144
117
  }
145
118
  },
146
119
  async logout(req) {
@@ -148,7 +121,7 @@ module.exports = {
148
121
  throw self.apos.error('forbidden', req.t('apostrophe:logOutNotLoggedIn'));
149
122
  }
150
123
  if (req.token) {
151
- await self.bearerTokens.remove({
124
+ await self.bearerTokens.removeOne({
152
125
  userId: req.user._id,
153
126
  _id: req.token
154
127
  });
@@ -163,6 +136,68 @@ module.exports = {
163
136
  await destroySession();
164
137
  }
165
138
  },
139
+ // invokes the `props(req, user)` function for the requirement specified by
140
+ // `body.name`. Invoked before displaying each `afterPasswordVerified`
141
+ // requirement. The return value of the function, which should
142
+ // be an object, is delivered as the API response
143
+ async requirementProps(req) {
144
+ const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
145
+
146
+ const name = self.apos.launder.string(req.body.name);
147
+
148
+ const requirement = self.requirements[name];
149
+ if (!requirement) {
150
+ throw self.apos.error('notfound');
151
+ }
152
+ if (!requirement.props) {
153
+ return {};
154
+ }
155
+ return requirement.props(req, user);
156
+ },
157
+ async requirementVerify(req) {
158
+ const name = self.apos.launder.string(req.body.name);
159
+
160
+ const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
161
+
162
+ const requirement = self.requirements[name];
163
+
164
+ if (!requirement) {
165
+ throw self.apos.error('notfound');
166
+ }
167
+
168
+ if (!requirement.verify) {
169
+ throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
170
+ }
171
+
172
+ try {
173
+ await requirement.verify(req, req.body.value, user);
174
+
175
+ const token = await self.bearerTokens.findOne({
176
+ _id: self.apos.launder.string(req.body.incompleteToken),
177
+ requirementsToVerify: { $exists: true },
178
+ expires: {
179
+ $gte: new Date()
180
+ }
181
+ });
182
+
183
+ if (!token) {
184
+ throw self.apos.error('notfound');
185
+ }
186
+
187
+ await self.bearerTokens.updateOne(token, {
188
+ $pull: { requirementsToVerify: name }
189
+ });
190
+
191
+ return {};
192
+ } catch (err) {
193
+ err.data = err.data || {};
194
+ err.data.requirement = name;
195
+ throw err;
196
+ }
197
+ },
198
+ async context(req) {
199
+ return self.getContext(req);
200
+ },
166
201
  ...(self.options.passwordReset ? {
167
202
  async resetRequest(req) {
168
203
  const site = (req.headers.host || '').replace(/:\d+$/, '');
@@ -224,19 +259,12 @@ module.exports = {
224
259
  } : {})
225
260
  },
226
261
  get: {
227
- context () {
228
- let aposPackage = {};
229
- try {
230
- aposPackage = require('../../../package.json');
231
- } catch (err) {
232
- self.apos.util.error(err);
233
- }
234
-
235
- return {
236
- env: process.env.NODE_ENV || 'development',
237
- name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
238
- version: aposPackage.version || '3'
239
- };
262
+ // For bc this route is still available via GET, however
263
+ // it should be accessed via POST because the result
264
+ // may differ by individual user session and should not
265
+ // be cached
266
+ async context(req) {
267
+ return self.getContext(req);
240
268
  }
241
269
  }
242
270
  };
@@ -244,6 +272,33 @@ module.exports = {
244
272
  methods(self) {
245
273
  return {
246
274
 
275
+ // Implements the context route, which provides basic
276
+ // information about the site being logged into and also
277
+ // props for beforeSubmit requirements
278
+ async getContext(req) {
279
+ const aposPackage = require('../../../package.json');
280
+ // For performance beforeSubmit requirement props all happen together here
281
+ const requirementProps = {};
282
+ for (const [ name, requirement ] of Object.entries(self.requirements)) {
283
+ if ((requirement.phase !== 'afterPasswordVerified') && requirement.props) {
284
+ try {
285
+ requirementProps[name] = await requirement.props(req);
286
+ } catch (e) {
287
+ if (e.body && e.body.data) {
288
+ e.body.data.requirement = name;
289
+ }
290
+ throw e;
291
+ }
292
+ }
293
+ }
294
+ return {
295
+ env: process.env.NODE_ENV || 'development',
296
+ name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
297
+ version: aposPackage.version || '3',
298
+ requirementProps
299
+ };
300
+ },
301
+
247
302
  // return the loginUrl option
248
303
  login(url) {
249
304
  return self.options.loginUrl ? self.options.loginUrl : '/login';
@@ -375,7 +430,17 @@ module.exports = {
375
430
  username: req.user.username,
376
431
  email: req.user.email
377
432
  }
378
- } : {})
433
+ } : {}),
434
+ requirements: Object.fromEntries(
435
+ Object.entries(self.requirements).map(([ name, requirement ]) => {
436
+ const browserRequirement = {
437
+ phase: requirement.phase,
438
+ propsRequired: !!requirement.props,
439
+ askForConfirmation: requirement.askForConfirmation || false
440
+ };
441
+ return [ name, browserRequirement ];
442
+ })
443
+ )
379
444
  };
380
445
  },
381
446
 
@@ -391,6 +456,164 @@ module.exports = {
391
456
  async enableBearerTokens() {
392
457
  self.bearerTokens = self.apos.db.collection('aposBearerTokens');
393
458
  await self.bearerTokens.createIndex({ expires: 1 }, { expireAfterSeconds: 0 });
459
+ },
460
+
461
+ // Finalize an incomplete login based on the provided incompleteToken
462
+ // and various `requirements` subproperties. Implementation detail of the login route
463
+ async finalizeIncompleteLogin(req) {
464
+ const session = self.apos.launder.boolean(req.body.session);
465
+ // Completing a previous incomplete login
466
+ // (password was verified but post-password-verification
467
+ // requirements were not supplied)
468
+ const token = await self.bearerTokens.findOne({
469
+ _id: self.apos.launder.string(req.body.incompleteToken),
470
+ requirementsToVerify: {
471
+ $exists: true
472
+ },
473
+ expires: {
474
+ $gte: new Date()
475
+ }
476
+ });
477
+
478
+ if (!token) {
479
+ throw self.apos.error('notfound');
480
+ }
481
+
482
+ if (token.requirementsToVerify.length) {
483
+ throw self.apos.error('forbidden', 'All requirements must be verified');
484
+ }
485
+
486
+ const user = await self.deserializeUser(token.userId);
487
+ if (!user) {
488
+ await self.bearerTokens.removeOne({
489
+ _id: token.userId
490
+ });
491
+ throw self.apos.error('notfound');
492
+ }
493
+
494
+ if (session) {
495
+ await self.bearerTokens.removeOne({
496
+ _id: token.userId
497
+ });
498
+ await self.passportLogin(req, user);
499
+ } else {
500
+ delete token.requirementsToVerify;
501
+ self.bearerTokens.updateOne(token, {
502
+ $unset: {
503
+ requirementsToVerify: 1
504
+ }
505
+ });
506
+ return {
507
+ token
508
+ };
509
+ }
510
+ },
511
+
512
+ // Implementation detail of the login route and the requirementProps mechanism for
513
+ // custom login requirements. Given the string `token`, returns
514
+ // `{ token, user }`. Throws an exception if the token is not found.
515
+ // `token` is sanitized before passing to mongodb.
516
+ async findIncompleteTokenAndUser(req, token) {
517
+ token = await self.bearerTokens.findOne({
518
+ _id: self.apos.launder.string(token),
519
+ requirementsToVerify: {
520
+ $exists: true,
521
+ $ne: []
522
+ },
523
+ expires: {
524
+ $gte: new Date()
525
+ }
526
+ });
527
+ if (!token) {
528
+ throw self.apos.error('notfound');
529
+ }
530
+ const user = await self.deserializeUser(token.userId);
531
+ if (!user) {
532
+ await self.bearerTokens.removeOne({
533
+ _id: token._id
534
+ });
535
+ throw self.apos.error('notfound');
536
+ }
537
+ return {
538
+ token,
539
+ user
540
+ };
541
+ },
542
+
543
+ // Implementation detail of the login route. Log in the user, or if there are
544
+ // `requirements` that require password verification occur first, return an incomplete token.
545
+ async initialLogin(req) {
546
+ // Initial login step
547
+ const username = self.apos.launder.string(req.body.username);
548
+ const password = self.apos.launder.string(req.body.password);
549
+ if (!(username && password)) {
550
+ throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
551
+ }
552
+ const { earlyRequirements, lateRequirements } = self.filterRequirements();
553
+ for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
554
+ try {
555
+ await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
556
+ } catch (e) {
557
+ e.data = e.data || {};
558
+ e.data.requirement = name;
559
+ throw e;
560
+ }
561
+ }
562
+ const user = await self.apos.login.verifyLogin(username, password);
563
+ if (!user) {
564
+ // For security reasons we may not tell the user which case applies
565
+ throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
566
+ }
567
+
568
+ const requirementsToVerify = Object.keys(lateRequirements);
569
+
570
+ if (requirementsToVerify.length) {
571
+ const token = cuid();
572
+
573
+ await self.bearerTokens.insert({
574
+ _id: token,
575
+ userId: user._id,
576
+ requirementsToVerify,
577
+ // Default lifetime of 1 hour is generous to permit situations like
578
+ // installing a TOTP app for the first time
579
+ expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
580
+ });
581
+ return {
582
+ incompleteToken: token
583
+ };
584
+ } else {
585
+ const session = self.apos.launder.boolean(req.body.session);
586
+ if (session) {
587
+ await self.passportLogin(req, user);
588
+ } else {
589
+ const token = cuid();
590
+ await self.bearerTokens.insert({
591
+ _id: token,
592
+ userId: user._id,
593
+ expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
594
+ });
595
+ return {
596
+ token
597
+ };
598
+ }
599
+ }
600
+ },
601
+
602
+ filterRequirements() {
603
+ return {
604
+ earlyRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'beforeSubmit')),
605
+ lateRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'afterPasswordVerified'))
606
+ };
607
+ },
608
+
609
+ // Awaitable wrapper for req.login. An implementation detail of the login route
610
+ async passportLogin(req, user) {
611
+ const passportLogin = (user) => {
612
+ return require('util').promisify(function(user, callback) {
613
+ return req.login(user, callback);
614
+ })(user);
615
+ };
616
+ await passportLogin(user);
394
617
  }
395
618
  };
396
619
  },
@@ -405,15 +628,35 @@ module.exports = {
405
628
  before: '@apostrophecms/i18n',
406
629
  middleware(req, res, next) {
407
630
  const superLogin = req.login.bind(req);
408
- req.login = (user, callback) => {
409
- return superLogin(user, (err) => {
631
+ req.login = (user, ...args) => {
632
+ let options, callback;
633
+ // Support inconsistent calling conventions inside passport core
634
+ if (typeof args[0] === 'function') {
635
+ options = {};
636
+ callback = args[0];
637
+ } else {
638
+ options = args[0];
639
+ callback = args[1];
640
+ }
641
+ return superLogin(user, options, async (err) => {
410
642
  if (err) {
411
643
  return callback(err);
412
644
  }
413
- req.session.loginAt = Date.now();
645
+ await self.emit('afterSessionLogin', req);
646
+ // Make sure no handler removed req.user
647
+ if (req.user) {
648
+ // Mark the login timestamp. Middleware takes care of ensuring
649
+ // that logins cannot be used to carry out actions prior
650
+ // to this property being added
651
+ req.session.loginAt = Date.now();
652
+ }
414
653
  return callback(null);
415
654
  });
416
655
  };
656
+ // Passport itself maintains this bc alias, while refusing
657
+ // to actually decide which one is best in its own dev docs.
658
+ // Both have to exist to avoid bugs when passport calls itself
659
+ req.logIn = req.login;
417
660
  return next();
418
661
  }
419
662
  },