apostrophe 3.63.3 → 3.65.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.65.0 (2024-05-06)
4
+
5
+ ### Adds
6
+
7
+ * Adds a `publicBundle` option to `@apostrophecms/asset`. When set to `false`, the `ui/src` public asset bundle is not built at all in most cases
8
+ except as part of the admin UI bundle which depends on it. For use with external front ends such as [apostrophe-astro](https://github.com/apostrophecms/apostrophe-astro).
9
+ Thanks to Michelin for contributing this feature.
10
+
11
+ ## 3.64.0 (2024-04-18)
12
+
13
+ ### Adds
14
+
15
+ ### Fixes
16
+
17
+ * Add the missing `metaType` property to newly inserted widgets.
18
+
19
+ ### Security
20
+
21
+ * 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).
22
+ This reduces login time while improving overall security.
23
+ * Old passwords are automatically re-hashed with `scrypt` on the next successful login attempt, which
24
+ adds some delay to that next attempt, but speeds them up forever after compared to the old implementation.
25
+ * 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.
26
+
27
+ ### Changes
28
+
29
+ * `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."
30
+ * 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`.
31
+
3
32
  ## 3.63.3 (2024-03-14)
4
33
 
5
34
  ### Adds
@@ -13,6 +42,18 @@ that these keys would be present.
13
42
  * `field.help` and `field.htmlHelp` are now correctly translated when displayed in a tooltip.
14
43
  This was also an expectation for the multisite module.
15
44
 
45
+ ## UNRELEASED
46
+
47
+ ### Adds
48
+
49
+ * Add side by side comparison support in AposSchema component.
50
+ * 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).
51
+
52
+ ### Fixes
53
+
54
+ * Adds `textStyle` to Tiptap types so that spans are rendered on RT initialization
55
+ * Notification REST APIs should not directly return the result of MongoDB operations.
56
+
16
57
  ## 3.63.2 (2024-03-01)
17
58
 
18
59
  ### Security
@@ -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
  };
@@ -42,7 +42,10 @@ module.exports = {
42
42
  watchDebounceMs: 1000,
43
43
  // Object containing instructions for remapping existing bundles.
44
44
  // See the modulre reference documentation for more information.
45
- rebundleModules: undefined
45
+ rebundleModules: undefined,
46
+ // In case of external front end like Astro, this option allows to
47
+ // disable the build of the public UI assets.
48
+ publicBundle: true
46
49
  },
47
50
 
48
51
  async init(self) {
@@ -169,7 +172,9 @@ module.exports = {
169
172
  // to the same relative path `/public/apos-frontend/namespace/modules/modulename`.
170
173
  // Inherited files are also copied, with the deepest subclass overriding in the
171
174
  // event of a conflict
172
- await moduleOverrides(`${bundleDir}/modules`, 'public');
175
+ if (self.options.publicBundle) {
176
+ await moduleOverrides(`${bundleDir}/modules`, 'public');
177
+ }
173
178
 
174
179
  for (const [ name, options ] of Object.entries(self.builds)) {
175
180
  // If the option is not present always rebuild everything...
@@ -180,11 +185,12 @@ module.exports = {
180
185
  } else if (!rebuild) {
181
186
  let checkTimestamp = false;
182
187
 
183
- // Only builds contributing to the apos admin UI (currently just "apos")
188
+ // If options.publicBundle, only builds contributing to the apos admin UI (currently just "apos")
184
189
  // are candidates to skip the build simply because package-lock.json is
185
190
  // older than the bundle. All other builds frequently contain
186
191
  // project level code
187
- if (options.apos) {
192
+ // Else we can skip also for the src bundle
193
+ if (options.apos || !self.options.publicBundle) {
188
194
  const bundleExists = await fs.pathExists(bundleDir);
189
195
 
190
196
  if (!bundleExists) {
@@ -437,7 +443,7 @@ module.exports = {
437
443
  modulesPrefix: `${self.getAssetBaseUrl()}/modules`
438
444
  }));
439
445
  }
440
- if (options.apos) {
446
+ if (options.apos || !self.options.publicBundle) {
441
447
  const now = Date.now().toString();
442
448
  fs.writeFileSync(`${bundleDir}/${name}-build-timestamp.txt`, now);
443
449
  }
@@ -1300,7 +1306,7 @@ module.exports = {
1300
1306
  `;
1301
1307
  self.builds = {
1302
1308
  src: {
1303
- scenes: [ 'public', 'apos' ],
1309
+ scenes: [ 'apos' ],
1304
1310
  webpack: true,
1305
1311
  outputs: [ 'css', 'js' ],
1306
1312
  label: 'apostrophe:modernBuild',
@@ -1310,13 +1316,6 @@ module.exports = {
1310
1316
  condition: 'module',
1311
1317
  prologue: self.srcPrologue
1312
1318
  },
1313
- public: {
1314
- scenes: [ 'public', 'apos' ],
1315
- outputs: [ 'css', 'js' ],
1316
- label: 'apostrophe:rawCssAndJs',
1317
- // Just concatenates
1318
- webpack: false
1319
- },
1320
1319
  apos: {
1321
1320
  scenes: [ 'apos' ],
1322
1321
  outputs: [ 'js' ],
@@ -1337,6 +1336,16 @@ module.exports = {
1337
1336
  // We could add an apos-ie11 bundle that just pushes a "sorry charlie" prologue,
1338
1337
  // if we chose
1339
1338
  };
1339
+ if (self.options.publicBundle) {
1340
+ self.builds.public = {
1341
+ scenes: [ 'public', 'apos' ],
1342
+ outputs: [ 'css', 'js' ],
1343
+ label: 'apostrophe:rawCssAndJs',
1344
+ // Just concatenates
1345
+ webpack: false
1346
+ };
1347
+ self.builds.src.scenes.push('public');
1348
+ }
1340
1349
  },
1341
1350
  // Filter the given css performing any necessary transformations,
1342
1351
  // such as support for the /modules path regardless of where
@@ -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%);
@@ -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
@@ -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.3",
3
+ "version": "3.65.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() {