apostrophe 4.1.1 → 4.2.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 +34 -1
- package/lib/mongodb-connect.js +1 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +1 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +22 -16
- 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/index.js +19 -31
- package/modules/@apostrophecms/doc/lib/migrations.js +59 -0
- package/modules/@apostrophecms/doc-type/index.js +5 -2
- package/modules/@apostrophecms/express/index.js +3 -2
- package/modules/@apostrophecms/i18n/i18n/de.json +5 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +5 -1
- package/modules/@apostrophecms/i18n/i18n/es.json +5 -1
- package/modules/@apostrophecms/i18n/i18n/fr.json +5 -1
- package/modules/@apostrophecms/i18n/i18n/it.json +5 -1
- package/modules/@apostrophecms/i18n/i18n/pt-BR.json +5 -1
- package/modules/@apostrophecms/i18n/i18n/sk.json +5 -1
- package/modules/@apostrophecms/i18n/index.js +1 -1
- package/modules/@apostrophecms/migration/index.js +5 -0
- package/modules/@apostrophecms/modal/ui/apos/apps/AposModals.js +2 -2
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +188 -187
- package/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js +2 -2
- package/modules/@apostrophecms/notification/index.js +2 -0
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +2 -2
- package/modules/@apostrophecms/rich-text-widget/index.js +19 -8
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +44 -15
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapMarks.vue +226 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapStyles.vue +90 -18
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Classes.js +70 -6
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Default.js +2 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Document.js +1 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Heading.js +1 -1
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputColor.vue +1 -1
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js +4 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js +3 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposButtonSplit.vue +2 -2
- package/modules/@apostrophecms/ui/ui/apos/lib/i18next.js +4 -8
- package/modules/@apostrophecms/user/index.js +40 -14
- package/modules/@apostrophecms/user/lib/password-hash.js +122 -0
- package/modules/@apostrophecms/util/ui/src/http.js +7 -0
- package/package.json +3 -5
- package/test/docs.js +151 -0
- package/test/password-hash.js +56 -0
- package/test/users.js +19 -3
- package/.github/workflows/outdated-dependencies.yml +0 -43
- package/modules/@apostrophecms/modal/ui/apos/mixins/AposFocusMixin.js +0 -91
|
@@ -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
|
+
}
|
|
@@ -99,6 +99,9 @@ export default () => {
|
|
|
99
99
|
throw new Error('If you wish to receive a promise from apos.http methods in older browsers you must have a Promise polyfill. If you do not want to provide one, pass a callback instead.');
|
|
100
100
|
}
|
|
101
101
|
return new window.Promise(function(resolve, reject) {
|
|
102
|
+
if (!url) {
|
|
103
|
+
return reject(new Error('url is not defined'));
|
|
104
|
+
}
|
|
102
105
|
return apos.http.remote(method, url, options, function(err, result) {
|
|
103
106
|
if (err) {
|
|
104
107
|
return reject(err);
|
|
@@ -108,6 +111,10 @@ export default () => {
|
|
|
108
111
|
});
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
if (!url) {
|
|
115
|
+
return callback(new Error('url is not defined'));
|
|
116
|
+
}
|
|
117
|
+
|
|
111
118
|
if (apos.prefix && options.prefix !== false) {
|
|
112
119
|
// Prepend the prefix if the URL is absolute:
|
|
113
120
|
if (url.substring(0, 1) === '/') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apostrophe",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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-material-design-icons": "^1.0.0",
|
|
35
36
|
"@ckpack/vue-color": "^1.4.1",
|
|
36
37
|
"@floating-ui/dom": "^1.5.3",
|
|
@@ -51,7 +52,6 @@
|
|
|
51
52
|
"@tiptap/extension-underline": "^2.0.3",
|
|
52
53
|
"@tiptap/starter-kit": "^2.0.3",
|
|
53
54
|
"@tiptap/vue-3": "^2.0.3",
|
|
54
|
-
"@vue/compat": "^3.3.8",
|
|
55
55
|
"@vue/compiler-sfc": "^3.3.8",
|
|
56
56
|
"autoprefixer": "^10.4.1",
|
|
57
57
|
"bluebird": "^3.7.2",
|
|
@@ -61,11 +61,10 @@
|
|
|
61
61
|
"cheerio": "^1.0.0-rc.10",
|
|
62
62
|
"chokidar": "^3.5.2",
|
|
63
63
|
"common-tags": "^1.8.0",
|
|
64
|
-
"connect-mongo": "^
|
|
64
|
+
"connect-mongo": "^5.1.0",
|
|
65
65
|
"connect-multiparty": "^2.1.1",
|
|
66
66
|
"cookie-parser": "^1.4.5",
|
|
67
67
|
"cors": "^2.8.5",
|
|
68
|
-
"credentials": "^3.0.2",
|
|
69
68
|
"css-loader": "^5.2.4",
|
|
70
69
|
"cuid": "^2.1.8",
|
|
71
70
|
"dayjs": "^1.9.8",
|
|
@@ -91,7 +90,6 @@
|
|
|
91
90
|
"mini-css-extract-plugin": "^1.6.0",
|
|
92
91
|
"minimatch": "^3.0.4",
|
|
93
92
|
"mkdirp": "^0.5.5",
|
|
94
|
-
"mongodb": "^3.6.6",
|
|
95
93
|
"node-fetch": "^2.6.1",
|
|
96
94
|
"nodemailer": "^6.6.1",
|
|
97
95
|
"nunjucks": "^3.2.1",
|
package/test/docs.js
CHANGED
|
@@ -1047,6 +1047,157 @@ describe('Docs', function() {
|
|
|
1047
1047
|
assert(response.cacheInvalidatedAt.getTime() === response.updatedAt.getTime());
|
|
1048
1048
|
assert(draft.cacheInvalidatedAt.getTime() === draft.updatedAt.getTime());
|
|
1049
1049
|
});
|
|
1050
|
+
|
|
1051
|
+
describe('beforeInsert handler', function() {
|
|
1052
|
+
it('should rely on req.mode when inserting a doc without _id', async function() {
|
|
1053
|
+
const req = apos.task.getReq();
|
|
1054
|
+
const draftReq = apos.task.getReq({ mode: 'draft' });
|
|
1055
|
+
const people = apos.modules['test-people'];
|
|
1056
|
+
const instance = people.newInstance();
|
|
1057
|
+
|
|
1058
|
+
const piece1 = {
|
|
1059
|
+
...instance,
|
|
1060
|
+
title: 'piece 1'
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const piece2 = {
|
|
1064
|
+
...instance,
|
|
1065
|
+
title: 'piece 2'
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const pieceDraft = await people.insert(draftReq, piece1);
|
|
1069
|
+
const piecePublished = await people.insert(req, piece2);
|
|
1070
|
+
|
|
1071
|
+
const actual = {
|
|
1072
|
+
draft: {
|
|
1073
|
+
idMode: pieceDraft._id.split(':').pop(),
|
|
1074
|
+
aposLocale: pieceDraft.aposLocale,
|
|
1075
|
+
aposMode: pieceDraft.aposMode
|
|
1076
|
+
},
|
|
1077
|
+
published: {
|
|
1078
|
+
idMode: piecePublished._id.split(':').pop(),
|
|
1079
|
+
aposLocale: piecePublished.aposLocale,
|
|
1080
|
+
aposMode: piecePublished.aposMode
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const expected = {
|
|
1085
|
+
draft: {
|
|
1086
|
+
idMode: 'draft',
|
|
1087
|
+
aposLocale: 'en:draft',
|
|
1088
|
+
aposMode: 'draft'
|
|
1089
|
+
},
|
|
1090
|
+
published: {
|
|
1091
|
+
idMode: 'published',
|
|
1092
|
+
aposLocale: 'en:published',
|
|
1093
|
+
aposMode: 'published'
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
assert.deepEqual(actual, expected);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('should rely on _id when present for aposMode and aposLocale even if req.mode does not match', async function() {
|
|
1101
|
+
const req = apos.task.getReq();
|
|
1102
|
+
const draftReq = apos.task.getReq({ mode: 'draft' });
|
|
1103
|
+
const people = apos.modules['test-people'];
|
|
1104
|
+
const instance = people.newInstance();
|
|
1105
|
+
|
|
1106
|
+
const piece1 = {
|
|
1107
|
+
_id: 'testid:en:draft',
|
|
1108
|
+
...instance,
|
|
1109
|
+
title: 'piece 1'
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
const piece2 = {
|
|
1113
|
+
_id: 'testid:en:published',
|
|
1114
|
+
...instance,
|
|
1115
|
+
title: 'piece 2'
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
const pieceDraft = await people.insert(req, piece1);
|
|
1119
|
+
const piecePublished = await people.insert(draftReq, piece2);
|
|
1120
|
+
|
|
1121
|
+
const actual = {
|
|
1122
|
+
draft: {
|
|
1123
|
+
idMode: pieceDraft._id.split(':').pop(),
|
|
1124
|
+
aposLocale: pieceDraft.aposLocale,
|
|
1125
|
+
aposMode: pieceDraft.aposMode
|
|
1126
|
+
},
|
|
1127
|
+
published: {
|
|
1128
|
+
idMode: piecePublished._id.split(':').pop(),
|
|
1129
|
+
aposLocale: piecePublished.aposLocale,
|
|
1130
|
+
aposMode: piecePublished.aposMode
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const expected = {
|
|
1135
|
+
draft: {
|
|
1136
|
+
idMode: 'draft',
|
|
1137
|
+
aposLocale: 'en:draft',
|
|
1138
|
+
aposMode: 'draft'
|
|
1139
|
+
},
|
|
1140
|
+
published: {
|
|
1141
|
+
idMode: 'published',
|
|
1142
|
+
aposLocale: 'en:published',
|
|
1143
|
+
aposMode: 'published'
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
assert.deepEqual(actual, expected);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it('should rely on aposMode when present for _id and aposLocale even if req.mode does not match', async function() {
|
|
1151
|
+
const req = apos.task.getReq();
|
|
1152
|
+
const draftReq = apos.task.getReq({ mode: 'draft' });
|
|
1153
|
+
const people = apos.modules['test-people'];
|
|
1154
|
+
const instance = people.newInstance();
|
|
1155
|
+
|
|
1156
|
+
const piece1 = {
|
|
1157
|
+
...instance,
|
|
1158
|
+
title: 'piece 1',
|
|
1159
|
+
aposLocale: 'en:draft'
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
const piece2 = {
|
|
1163
|
+
...instance,
|
|
1164
|
+
title: 'piece 2',
|
|
1165
|
+
aposLocale: 'en:published'
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
const pieceDraft = await people.insert(req, piece1);
|
|
1169
|
+
const piecePublished = await people.insert(draftReq, piece2);
|
|
1170
|
+
|
|
1171
|
+
const actual = {
|
|
1172
|
+
draft: {
|
|
1173
|
+
idMode: pieceDraft._id.split(':').pop(),
|
|
1174
|
+
aposLocale: pieceDraft.aposLocale,
|
|
1175
|
+
aposMode: pieceDraft.aposMode
|
|
1176
|
+
},
|
|
1177
|
+
published: {
|
|
1178
|
+
idMode: piecePublished._id.split(':').pop(),
|
|
1179
|
+
aposLocale: piecePublished.aposLocale,
|
|
1180
|
+
aposMode: piecePublished.aposMode
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const expected = {
|
|
1185
|
+
draft: {
|
|
1186
|
+
idMode: 'draft',
|
|
1187
|
+
aposLocale: 'en:draft',
|
|
1188
|
+
aposMode: 'draft'
|
|
1189
|
+
},
|
|
1190
|
+
published: {
|
|
1191
|
+
idMode: 'published',
|
|
1192
|
+
aposLocale: 'en:published',
|
|
1193
|
+
aposMode: 'published'
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
assert.deepEqual(actual, expected);
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1050
1201
|
});
|
|
1051
1202
|
|
|
1052
1203
|
async function insertPeople(apos) {
|
|
@@ -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() {
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
name: Check outdated dependencies
|
|
2
|
-
on:
|
|
3
|
-
schedule:
|
|
4
|
-
# Runs every Monday at 8:00
|
|
5
|
-
- cron: "0 8 * * MON"
|
|
6
|
-
# Allows you to run this workflow manually from the Actions tab
|
|
7
|
-
workflow_dispatch:
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
check_outdated_dependencies:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
steps:
|
|
13
|
-
- name: Git checkout
|
|
14
|
-
uses: actions/checkout@v2
|
|
15
|
-
- name: Use Node.js 18
|
|
16
|
-
uses: actions/setup-node@v1
|
|
17
|
-
with:
|
|
18
|
-
node-version: 18
|
|
19
|
-
- name: Install dependencies
|
|
20
|
-
run: npm install
|
|
21
|
-
- name: Check outdated dependencies
|
|
22
|
-
run: |
|
|
23
|
-
echo "$(npm outdated)" > output
|
|
24
|
-
npm outdated
|
|
25
|
-
- name: Report Status
|
|
26
|
-
if: failure()
|
|
27
|
-
run: |
|
|
28
|
-
outdated_dependencies=$(cat output)
|
|
29
|
-
|
|
30
|
-
repo="${{ github.repository }}"
|
|
31
|
-
repo_url="${{ github.server_url }}/$repo"
|
|
32
|
-
run_url="$repo_url/actions/runs/${{ github.run_id }}"
|
|
33
|
-
|
|
34
|
-
text="ℹ️ The <$repo_url|$repo> project has outdated dependencies:
|
|
35
|
-
\`\`\`
|
|
36
|
-
$outdated_dependencies
|
|
37
|
-
\`\`\`
|
|
38
|
-
<$run_url|View Run>"
|
|
39
|
-
|
|
40
|
-
payload="{\"text\": \"$text\"}"
|
|
41
|
-
curl -X POST -H 'Content-type: application/json' --data "$payload" $SLACK_WEBHOOK_URL
|
|
42
|
-
env:
|
|
43
|
-
SLACK_WEBHOOK_URL: ${{ secrets.ACTION_MONITORING_SLACK }}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Provides:
|
|
3
|
-
*
|
|
4
|
-
* Methods to handle focus with keyboard.
|
|
5
|
-
*/
|
|
6
|
-
export default {
|
|
7
|
-
data() {
|
|
8
|
-
return {
|
|
9
|
-
elementsToFocus: [],
|
|
10
|
-
|
|
11
|
-
// specific to modals:
|
|
12
|
-
focusedElement: null
|
|
13
|
-
};
|
|
14
|
-
},
|
|
15
|
-
methods: {
|
|
16
|
-
// Adapted from https://uxdesign.cc/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700
|
|
17
|
-
// All the elements inside modal which you want to make focusable.
|
|
18
|
-
//
|
|
19
|
-
// This has been adapted to Vue logic with `this.elementsToFocus` array as a data
|
|
20
|
-
// so that any elements, not only from a modal but a menu for instance, can be focusable.
|
|
21
|
-
// `cycleElementsToFocus` listeners relies on this dynamic list which has the advantage of
|
|
22
|
-
// taking new or less elements to focus, after an update has happened inside a modal,
|
|
23
|
-
// like an XHR call to get the pieces list in the AposDocsManager modal, for instance.
|
|
24
|
-
cycleElementsToFocus(e) {
|
|
25
|
-
if (!this.elementsToFocus.length) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const isTabPressed = e.key === 'Tab' || e.code === 'Tab';
|
|
30
|
-
if (!isTabPressed) {
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const firstElementToFocus = this.elementsToFocus.at(0);
|
|
35
|
-
const lastElementToFocus = this.elementsToFocus.at(-1);
|
|
36
|
-
|
|
37
|
-
// If shift key pressed for shift + tab combination
|
|
38
|
-
if (e.shiftKey) {
|
|
39
|
-
if (document.activeElement === firstElementToFocus) {
|
|
40
|
-
// Add focus for the last focusable element
|
|
41
|
-
lastElementToFocus.focus();
|
|
42
|
-
e.preventDefault();
|
|
43
|
-
}
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// If tab key is pressed
|
|
48
|
-
if (document.activeElement === lastElementToFocus) {
|
|
49
|
-
// Add focus for the first focusable element
|
|
50
|
-
firstElementToFocus.focus();
|
|
51
|
-
e.preventDefault();
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
// Focus the last focused element from the last modal.
|
|
55
|
-
// If it is not focusable (not visible/not in the DOM),
|
|
56
|
-
// fallbacks to the first focusable element from the last modal.
|
|
57
|
-
focusLastModalFocusedElement() {
|
|
58
|
-
const lastModal = apos.modal.stack.at(-1);
|
|
59
|
-
|
|
60
|
-
if (!lastModal) {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const { focusedElement, elementsToFocus } = lastModal;
|
|
65
|
-
|
|
66
|
-
this.focusElement(focusedElement, elementsToFocus[0]);
|
|
67
|
-
},
|
|
68
|
-
storeFocusedElement(e) {
|
|
69
|
-
this.focusedElement = e.target;
|
|
70
|
-
},
|
|
71
|
-
// Iterate through elements given in arguments and
|
|
72
|
-
// focus the first element that exists in the DOM.
|
|
73
|
-
focusElement(...elementsToFocus) {
|
|
74
|
-
for (const element of elementsToFocus) {
|
|
75
|
-
const isAlreadySelected = document.activeElement === element;
|
|
76
|
-
|
|
77
|
-
if (!element || !this.isElementVisible(element)) {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (!isAlreadySelected) {
|
|
81
|
-
element.focus();
|
|
82
|
-
}
|
|
83
|
-
// Element exists in the DOM and is focused, stop iterating.
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
isElementVisible(element) {
|
|
88
|
-
return element.offsetParent !== null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
};
|