apostrophe 3.63.2 → 3.64.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.64.0 (2024-04-18)
4
+
5
+ ### Adds
6
+
7
+ ### Fixes
8
+
9
+ * Add the missing `metaType` property to newly inserted widgets.
10
+
11
+ ### Security
12
+
13
+ * New passwords are now hashed with `scrypt`, the best password hash available in the Node.js core `crypto` module, following guidance from [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html).
14
+ This reduces login time while improving overall security.
15
+ * Old passwords are automatically re-hashed with `scrypt` on the next successful login attempt, which
16
+ adds some delay to that next attempt, but speeds them up forever after compared to the old implementation.
17
+ * Custom `scrypt` parameters for password hashing can be passed to the `@apostrophecms/user` module via the `scrypt` option. See the [Node.js documentation for `scrypt`]. Note that the `maxmem` parameter is computed automatically based on the other parameters.
18
+
19
+ ### Changes
20
+
21
+ * `APOS_MONGODB_LOG_LEVEL` has been removed. According to [mongodb documentation](https://github.com/mongodb/node-mongodb-native/blob/main/etc/notes/CHANGES_5.0.0.md#mongoclientoptionslogger-and-mongoclientoptionsloglevel-removed) "Both the logger and the logLevel options had no effect and have been removed."
22
+ * Update `connect-mongo` to `5.x`. Add `@apostrophecms/emulate-mongo-3-driver` dependency to keep supporting `mongodb@3.x` queries while using `mongodb@6.x`.
23
+
24
+ ## 3.63.3 (2024-03-14)
25
+
26
+ ### Adds
27
+
28
+ * Add translation keys used by the multisite assembly module. This was released ahead of
29
+ our regular schedule because the multisite module was released early with the expectation
30
+ that these keys would be present.
31
+
32
+ ### Fixes
33
+
34
+ * `field.help` and `field.htmlHelp` are now correctly translated when displayed in a tooltip.
35
+ This was also an expectation for the multisite module.
36
+
37
+ ## UNRELEASED
38
+
39
+ ### Adds
40
+
41
+ * Add side by side comparison support in AposSchema component.
42
+ * Add the possibility to make widget modals wider, which can be useful for widgets that contain areas taking significant space. See [documentation](https://v3.docs.apostrophecms.org/reference/modules/widget-type.html#options).
43
+
44
+ ### Fixes
45
+
46
+ * Adds `textStyle` to Tiptap types so that spans are rendered on RT initialization
47
+ * Notification REST APIs should not directly return the result of MongoDB operations.
48
+
3
49
  ## 3.63.2 (2024-03-01)
4
50
 
5
51
  ### Security
package/defaults.js CHANGED
@@ -4,6 +4,7 @@ module.exports = {
4
4
  '@apostrophecms/error': {},
5
5
  '@apostrophecms/util': {},
6
6
  '@apostrophecms/i18n': {},
7
+ '@apostrophecms/multisite-i18n': {},
7
8
  '@apostrophecms/task': {},
8
9
  '@apostrophecms/schema': {},
9
10
  '@apostrophecms/uploadfs': {},
@@ -1,4 +1,4 @@
1
- const mongo = require('mongodb');
1
+ const mongo = require('@apostrophecms/emulate-mongo-3-driver');
2
2
  const dns = require('dns');
3
3
 
4
4
  // Connect to MongoDB, using the modern topology and parser, and
@@ -622,7 +622,7 @@ module.exports = {
622
622
  widgetIsContextual[name] = manager.options.contextual;
623
623
  widgetHasPlaceholder[name] = manager.options.placeholder;
624
624
  widgetHasInitialModal[name] = !manager.options.placeholder && manager.options.initialModal !== false;
625
- contextualWidgetDefaultData[name] = manager.options.defaultData;
625
+ contextualWidgetDefaultData[name] = manager.options.defaultData || {};
626
626
  });
627
627
 
628
628
  return {
@@ -431,7 +431,9 @@ export default {
431
431
  },
432
432
  async update(widget) {
433
433
  widget.aposPlaceholder = false;
434
-
434
+ if (!widget.metaType) {
435
+ widget.metaType = 'widget';
436
+ }
435
437
  if (this.docId === window.apos.adminBar.contextId) {
436
438
  apos.bus.$emit('context-edited', {
437
439
  [`@${widget._id}`]: widget
@@ -515,6 +517,9 @@ export default {
515
517
  if (!widget._id) {
516
518
  widget._id = cuid();
517
519
  }
520
+ if (!widget.metaType) {
521
+ widget.metaType = 'widget';
522
+ }
518
523
  const push = {
519
524
  $each: [ widget ]
520
525
  };
@@ -96,12 +96,6 @@ module.exports = {
96
96
  self.connectionReused = true;
97
97
  return;
98
98
  }
99
- let Logger;
100
- if (process.env.APOS_MONGODB_LOG_LEVEL) {
101
- Logger = require('mongodb').Logger;
102
- // Set debug level
103
- Logger.setLevel(process.env.APOS_MONGODB_LOG_LEVEL);
104
- }
105
99
  let uri = 'mongodb://';
106
100
  if (process.env.APOS_MONGODB_URI) {
107
101
  uri = process.env.APOS_MONGODB_URI;
@@ -2682,8 +2682,14 @@ module.exports = {
2682
2682
  subquery.limit(undefined);
2683
2683
  subquery.page(undefined);
2684
2684
  subquery.perPage(undefined);
2685
- const mongo = await subquery.toMongo();
2686
- const count = await mongo.count();
2685
+ const cursor = await subquery.toMongo();
2686
+ const count = await cursor.count();
2687
+ // TODO: replace count with the code below
2688
+ // await subquery.finalize();
2689
+ // const count = await self.apos.doc.db.countDocuments({
2690
+ // ...subquery.get('criteria'),
2691
+ // ...(subquery.get('lateCriteria') || {})
2692
+ // });
2687
2693
  if (query.get('perPage')) {
2688
2694
  const perPage = query.get('perPage');
2689
2695
  const totalPages = Math.ceil(count / perPage);
@@ -554,12 +554,13 @@ module.exports = {
554
554
  }
555
555
  if (!sessionOptions.store.name) {
556
556
  // require from this module's dependencies
557
- Store = require('connect-mongo')(expressSession);
557
+ const MongoStore = require('connect-mongo');
558
+ sessionOptions.store = MongoStore.create(sessionOptions.store.options);
558
559
  } else {
559
560
  // require from project's dependencies
560
561
  Store = self.apos.root.require(sessionOptions.store.name)(expressSession);
562
+ sessionOptions.store = new Store(sessionOptions.store.options);
561
563
  }
562
- sessionOptions.store = new Store(sessionOptions.store.options);
563
564
  }
564
565
  // Exported for the benefit of code that needs to
565
566
  // interoperate in a compatible way with express-sessions
@@ -115,6 +115,11 @@ module.exports = {
115
115
  // https://groups.google.com/forum/#!topic/mongodb-user/AFC1ia7MHzk
116
116
  const cursor = collection.find(criteria);
117
117
  cursor.sort({ _id: 1 });
118
+ // TODO use a variant of the code below instead
119
+ // cursor.batchSize(limit);
120
+ // for await (const docs of cursor) {
121
+ // // await iterator(docs);
122
+ // }
118
123
  return require('util').promisify(broadband)(cursor, limit, async function (doc, cb) {
119
124
  try {
120
125
  await iterator(doc);
@@ -308,7 +308,7 @@ export default {
308
308
  top: 0;
309
309
  bottom: 0;
310
310
  transform: translateX(0);
311
- width: 90%;
311
+ width: 100%;
312
312
  border-radius: 0;
313
313
  height: 100vh;
314
314
 
@@ -339,6 +339,12 @@ export default {
339
339
  }
340
340
  }
341
341
 
342
+ &.apos-modal__inner--full {
343
+ @media screen and (min-width: 800px) {
344
+ max-width: 100%;
345
+ }
346
+ }
347
+
342
348
  &.slide-left-enter,
343
349
  &.slide-left-leave-to {
344
350
  transform: translateX(100%);
@@ -0,0 +1,48 @@
1
+ {
2
+ "shortName": "Short Name",
3
+ "shortNameHelp": "If the short name is \"niftypig\", then the temporary hostname of the site will be \"niftypig.{{ baseDomain }}\".",
4
+ "prodHostname": "Production Hostname",
5
+ "prodHostnameHelp": "We will also automatically add \"www.\" as an alternate. The final name of the site. Do not add unless the DNS is being changed or has been changed to point to this service",
6
+ "canonicalize": "Redirect to Production Hostname",
7
+ "canonicalizeHelp": "Do not activate this until you see that both DNS and HTTPS are working for the production hostname.",
8
+ "canonicalizeStatus": "Canonical Redirect Status Code",
9
+ "canonicalizeStatusHelp": "\"Moved Permanently\" is best for SEO, but you should make sure you are happy with the results using \"Moved Temporarily\" first to avoid caching of bad redirects.",
10
+ "hostnamesArray": "Hostnames",
11
+ "hostnamesArrayHelp": "All valid hostnames for the site must be on this list, for instance both example.com and www.example.com",
12
+ "devBaseUrl": "Development Base URL",
13
+ "devBaseUrlHelp": "like http://localhost:3000",
14
+ "stagingBaseUrl": "Staging Base URL",
15
+ "stagingBaseUrlHelp": "like http://project.staging.org",
16
+ "prodBaseUrl": "Production Base URL",
17
+ "prodBaseUrlHelp": "like https://myproject.com",
18
+ "localeName": "Name",
19
+ "localeNameHelp": "Like en or en-GB. NOTE: the name may be changed but renaming a locale can be a slow operation. Consider changing just the label, prefix and hostname.",
20
+ "localeLabel": "Label",
21
+ "localeLabelHelp": "Like British English",
22
+ "localePrefix": "Prefix",
23
+ "localePrefixHelp": "Like /en",
24
+ "localeSeparateHost": "Separate Host",
25
+ "localeSeparateHostHelp": "This locale requires a separate hostname, e.g. fr.example.com in staging or example.fr in production.",
26
+ "localeStagingSubdomain": "Staging Subdomain",
27
+ "localeStagingSubdomainHelp": "Custom subdomain used in staging. Multiple locales can be configured with the same subdomain in order to group them on it, for instance \"canada.example.com/en\" and \"canada.example.com/fr\". Note that all but one locale must have a prefix for distinction. If left blank, the locale name will be used as the subdomain, outside of production.",
28
+ "localeSeparateProductionHostname": "Separate Production Hostname",
29
+ "localeSeparateProductionHostnameHelp": "Like example.fr. If not set, defaults to LOCALE.SHORTNAME.{{ baseDomain }}, e.g. fr.somesite.{{ baseDomain }}.",
30
+ "localePrivate": "Private locale",
31
+ "localePrivateHelp": "This locale is private",
32
+ "adminPassword": "Admin Password",
33
+ "adminPasswordHelp": "Set password for the \"admin\" user of the new site. For pre-existing sites, leave blank for no change.",
34
+ "redirect": "Redirect Entire Site",
35
+ "redirectHelp": "Redirect all traffic for the site to another URL.",
36
+ "redirectUrl": "Redirect To...",
37
+ "redirectUrlHelp": "Redirect traffic to this URL.",
38
+ "redirectPreservePath": "Preserve the Path when Redirecting",
39
+ "redirectPreservePathHelp": "If the URL ends with /something, add /something to the redirect URL as well. Otherwise, all traffic is redirected to a single place.",
40
+ "redirectStatus": "Redirect Status Code",
41
+ "redirectStatusHelp": "\"Moved Permanently\" is best for SEO, but you should test thoroughly first with \"Moved Temporarily\" to avoid caching of bad redirects.",
42
+ "emptyAdminPasswordError": "You must fill out the admin password field.",
43
+ "shortnameError": "The short name of the site must not contain dots, a protocol or spaces. It is a short name like \"nifty\" (without quotes) and will be used as a \"working name\" for a temporary subdomain for your site until it is launched.",
44
+ "shortnameInUseError": "That short name is already in use by another site.",
45
+ "productionHostnameInUseError": "That Production Hostname is already in use by another site.",
46
+ "renamingLocale": "Renaming locale {{ oldName }} to {{ newName }} in site {{ siteName }}, access to the site is paused, this may take time",
47
+ "localeRenamed": "Locale renamed"
48
+ }
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ i18n: {
3
+ aposMultisite: {
4
+ browser: true
5
+ }
6
+ }
7
+ };
@@ -154,7 +154,7 @@ module.exports = {
154
154
  dismissed
155
155
  });
156
156
 
157
- return self.db.updateOne({ _id }, {
157
+ await self.db.updateOne({ _id }, {
158
158
  $set: {
159
159
  dismissed
160
160
  },
@@ -164,8 +164,8 @@ module.exports = {
164
164
  });
165
165
  }
166
166
  },
167
- delete(req, _id) {
168
- return self.db.deleteMany({ _id });
167
+ async delete(req, _id) {
168
+ await self.db.deleteMany({ _id });
169
169
  }
170
170
  }),
171
171
  apiRoutes(self) {
@@ -414,7 +414,7 @@ module.exports = {
414
414
 
415
415
  async ensureCollection() {
416
416
  self.db = self.apos.db.collection('aposNotifications');
417
- return self.db.createIndex({
417
+ await self.db.createIndex({
418
418
  userId: 1,
419
419
  createdAt: 1
420
420
  });
@@ -331,6 +331,7 @@ module.exports = {
331
331
  blockquote: [ 'blockquote' ],
332
332
  superscript: [ 'sup' ],
333
333
  subscript: [ 'sub' ],
334
+ textStyle: [ 'span' ],
334
335
  // Generic div type, usually used with classes,
335
336
  // and for A2 content migration. Intentionally not
336
337
  // given a nicer-sounding name
@@ -38,7 +38,7 @@
38
38
  <AposIndicator
39
39
  icon="help-circle-icon"
40
40
  class="apos-field__help-tooltip__icon"
41
- :tooltip="field.help || field.htmlHelp"
41
+ :tooltip="$t(field.help || field.htmlHelp)"
42
42
  :icon-size="11"
43
43
  icon-color="var(--a-base-4)"
44
44
  />
@@ -25,11 +25,13 @@
25
25
  <template>
26
26
  <component
27
27
  class="apos-schema"
28
+ :class="classes"
28
29
  :is="fieldStyle === 'table' ? 'tr' : 'div'"
29
30
  >
30
31
  <slot name="before" />
31
32
  <component
32
- v-for="field in schema" :key="field.name.concat(field._id ?? '')"
33
+ v-for="field in schema"
34
+ :key="field.name.concat(field._id ?? '')"
33
35
  :data-apos-field="field.name"
34
36
  :is="fieldStyle === 'table' ? 'td' : 'div'"
35
37
  :style="(fieldStyle === 'table' && field.columnStyle) || {}"
@@ -53,6 +55,25 @@
53
55
  @update-doc-data="onUpdateDocData"
54
56
  @validate="emitValidate()"
55
57
  />
58
+ <component
59
+ v-if="hasCompareMeta"
60
+ v-show="displayComponent(field)"
61
+ v-model="compareMetaState[field.name]"
62
+ :is="fieldComponentMap[field.type]"
63
+ :following-values="followingValues[field.name]"
64
+ :condition-met="conditionalFields?.if[field.name]"
65
+ :field="fields[field.name].field"
66
+ :meta="meta"
67
+ :modifiers="fields[field.name].modifiers"
68
+ :display-options="getDisplayOptions(field.name)"
69
+ :trigger-validation="triggerValidation"
70
+ :server-error="fields[field.name].serverError"
71
+ :doc-id="docId"
72
+ :ref="field.name"
73
+ :generation="generation"
74
+ @update-doc-data="onUpdateDocData"
75
+ @validate="emitValidate()"
76
+ />
56
77
  </component>
57
78
  <slot name="after" />
58
79
  </component>
@@ -96,4 +117,26 @@ export default {
96
117
  margin-bottom: 0;
97
118
  }
98
119
  }
120
+
121
+ .apos-schema.apos-schema--compare {
122
+ & > ::v-deep [data-apos-field] {
123
+ display: flex;
124
+
125
+ & > .apos-field__wrapper {
126
+ flex-grow: 1;
127
+ flex-basis: 50%;
128
+ border-right: 1px solid var(--a-base-9);
129
+ padding-right: 20px;
130
+ }
131
+ & > .apos-field__wrapper + .apos-field__wrapper {
132
+ border-right: none;
133
+ padding-right: 0;
134
+ padding-left: 20px;
135
+ }
136
+
137
+ & .apos-field__label {
138
+ word-break: break-all;
139
+ }
140
+ }
141
+ }
99
142
  </style>
@@ -130,6 +130,32 @@ export default {
130
130
  }
131
131
  };
132
132
  }, {});
133
+ },
134
+ hasCompareMeta() {
135
+ return this.schema.some(field => this.meta[field.name]?.['@apostrophecms/schema:compare']);
136
+ },
137
+ classes() {
138
+ const classes = [];
139
+ if (this.hasCompareMeta) {
140
+ classes.push('apos-schema--compare');
141
+ }
142
+
143
+ return classes;
144
+ },
145
+ compareMetaState() {
146
+ if (!this.hasCompareMeta) {
147
+ return {};
148
+ }
149
+
150
+ const compareMetaState = {};
151
+ this.schema.forEach(field => {
152
+ compareMetaState[field.name] = {
153
+ error: false,
154
+ data: this.meta[field.name]['@apostrophecms/schema:compare']
155
+ };
156
+ });
157
+
158
+ return compareMetaState;
133
159
  }
134
160
  },
135
161
  watch: {
@@ -34,7 +34,7 @@
34
34
  // their preferred language for the admin UI. It will be added only if
35
35
  // @apostrophecms/i18n is configured with `adminLocales`.
36
36
 
37
- const credentials = require('credentials');
37
+ const passwordHash = require('./lib/password-hash.js');
38
38
  const prompts = require('prompts');
39
39
 
40
40
  module.exports = {
@@ -52,7 +52,20 @@ module.exports = {
52
52
  publishRole: 'admin',
53
53
  viewRole: 'admin',
54
54
  showPermissions: true,
55
- relationshipSuggestionIcon: 'account-box-icon'
55
+ relationshipSuggestionIcon: 'account-box-icon',
56
+ scrypt: {
57
+ // These are the defaults. If you choose to pass
58
+ // this option, you can pass one or more new values.
59
+ // "cost" must be a power of 2. See:
60
+ //
61
+ // https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
62
+ //
63
+ // Do not pass maxmem, it is calculated automatically.
64
+ //
65
+ // cost: 131072,
66
+ // parallelization: 1,
67
+ // blockSize: 8
68
+ }
56
69
  },
57
70
  fields(self, options) {
58
71
  const fields = {};
@@ -426,11 +439,11 @@ module.exports = {
426
439
  // in `options.secrets` when configuring this module or via
427
440
  // `addSecrets` are not stored as plaintext and are not kept in the
428
441
  // aposDocs collection. Instead, they are hashed and salted using the
429
- // `credential` module and the resulting hash is stored
442
+ // the same algorithm applied to passwords and the resulting hash is stored
430
443
  // in a separate `aposUsersSafe` collection. This method
431
444
  // can be used to verify that `attempt` matches the
432
445
  // previously hashed value for the property named `secret`,
433
- // without ever storing the actual value of the secret
446
+ // without ever storing the actual value of the secret.
434
447
  //
435
448
  // If the secret does not match, an `invalid` error is thrown.
436
449
  // Otherwise the method returns normally.
@@ -440,10 +453,20 @@ module.exports = {
440
453
  if (!safeUser) {
441
454
  throw new Error('No such user in the safe.');
442
455
  }
443
-
444
- const isVerified = await self.pw.verify(migrate(safeUser[secret + 'Hash']), attempt);
456
+ const key = secret + 'Hash';
457
+ const isVerified = await self.pw.verify(migrate(safeUser[key]), attempt);
445
458
 
446
459
  if (isVerified) {
460
+ if ((typeof isVerified) === 'string') {
461
+ // "verify" updated the hash, store the new one
462
+ const $set = {};
463
+ $set[key] = isVerified;
464
+ await self.safe.updateOne({
465
+ _id: user._id
466
+ }, {
467
+ $set
468
+ });
469
+ }
447
470
  return null;
448
471
  } else {
449
472
  throw self.apos.error('invalid', `Incorrect ${secret}`);
@@ -452,8 +475,9 @@ module.exports = {
452
475
  function migrate(json) {
453
476
  const data = JSON.parse(json);
454
477
 
455
- // Do not re-encode salt generated by credentials@3
456
- if (data.credentials3) {
478
+ // * Do not re-encode legacy salt generated by credentials@3
479
+ // * Do not alter salts not generated by the credentials module
480
+ if (data.credentials3 || (data.hashMethod !== 'pbkdf2')) {
457
481
  return json;
458
482
  }
459
483
 
@@ -477,13 +501,15 @@ module.exports = {
477
501
  await self.safe.updateOne({ _id: user._id }, changes);
478
502
  },
479
503
 
480
- // Initialize the [credential](https://npmjs.org/package/credential) module.
504
+ // Initialize password hashing system. Name is for
505
+ // legacy reasons
506
+
481
507
  initializeCredential() {
482
- self.pw = credentials({
483
- // For efficient unit tests only. Reducing the work factor
484
- // for actual credentials increases the speed of brute force attacks
485
- // if the database is ever compromised
486
- work: self.options.insecurePasswords ? 0.01 : 1
508
+ self.pw = passwordHash({
509
+ error(s) {
510
+ return self.apos.error('invalid', s);
511
+ },
512
+ scrypt: self.options.scrypt
487
513
  });
488
514
  },
489
515
 
@@ -0,0 +1,122 @@
1
+ // Password hashing based on scrypt, per:
2
+ // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
3
+ //
4
+ // Also includes legacy support for pbkdf2 passwords.
5
+ //
6
+ // Adapted from the "credential" and "credentials" modules,
7
+ // which were also released under the MIT license.
8
+
9
+ const util = require('util');
10
+ const crypto = require('crypto');
11
+
12
+ const scrypt = util.promisify(crypto.scrypt);
13
+ const pbkdf2 = util.promisify(crypto.pbkdf2);
14
+ const randomBytes = util.promisify(crypto.randomBytes);
15
+ const timingSafeEqual = crypto.timingSafeEqual;
16
+
17
+ function getScryptOptions(options) {
18
+ const result = {
19
+ cost: 131072,
20
+ parallelization: 1,
21
+ blockSize: 8,
22
+ ...options
23
+ };
24
+ // Per https://github.com/nodejs/node/issues/21524
25
+ // Without this the parameters are rejected as soon as we
26
+ // exceed the default cost of 16384
27
+ result.maxmem = 128 * result.parallelization * result.blockSize + 128 * (2 + result.cost) * result.blockSize;
28
+ return result;
29
+ }
30
+
31
+ configure.hash = hash;
32
+ configure.verify = verify;
33
+
34
+ module.exports = configure;
35
+
36
+ function configure(opts) {
37
+ opts = {
38
+ keyLength: 64,
39
+ ...opts,
40
+ scrypt: getScryptOptions(opts.scrypt)
41
+ };
42
+ return {
43
+ hash: password => hash(password, opts),
44
+ verify: (stored, input) => verify(stored, input, opts)
45
+ };
46
+ }
47
+
48
+ async function hash(password, opts) {
49
+ const { keyLength } = opts;
50
+
51
+ if (typeof password !== 'string' || password.length === 0) {
52
+ throw opts.error('Password must be a non-empty string.');
53
+ }
54
+
55
+ const salt = await randomBytes(keyLength);
56
+ const hash = await scrypt(password, salt, keyLength, opts.scrypt);
57
+
58
+ return JSON.stringify({
59
+ hashMethod: 'scrypt',
60
+ salt: salt.toString('base64'),
61
+ hash: hash.toString('base64'),
62
+ keyLength,
63
+ scrypt: opts.scrypt
64
+ });
65
+ }
66
+
67
+ async function verify(stored, input, opts) {
68
+ const parsed = parse(stored);
69
+
70
+ const {
71
+ hashMethod, keyLength, salt, hash: hashA
72
+ } = parse(stored);
73
+
74
+ if (typeof input !== 'string' || input.length === 0) {
75
+ throw opts.error('Input password must be a non-empty string.');
76
+ }
77
+ if (!hashMethod) {
78
+ throw opts.error('Couldn\'t parse stored hash.');
79
+ }
80
+ let hashB;
81
+ if (hashMethod === 'scrypt') {
82
+ // Use scrypt as a more modern but also safely portable
83
+ // solution in Node.js
84
+ const { scrypt: scryptOptions } = parsed;
85
+ // Calculate maxmem to make sure we still have the resources
86
+ // if this password was hashed with a higher cost factor
87
+ // than the one we are using for new passwords
88
+ hashB = await scrypt(input, salt, keyLength, getScryptOptions(scryptOptions));
89
+ } else {
90
+ // Support existing pbkdf2 hashes from credentials module
91
+ const { iterations } = parsed;
92
+ const dfn = hashMethod.slice(0, 6);
93
+ const hfn = hashMethod.slice(7) || 'sha1';
94
+ if (dfn !== 'pbkdf2') {
95
+ throw opts.error('Unsupported key derivation function');
96
+ }
97
+ if (![ 'sha1', 'sha512' ].includes(hfn)) {
98
+ throw opts.error('Unsupported hash function');
99
+ }
100
+ hashB = await pbkdf2(input, salt, iterations, keyLength, hfn);
101
+ }
102
+ const equal = timingSafeEqual(hashA, hashB);
103
+ if (equal && (hashMethod !== 'scrypt')) {
104
+ // Modernize legacy hashes on next login
105
+ return hash(input, opts);
106
+ } else {
107
+ return equal;
108
+ }
109
+ }
110
+
111
+ function parse(stored) {
112
+ try {
113
+ const parsed = JSON.parse(stored);
114
+ return {
115
+ ...parsed,
116
+ salt: Buffer.from(parsed.salt, 'base64'),
117
+ hash: Buffer.from(parsed.hash, 'base64')
118
+ };
119
+ } catch (err) {
120
+ return {};
121
+ }
122
+ }
@@ -100,7 +100,11 @@ module.exports = {
100
100
  neverLoadSelf: true,
101
101
  initialModal: true,
102
102
  placeholder: false,
103
- placeholderClass: 'apos-placeholder'
103
+ placeholderClass: 'apos-placeholder',
104
+ // two-thirds, half or full:
105
+ width: '',
106
+ // left or right:
107
+ origin: 'right'
104
108
  },
105
109
  init(self) {
106
110
  const badFieldName = Object.keys(self.fields).indexOf('type') !== -1;
@@ -400,7 +404,9 @@ module.exports = {
400
404
  contextual: self.options.contextual,
401
405
  placeholderClass: self.options.placeholderClass,
402
406
  className: self.options.className,
403
- components: self.options.components
407
+ components: self.options.components,
408
+ width: self.options.width,
409
+ origin: self.options.origin
404
410
  });
405
411
  return result;
406
412
  }
@@ -92,6 +92,8 @@ export default {
92
92
  },
93
93
  emits: [ 'safe-close', 'modal-result' ],
94
94
  data() {
95
+ const moduleOptions = window.apos.modules[apos.area.widgetManagers[this.type]];
96
+
95
97
  return {
96
98
  id: this.value && this.value._id,
97
99
  original: null,
@@ -103,6 +105,8 @@ export default {
103
105
  title: this.editLabel,
104
106
  active: false,
105
107
  type: 'slide',
108
+ width: moduleOptions.width,
109
+ origin: moduleOptions.origin,
106
110
  showModal: false
107
111
  },
108
112
  triggerValidation: false
@@ -218,9 +222,3 @@ export default {
218
222
  }
219
223
  };
220
224
  </script>
221
-
222
- <style lang="scss" scoped>
223
- .apos-widget-editor ::v-deep .apos-modal__inner {
224
- max-width: 458px;
225
- }
226
- </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.63.2",
3
+ "version": "3.64.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -31,6 +31,7 @@
31
31
  "author": "Apostrophe Technologies, Inc.",
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
+ "@apostrophecms/emulate-mongo-3-driver": "^1.0.2",
34
35
  "@apostrophecms/vue-color": "^2.8.2",
35
36
  "@opentelemetry/api": "^1.0.4",
36
37
  "@opentelemetry/semantic-conventions": "^1.0.1",
@@ -74,11 +75,10 @@
74
75
  "cheerio": "^1.0.0-rc.10",
75
76
  "chokidar": "^3.5.2",
76
77
  "common-tags": "^1.8.0",
77
- "connect-mongo": "^3.0.0",
78
+ "connect-mongo": "^5.1.0",
78
79
  "connect-multiparty": "^2.1.1",
79
80
  "cookie-parser": "^1.4.5",
80
81
  "cors": "^2.8.5",
81
- "credentials": "^3.0.2",
82
82
  "css-loader": "^5.2.4",
83
83
  "cuid": "^2.1.8",
84
84
  "dayjs": "^1.9.8",
@@ -104,7 +104,6 @@
104
104
  "mini-css-extract-plugin": "^1.6.0",
105
105
  "minimatch": "^3.0.4",
106
106
  "mkdirp": "^0.5.5",
107
- "mongodb": "^3.6.6",
108
107
  "node-fetch": "^2.6.1",
109
108
  "nodemailer": "^6.6.1",
110
109
  "nunjucks": "^3.2.1",
@@ -0,0 +1,56 @@
1
+ const assert = require('assert');
2
+ const passwordHash = require('../modules/@apostrophecms/user/lib/password-hash.js');
3
+
4
+ const legacyHash = '{"hashMethod":"pbkdf2-sha512","salt":"JEB7TX4iOky4kWy+1xsGlN0u7GpEtEoUxmRzaf0Oi35A5j9ynYZfT1Lk4JofBz5nbAHD4HoMqQnevltTLd4Hbw==","hash":"aFM6axOnaPiwNGly7NsfYEvFHEv1ML4lNyi2nEz95tudK1/M1PUlMbtxujZ+W1Gv8Q2mHh7KnL6Ql94OOL8S0g==","keyLength":64,"iterations":4449149,"credentials3":true}';
5
+
6
+ describe('password-hash', function() {
7
+ it('can hash a password', async function() {
8
+ const instance = getInstance();
9
+ const hash = await instance.hash('test one');
10
+ assert(hash);
11
+ });
12
+ it('can verify a correct password', async function() {
13
+ const instance = getInstance();
14
+ const hash = await instance.hash('test one');
15
+ assert(await instance.verify(hash, 'test one'));
16
+ });
17
+ it('cannot verify an incorrect password', async function() {
18
+ const instance = getInstance();
19
+ const hash = await instance.hash('test one');
20
+ assert(!await instance.verify(hash, 'test two'));
21
+ });
22
+ it('hash does not contain password and uses scrypt with parameters', async function() {
23
+ const instance = getInstance();
24
+ const hash = JSON.parse(await instance.hash('test one'));
25
+ assert(!JSON.stringify(hash).includes('test one'));
26
+ assert.strictEqual(hash.hashMethod, 'scrypt');
27
+ assert(hash.scrypt);
28
+ assert.strictEqual(hash.scrypt.cost, 131072);
29
+ assert.strictEqual(hash.scrypt.blockSize, 8);
30
+ assert.strictEqual(hash.scrypt.parallelization, 1);
31
+ });
32
+ it('can verify and modernize a legacy pbkdf2 password hash', async function() {
33
+ this.timeout(10000);
34
+ const instance = getInstance();
35
+ const hash = await instance.verify(legacyHash, 'test-password');
36
+ assert(hash);
37
+ assert.strictEqual(typeof hash, 'string');
38
+ const data = JSON.parse(hash);
39
+ assert.strictEqual(data.hashMethod, 'scrypt');
40
+ assert.strictEqual(await instance.verify(hash, 'test-password'), true);
41
+ assert.strictEqual(await instance.verify(hash, 'bogus-password'), false);
42
+ });
43
+ it('can reject a bad password for a legacy pbkdf2 hash', async function() {
44
+ this.timeout(10000);
45
+ const instance = getInstance();
46
+ assert(!await instance.verify(legacyHash, 'bad-password'));
47
+ });
48
+ });
49
+
50
+ function getInstance() {
51
+ return passwordHash({
52
+ error(s) {
53
+ return new Error(s);
54
+ }
55
+ });
56
+ }
package/test/users.js CHANGED
@@ -99,7 +99,7 @@ describe('Users', function() {
99
99
  }
100
100
  });
101
101
 
102
- it('should verify a user password created with former credential package', async function() {
102
+ it('should verify a user password created with former credential package and also upgrade the hash', async function() {
103
103
  const req = apos.task.getReq();
104
104
  const user = apos.user.newInstance();
105
105
 
@@ -115,9 +115,25 @@ describe('Users', function() {
115
115
  const oldPasswordHashSimulated =
116
116
  '{"hash":"/1GntJjtkMY1iPmQY1gn9f3bOZ5tb2qFL+x4qsDerZq2JL8+12TERR4/xqh246wBb+QJwwIRsF/6E+eccshsLxT/","salt":"GJHukLNaG6xDgdIpxVOpqV7xQLQM7e5xnhDW7oaUOe7mTicr7Ca76M4uUJalN/cQ68CE9O7yXZ5WJOz4RN/udcX0","keyLength":66,"hashMethod":"pbkdf2","iterations":2853053}';
117
117
 
118
- await apos.user.safe.update({ username: 'olduser' }, { $set: { passwordHash: oldPasswordHashSimulated } });
118
+ await apos.user.safe.updateOne({ username: 'olduser' }, { $set: { passwordHash: oldPasswordHashSimulated } });
119
119
  await apos.user.verifyPassword(user, 'passwordThatWentThroughOldCredentialPackageHashing');
120
- await apos.user.safe.remove({ username: 'olduser' });
120
+
121
+ // verifyPassword now upgrades legacy hashes on next use, e.g.
122
+ // the next time it is possible because the password is known
123
+ const newHash = JSON.parse((await apos.user.safe.findOne({
124
+ username: 'olduser'
125
+ })).passwordHash);
126
+ assert.strictEqual(newHash.hashMethod, 'scrypt');
127
+ // Confirm the modernized end result is still verifiable with the old password
128
+ await apos.user.verifyPassword(user, 'passwordThatWentThroughOldCredentialPackageHashing');
129
+ try {
130
+ // ... And not with a bogus one
131
+ await apos.user.verifyPassword(user, 'bogus');
132
+ assert(false);
133
+ } catch (e) {
134
+ // Good
135
+ }
136
+ await apos.user.safe.removeOne({ username: 'olduser' });
121
137
  });
122
138
 
123
139
  it('should not be able to insert a new user if their email already exists', async function() {