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 +46 -0
- package/defaults.js +1 -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/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/multisite-i18n/i18n/aposMultisite/en.json +48 -0
- package/modules/@apostrophecms/multisite-i18n/index.js +7 -0
- 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/AposInputWrapper.vue +1 -1
- 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,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
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
|
};
|
|
@@ -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%);
|
|
@@ -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
|
+
}
|
|
@@ -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.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": "^
|
|
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() {
|