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 +41 -0
- package/lib/mongodb-connect.js +1 -1
- package/modules/@apostrophecms/area/index.js +1 -1
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +6 -1
- package/modules/@apostrophecms/asset/index.js +22 -13
- package/modules/@apostrophecms/db/index.js +0 -6
- package/modules/@apostrophecms/doc-type/index.js +8 -2
- package/modules/@apostrophecms/express/index.js +3 -2
- package/modules/@apostrophecms/migration/index.js +5 -0
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +7 -1
- package/modules/@apostrophecms/notification/index.js +4 -4
- package/modules/@apostrophecms/rich-text-widget/index.js +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +44 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js +26 -0
- package/modules/@apostrophecms/user/index.js +40 -14
- package/modules/@apostrophecms/user/lib/password-hash.js +122 -0
- package/modules/@apostrophecms/widget-type/index.js +8 -2
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +4 -6
- package/package.json +3 -4
- package/test/password-hash.js +56 -0
- package/test/users.js +19 -3
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
|
package/lib/mongodb-connect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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: [ '
|
|
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
|
|
2686
|
-
const count = await
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
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
|
-
//
|
|
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[
|
|
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
|
-
|
|
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
|
|
504
|
+
// Initialize password hashing system. Name is for
|
|
505
|
+
// legacy reasons
|
|
506
|
+
|
|
481
507
|
initializeCredential() {
|
|
482
|
-
self.pw =
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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.
|
|
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": "^
|
|
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.
|
|
118
|
+
await apos.user.safe.updateOne({ username: 'olduser' }, { $set: { passwordHash: oldPasswordHashSimulated } });
|
|
119
119
|
await apos.user.verifyPassword(user, 'passwordThatWentThroughOldCredentialPackageHashing');
|
|
120
|
-
|
|
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() {
|