apostrophe 3.10.0 → 3.13.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 +53 -0
- package/index.js +37 -1
- package/lib/moog.js +2 -2
- package/modules/@apostrophecms/asset/lib/globalIcons.js +2 -0
- package/modules/@apostrophecms/attachment/index.js +1 -1
- package/modules/@apostrophecms/doc/index.js +2 -2
- package/modules/@apostrophecms/doc-type/index.js +2 -2
- package/modules/@apostrophecms/express/index.js +7 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +1 -0
- package/modules/@apostrophecms/i18n/i18n/sk.json +23 -1
- package/modules/@apostrophecms/i18n/index.js +62 -13
- package/modules/@apostrophecms/login/index.js +295 -52
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +245 -76
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +108 -0
- package/modules/@apostrophecms/module/index.js +12 -2
- package/modules/@apostrophecms/page/index.js +7 -8
- package/modules/@apostrophecms/permission/index.js +1 -1
- package/modules/@apostrophecms/piece-type/index.js +1 -1
- package/modules/@apostrophecms/schema/index.js +18 -2
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +12 -5
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputCheckboxes.vue +4 -0
- package/modules/@apostrophecms/ui/ui/apos/lib/i18next.js +18 -0
- package/modules/@apostrophecms/util/index.js +3 -9
- package/package.json +9 -9
- package/test/login-requirements.js +328 -0
- package/test/modules/base-type/i18n/custom/en.json +4 -0
- package/test/modules/base-type/i18n/en.json +3 -0
- package/test/modules/nested-module-subdirs/example1/index.js +5 -0
- package/test/modules/nested-module-subdirs/modules.js +7 -0
- package/test/modules/subtype/i18n/custom/en.json +4 -0
- package/test/modules/subtype/index.js +7 -0
- package/test/pages-rest.js +39 -0
- package/test/static-i18n.js +28 -0
- package/test/with-nested-module-subdirs.js +32 -0
- package/test/without-nested-module-subdirs.js +31 -0
- package/test-lib/util.js +4 -2
- package/.github/pull_request_template.md +0 -8
|
@@ -37,13 +37,6 @@
|
|
|
37
37
|
//
|
|
38
38
|
// Apostrophe's instance of the [passport](https://npmjs.org/package/passport) npm module.
|
|
39
39
|
// You may access this object if you need to implement additional passport "strategies."
|
|
40
|
-
//
|
|
41
|
-
// ## callAll method: loginAfterLogin
|
|
42
|
-
//
|
|
43
|
-
// The method `loginAfterLogin` is invoked on **all modules that have one**. This method
|
|
44
|
-
// is a good place to set `req.redirect` to the URL of your choice. If no module sets
|
|
45
|
-
// `req.redirect`, the newly logged-in user is redirected to the home page. `loginAfterLogin`
|
|
46
|
-
// is invoked with `req` and may be an async function.
|
|
47
40
|
|
|
48
41
|
const Passport = require('passport').Passport;
|
|
49
42
|
const LocalStrategy = require('passport-local');
|
|
@@ -51,6 +44,7 @@ const Promise = require('bluebird');
|
|
|
51
44
|
const cuid = require('cuid');
|
|
52
45
|
|
|
53
46
|
module.exports = {
|
|
47
|
+
cascades: [ 'requirements' ],
|
|
54
48
|
options: {
|
|
55
49
|
alias: 'login',
|
|
56
50
|
localLogin: true,
|
|
@@ -113,34 +107,13 @@ module.exports = {
|
|
|
113
107
|
return {
|
|
114
108
|
post: {
|
|
115
109
|
async login(req) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
const user = await self.apos.login.verifyLogin(username, password);
|
|
123
|
-
if (!user) {
|
|
124
|
-
// For security reasons we may not tell the user which case applies
|
|
125
|
-
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
|
|
126
|
-
}
|
|
127
|
-
if (session) {
|
|
128
|
-
const passportLogin = (user) => {
|
|
129
|
-
return require('util').promisify(function(user, callback) {
|
|
130
|
-
return req.login(user, callback);
|
|
131
|
-
})(user);
|
|
132
|
-
};
|
|
133
|
-
await passportLogin(user);
|
|
110
|
+
// Don't make verify functions worry about whether this object
|
|
111
|
+
// is present, just the value of their own sub-property
|
|
112
|
+
req.body.requirements = req.body.requirements || {};
|
|
113
|
+
if (req.body.incompleteToken) {
|
|
114
|
+
return self.finalizeIncompleteLogin(req);
|
|
134
115
|
} else {
|
|
135
|
-
|
|
136
|
-
await self.bearerTokens.insert({
|
|
137
|
-
_id: token,
|
|
138
|
-
userId: user._id,
|
|
139
|
-
expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
|
|
140
|
-
});
|
|
141
|
-
return {
|
|
142
|
-
token
|
|
143
|
-
};
|
|
116
|
+
return self.initialLogin(req);
|
|
144
117
|
}
|
|
145
118
|
},
|
|
146
119
|
async logout(req) {
|
|
@@ -148,7 +121,7 @@ module.exports = {
|
|
|
148
121
|
throw self.apos.error('forbidden', req.t('apostrophe:logOutNotLoggedIn'));
|
|
149
122
|
}
|
|
150
123
|
if (req.token) {
|
|
151
|
-
await self.bearerTokens.
|
|
124
|
+
await self.bearerTokens.removeOne({
|
|
152
125
|
userId: req.user._id,
|
|
153
126
|
_id: req.token
|
|
154
127
|
});
|
|
@@ -163,6 +136,68 @@ module.exports = {
|
|
|
163
136
|
await destroySession();
|
|
164
137
|
}
|
|
165
138
|
},
|
|
139
|
+
// invokes the `props(req, user)` function for the requirement specified by
|
|
140
|
+
// `body.name`. Invoked before displaying each `afterPasswordVerified`
|
|
141
|
+
// requirement. The return value of the function, which should
|
|
142
|
+
// be an object, is delivered as the API response
|
|
143
|
+
async requirementProps(req) {
|
|
144
|
+
const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
|
|
145
|
+
|
|
146
|
+
const name = self.apos.launder.string(req.body.name);
|
|
147
|
+
|
|
148
|
+
const requirement = self.requirements[name];
|
|
149
|
+
if (!requirement) {
|
|
150
|
+
throw self.apos.error('notfound');
|
|
151
|
+
}
|
|
152
|
+
if (!requirement.props) {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
return requirement.props(req, user);
|
|
156
|
+
},
|
|
157
|
+
async requirementVerify(req) {
|
|
158
|
+
const name = self.apos.launder.string(req.body.name);
|
|
159
|
+
|
|
160
|
+
const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
|
|
161
|
+
|
|
162
|
+
const requirement = self.requirements[name];
|
|
163
|
+
|
|
164
|
+
if (!requirement) {
|
|
165
|
+
throw self.apos.error('notfound');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!requirement.verify) {
|
|
169
|
+
throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await requirement.verify(req, req.body.value, user);
|
|
174
|
+
|
|
175
|
+
const token = await self.bearerTokens.findOne({
|
|
176
|
+
_id: self.apos.launder.string(req.body.incompleteToken),
|
|
177
|
+
requirementsToVerify: { $exists: true },
|
|
178
|
+
expires: {
|
|
179
|
+
$gte: new Date()
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!token) {
|
|
184
|
+
throw self.apos.error('notfound');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await self.bearerTokens.updateOne(token, {
|
|
188
|
+
$pull: { requirementsToVerify: name }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return {};
|
|
192
|
+
} catch (err) {
|
|
193
|
+
err.data = err.data || {};
|
|
194
|
+
err.data.requirement = name;
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async context(req) {
|
|
199
|
+
return self.getContext(req);
|
|
200
|
+
},
|
|
166
201
|
...(self.options.passwordReset ? {
|
|
167
202
|
async resetRequest(req) {
|
|
168
203
|
const site = (req.headers.host || '').replace(/:\d+$/, '');
|
|
@@ -224,19 +259,12 @@ module.exports = {
|
|
|
224
259
|
} : {})
|
|
225
260
|
},
|
|
226
261
|
get: {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
env: process.env.NODE_ENV || 'development',
|
|
237
|
-
name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
|
|
238
|
-
version: aposPackage.version || '3'
|
|
239
|
-
};
|
|
262
|
+
// For bc this route is still available via GET, however
|
|
263
|
+
// it should be accessed via POST because the result
|
|
264
|
+
// may differ by individual user session and should not
|
|
265
|
+
// be cached
|
|
266
|
+
async context(req) {
|
|
267
|
+
return self.getContext(req);
|
|
240
268
|
}
|
|
241
269
|
}
|
|
242
270
|
};
|
|
@@ -244,6 +272,33 @@ module.exports = {
|
|
|
244
272
|
methods(self) {
|
|
245
273
|
return {
|
|
246
274
|
|
|
275
|
+
// Implements the context route, which provides basic
|
|
276
|
+
// information about the site being logged into and also
|
|
277
|
+
// props for beforeSubmit requirements
|
|
278
|
+
async getContext(req) {
|
|
279
|
+
const aposPackage = require('../../../package.json');
|
|
280
|
+
// For performance beforeSubmit requirement props all happen together here
|
|
281
|
+
const requirementProps = {};
|
|
282
|
+
for (const [ name, requirement ] of Object.entries(self.requirements)) {
|
|
283
|
+
if ((requirement.phase !== 'afterPasswordVerified') && requirement.props) {
|
|
284
|
+
try {
|
|
285
|
+
requirementProps[name] = await requirement.props(req);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (e.body && e.body.data) {
|
|
288
|
+
e.body.data.requirement = name;
|
|
289
|
+
}
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
env: process.env.NODE_ENV || 'development',
|
|
296
|
+
name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
|
|
297
|
+
version: aposPackage.version || '3',
|
|
298
|
+
requirementProps
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
|
|
247
302
|
// return the loginUrl option
|
|
248
303
|
login(url) {
|
|
249
304
|
return self.options.loginUrl ? self.options.loginUrl : '/login';
|
|
@@ -375,7 +430,17 @@ module.exports = {
|
|
|
375
430
|
username: req.user.username,
|
|
376
431
|
email: req.user.email
|
|
377
432
|
}
|
|
378
|
-
} : {})
|
|
433
|
+
} : {}),
|
|
434
|
+
requirements: Object.fromEntries(
|
|
435
|
+
Object.entries(self.requirements).map(([ name, requirement ]) => {
|
|
436
|
+
const browserRequirement = {
|
|
437
|
+
phase: requirement.phase,
|
|
438
|
+
propsRequired: !!requirement.props,
|
|
439
|
+
askForConfirmation: requirement.askForConfirmation || false
|
|
440
|
+
};
|
|
441
|
+
return [ name, browserRequirement ];
|
|
442
|
+
})
|
|
443
|
+
)
|
|
379
444
|
};
|
|
380
445
|
},
|
|
381
446
|
|
|
@@ -391,6 +456,164 @@ module.exports = {
|
|
|
391
456
|
async enableBearerTokens() {
|
|
392
457
|
self.bearerTokens = self.apos.db.collection('aposBearerTokens');
|
|
393
458
|
await self.bearerTokens.createIndex({ expires: 1 }, { expireAfterSeconds: 0 });
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
// Finalize an incomplete login based on the provided incompleteToken
|
|
462
|
+
// and various `requirements` subproperties. Implementation detail of the login route
|
|
463
|
+
async finalizeIncompleteLogin(req) {
|
|
464
|
+
const session = self.apos.launder.boolean(req.body.session);
|
|
465
|
+
// Completing a previous incomplete login
|
|
466
|
+
// (password was verified but post-password-verification
|
|
467
|
+
// requirements were not supplied)
|
|
468
|
+
const token = await self.bearerTokens.findOne({
|
|
469
|
+
_id: self.apos.launder.string(req.body.incompleteToken),
|
|
470
|
+
requirementsToVerify: {
|
|
471
|
+
$exists: true
|
|
472
|
+
},
|
|
473
|
+
expires: {
|
|
474
|
+
$gte: new Date()
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (!token) {
|
|
479
|
+
throw self.apos.error('notfound');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (token.requirementsToVerify.length) {
|
|
483
|
+
throw self.apos.error('forbidden', 'All requirements must be verified');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const user = await self.deserializeUser(token.userId);
|
|
487
|
+
if (!user) {
|
|
488
|
+
await self.bearerTokens.removeOne({
|
|
489
|
+
_id: token.userId
|
|
490
|
+
});
|
|
491
|
+
throw self.apos.error('notfound');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (session) {
|
|
495
|
+
await self.bearerTokens.removeOne({
|
|
496
|
+
_id: token.userId
|
|
497
|
+
});
|
|
498
|
+
await self.passportLogin(req, user);
|
|
499
|
+
} else {
|
|
500
|
+
delete token.requirementsToVerify;
|
|
501
|
+
self.bearerTokens.updateOne(token, {
|
|
502
|
+
$unset: {
|
|
503
|
+
requirementsToVerify: 1
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
token
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
// Implementation detail of the login route and the requirementProps mechanism for
|
|
513
|
+
// custom login requirements. Given the string `token`, returns
|
|
514
|
+
// `{ token, user }`. Throws an exception if the token is not found.
|
|
515
|
+
// `token` is sanitized before passing to mongodb.
|
|
516
|
+
async findIncompleteTokenAndUser(req, token) {
|
|
517
|
+
token = await self.bearerTokens.findOne({
|
|
518
|
+
_id: self.apos.launder.string(token),
|
|
519
|
+
requirementsToVerify: {
|
|
520
|
+
$exists: true,
|
|
521
|
+
$ne: []
|
|
522
|
+
},
|
|
523
|
+
expires: {
|
|
524
|
+
$gte: new Date()
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
if (!token) {
|
|
528
|
+
throw self.apos.error('notfound');
|
|
529
|
+
}
|
|
530
|
+
const user = await self.deserializeUser(token.userId);
|
|
531
|
+
if (!user) {
|
|
532
|
+
await self.bearerTokens.removeOne({
|
|
533
|
+
_id: token._id
|
|
534
|
+
});
|
|
535
|
+
throw self.apos.error('notfound');
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
token,
|
|
539
|
+
user
|
|
540
|
+
};
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
// Implementation detail of the login route. Log in the user, or if there are
|
|
544
|
+
// `requirements` that require password verification occur first, return an incomplete token.
|
|
545
|
+
async initialLogin(req) {
|
|
546
|
+
// Initial login step
|
|
547
|
+
const username = self.apos.launder.string(req.body.username);
|
|
548
|
+
const password = self.apos.launder.string(req.body.password);
|
|
549
|
+
if (!(username && password)) {
|
|
550
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
|
|
551
|
+
}
|
|
552
|
+
const { earlyRequirements, lateRequirements } = self.filterRequirements();
|
|
553
|
+
for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
|
|
554
|
+
try {
|
|
555
|
+
await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
|
|
556
|
+
} catch (e) {
|
|
557
|
+
e.data = e.data || {};
|
|
558
|
+
e.data.requirement = name;
|
|
559
|
+
throw e;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const user = await self.apos.login.verifyLogin(username, password);
|
|
563
|
+
if (!user) {
|
|
564
|
+
// For security reasons we may not tell the user which case applies
|
|
565
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const requirementsToVerify = Object.keys(lateRequirements);
|
|
569
|
+
|
|
570
|
+
if (requirementsToVerify.length) {
|
|
571
|
+
const token = cuid();
|
|
572
|
+
|
|
573
|
+
await self.bearerTokens.insert({
|
|
574
|
+
_id: token,
|
|
575
|
+
userId: user._id,
|
|
576
|
+
requirementsToVerify,
|
|
577
|
+
// Default lifetime of 1 hour is generous to permit situations like
|
|
578
|
+
// installing a TOTP app for the first time
|
|
579
|
+
expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
|
|
580
|
+
});
|
|
581
|
+
return {
|
|
582
|
+
incompleteToken: token
|
|
583
|
+
};
|
|
584
|
+
} else {
|
|
585
|
+
const session = self.apos.launder.boolean(req.body.session);
|
|
586
|
+
if (session) {
|
|
587
|
+
await self.passportLogin(req, user);
|
|
588
|
+
} else {
|
|
589
|
+
const token = cuid();
|
|
590
|
+
await self.bearerTokens.insert({
|
|
591
|
+
_id: token,
|
|
592
|
+
userId: user._id,
|
|
593
|
+
expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
|
|
594
|
+
});
|
|
595
|
+
return {
|
|
596
|
+
token
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
filterRequirements() {
|
|
603
|
+
return {
|
|
604
|
+
earlyRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'beforeSubmit')),
|
|
605
|
+
lateRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'afterPasswordVerified'))
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
// Awaitable wrapper for req.login. An implementation detail of the login route
|
|
610
|
+
async passportLogin(req, user) {
|
|
611
|
+
const passportLogin = (user) => {
|
|
612
|
+
return require('util').promisify(function(user, callback) {
|
|
613
|
+
return req.login(user, callback);
|
|
614
|
+
})(user);
|
|
615
|
+
};
|
|
616
|
+
await passportLogin(user);
|
|
394
617
|
}
|
|
395
618
|
};
|
|
396
619
|
},
|
|
@@ -405,15 +628,35 @@ module.exports = {
|
|
|
405
628
|
before: '@apostrophecms/i18n',
|
|
406
629
|
middleware(req, res, next) {
|
|
407
630
|
const superLogin = req.login.bind(req);
|
|
408
|
-
req.login = (user,
|
|
409
|
-
|
|
631
|
+
req.login = (user, ...args) => {
|
|
632
|
+
let options, callback;
|
|
633
|
+
// Support inconsistent calling conventions inside passport core
|
|
634
|
+
if (typeof args[0] === 'function') {
|
|
635
|
+
options = {};
|
|
636
|
+
callback = args[0];
|
|
637
|
+
} else {
|
|
638
|
+
options = args[0];
|
|
639
|
+
callback = args[1];
|
|
640
|
+
}
|
|
641
|
+
return superLogin(user, options, async (err) => {
|
|
410
642
|
if (err) {
|
|
411
643
|
return callback(err);
|
|
412
644
|
}
|
|
413
|
-
|
|
645
|
+
await self.emit('afterSessionLogin', req);
|
|
646
|
+
// Make sure no handler removed req.user
|
|
647
|
+
if (req.user) {
|
|
648
|
+
// Mark the login timestamp. Middleware takes care of ensuring
|
|
649
|
+
// that logins cannot be used to carry out actions prior
|
|
650
|
+
// to this property being added
|
|
651
|
+
req.session.loginAt = Date.now();
|
|
652
|
+
}
|
|
414
653
|
return callback(null);
|
|
415
654
|
});
|
|
416
655
|
};
|
|
656
|
+
// Passport itself maintains this bc alias, while refusing
|
|
657
|
+
// to actually decide which one is best in its own dev docs.
|
|
658
|
+
// Both have to exist to avoid bugs when passport calls itself
|
|
659
|
+
req.logIn = req.login;
|
|
417
660
|
return next();
|
|
418
661
|
}
|
|
419
662
|
},
|