apostrophe 3.13.0 → 3.14.2-alpha.20220401
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 +29 -0
- package/index.js +9 -7
- package/modules/@apostrophecms/asset/index.js +24 -9
- package/modules/@apostrophecms/doc/index.js +11 -3
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +0 -7
- package/modules/@apostrophecms/express/index.js +43 -37
- package/modules/@apostrophecms/http/index.js +0 -20
- package/modules/@apostrophecms/i18n/i18n/en.json +2 -0
- package/modules/@apostrophecms/login/index.js +160 -33
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +23 -24
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +1 -1
- package/modules/@apostrophecms/migration/index.js +27 -32
- package/modules/@apostrophecms/page/index.js +134 -80
- package/modules/@apostrophecms/piece-page-type/index.js +1 -1
- package/modules/@apostrophecms/piece-type/index.js +85 -72
- package/modules/@apostrophecms/util/ui/src/http.js +1 -7
- package/package.json +3 -2
- package/test/express.js +2 -26
- package/test/http.js +0 -24
- package/test/login-requirements.js +76 -6
- package/test/login.js +42 -0
- package/test/parked-pages.js +284 -13
- package/test/pieces-page-type.js +63 -0
- package/test/package.json +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.14.2 (2022-02-27)
|
|
4
|
+
|
|
5
|
+
* Hotfix: fixed a bug introduced by 3.14.1 in which non-parked pages could throw an error during the migration to fix replication issues.
|
|
6
|
+
|
|
7
|
+
## 3.14.1 (2022-02-25)
|
|
8
|
+
|
|
9
|
+
* Hotfix: fixed a bug in which replication across locales did not work properly for parked pages configured via the `_children` feature. A one-time migration is included to reconnect improperly replicated versions of the same parked pages. This runs automatically, no manual action is required. Thanks to [justyna1](https://github.com/justyna13) for identifying the issue.
|
|
10
|
+
|
|
11
|
+
## 3.14.0 (2022-02-22)
|
|
12
|
+
|
|
13
|
+
### Adds
|
|
14
|
+
|
|
15
|
+
* To reduce complications for those implementing caching strategies, the CSRF protection cookie now contains a simple constant string, and is not recorded in `req.session`. This is acceptable because the real purpose of the CSRF check is simply to verify that the browser has sent the cookie at all, which it will not allow a cross-origin script to do.
|
|
16
|
+
* As a result of the above, a session cookie is not generated and sent at all unless `req.session` is actually used or a user logs in. Again, this reduces complications for those implementing caching strategies.
|
|
17
|
+
* When logging out, the session cookie is now cleared in the browser. Formerly the session was destroyed on the server side only, which was sufficient for security purposes but could create caching issues.
|
|
18
|
+
* Uses `express-cache-on-demand` lib to make similar and concurrent requests on pieces and pages faster.
|
|
19
|
+
* Frontend build errors now stop app startup in development, and SCSS and JS/Vue build warnings are visible on the terminal console for the first time.
|
|
20
|
+
* Adds throttle system based on username (even when not existing), on initial login route. Also added for each late requirement.
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
* Fixed a bug when editing a page more than once if the page has a relationship to itself, whether directly or indirectly. Widget ids were unnecessarily regenerated in this situation, causing in-context edits after the first to fail to save.
|
|
25
|
+
* Pages no longer emit double `beforeUpdate` and `beforeSave` events.
|
|
26
|
+
* When the home page extends `@apostrophecms/piece-page-type`, the "show page" URLs for individual pieces should not contain two slashes before the piece slug. Thanks to [Martí Bravo](https://github.com/martibravo) for the fix.
|
|
27
|
+
* Fixes transitions between login page and `afterPasswordVerified` login steps.
|
|
28
|
+
* Frontend build errors now stop the `@apostrophecms/asset:build` task properly in production.
|
|
29
|
+
* `start` replaced with `flex-start` to address SCSS warnings.
|
|
30
|
+
* Dead code removal, as a result of following up on JS/Vue build warnings.
|
|
31
|
+
|
|
3
32
|
## 3.13.0 - 2022-02-04
|
|
4
33
|
|
|
5
34
|
### Adds
|
package/index.js
CHANGED
|
@@ -202,13 +202,15 @@ module.exports = async function(options) {
|
|
|
202
202
|
await self.emit('modulesRegistered'); // formerly modulesReady
|
|
203
203
|
self.apos.schema.validateAllSchemas();
|
|
204
204
|
self.apos.schema.registerAllSchemas();
|
|
205
|
-
await self.apos.migration
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
await self.apos.lock.withLock('@apostrophecms/migration:migrate', async () => {
|
|
206
|
+
await self.apos.migration.migrate(); // emits before and after events, inside the lock
|
|
207
|
+
await self.apos.global.insertIfMissing();
|
|
208
|
+
await self.apos.page.implementParkAllInDefaultLocale();
|
|
209
|
+
await self.apos.doc.replicate(); // emits beforeReplicate and afterReplicate events
|
|
210
|
+
// Replicate will have created the parked pages across locales if needed, but we may
|
|
211
|
+
// still need to reset parked properties
|
|
212
|
+
await self.apos.page.implementParkAllInOtherLocales();
|
|
213
|
+
});
|
|
212
214
|
await self.emit('ready'); // formerly afterInit
|
|
213
215
|
if (self.taskRan) {
|
|
214
216
|
process.exit(0);
|
|
@@ -249,15 +249,23 @@ module.exports = {
|
|
|
249
249
|
fs.removeSync(`${bundleDir}/${outputFilename}`);
|
|
250
250
|
const cssPath = `${bundleDir}/${outputFilename}`.replace(/\.js$/, '.css');
|
|
251
251
|
fs.removeSync(cssPath);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
)
|
|
252
|
+
const webpack = Promise.promisify(webpackModule);
|
|
253
|
+
const webpackBaseConfig = require(`./lib/webpack/${name}/webpack.config`);
|
|
254
|
+
const webpackInstanceConfig = webpackBaseConfig({
|
|
255
|
+
importFile,
|
|
256
|
+
modulesDir,
|
|
257
|
+
outputPath: bundleDir,
|
|
258
|
+
outputFilename
|
|
259
|
+
}, self.apos);
|
|
260
|
+
const result = await webpack(webpackInstanceConfig);
|
|
261
|
+
if (result.compilation.errors.length) {
|
|
262
|
+
// Throwing a string is appropriate in a command line task
|
|
263
|
+
throw cleanErrors(result.toString('errors'));
|
|
264
|
+
} else if (result.compilation.warnings.length) {
|
|
265
|
+
self.apos.util.warn(result.toString('errors-warnings'));
|
|
266
|
+
} else if (process.env.APOS_WEBPACK_VERBOSE) {
|
|
267
|
+
self.apos.util.info(result.toString('verbose'));
|
|
268
|
+
}
|
|
261
269
|
if (fs.existsSync(cssPath)) {
|
|
262
270
|
fs.writeFileSync(cssPath, self.filterCss(fs.readFileSync(cssPath, 'utf8'), {
|
|
263
271
|
modulesPrefix: `${self.getAssetBaseUrl()}/modules`
|
|
@@ -518,6 +526,13 @@ module.exports = {
|
|
|
518
526
|
function getComponentName(component, options, i) {
|
|
519
527
|
return require('path').basename(component).replace(/\.\w+/, '') + (options.enumerateImports ? `_${i}` : '');
|
|
520
528
|
}
|
|
529
|
+
|
|
530
|
+
function cleanErrors(errors) {
|
|
531
|
+
// Dev experience: remove confusing and inaccurate webpack warning about module loaders
|
|
532
|
+
// when straightforward JS parse errors occur, stackoverflow is full of people
|
|
533
|
+
// confused by this
|
|
534
|
+
return errors.replace(/(ERROR in[\s\S]*?Module parse failed[\s\S]*)You may need an appropriate loader.*/, '$1');
|
|
535
|
+
}
|
|
521
536
|
}
|
|
522
537
|
}
|
|
523
538
|
};
|
|
@@ -1005,10 +1005,12 @@ module.exports = {
|
|
|
1005
1005
|
async replicate() {
|
|
1006
1006
|
const localeNames = Object.keys(self.apos.i18n.locales);
|
|
1007
1007
|
const criteria = [];
|
|
1008
|
-
|
|
1008
|
+
self.apos.page.parked.forEach(pushParkedPageAndParkedChildren);
|
|
1009
|
+
function pushParkedPageAndParkedChildren(page) {
|
|
1009
1010
|
criteria.push({
|
|
1010
|
-
parkedId:
|
|
1011
|
+
parkedId: page.parkedId
|
|
1011
1012
|
});
|
|
1013
|
+
(page._children || []).forEach(pushParkedPageAndParkedChildren);
|
|
1012
1014
|
}
|
|
1013
1015
|
const pieceModules = Object.values(self.apos.modules).filter(module => self.apos.instanceOf(module, '@apostrophecms/piece-type') && module.options.replicate);
|
|
1014
1016
|
for (const module of pieceModules) {
|
|
@@ -1075,7 +1077,13 @@ module.exports = {
|
|
|
1075
1077
|
},
|
|
1076
1078
|
deduplicateWidgetIds(doc) {
|
|
1077
1079
|
const seen = new Set();
|
|
1078
|
-
self.apos.area.walk(doc, area => {
|
|
1080
|
+
self.apos.area.walk(doc, (area, dotPath) => {
|
|
1081
|
+
if (dotPath.includes('_')) {
|
|
1082
|
+
// Ignore relationships so recursive references from a
|
|
1083
|
+
// doc to itself can't result in random regeneration of
|
|
1084
|
+
// widget ids in the doc proper
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1079
1087
|
for (const widget of area.items || []) {
|
|
1080
1088
|
if ((!widget._id) || seen.has(widget._id)) {
|
|
1081
1089
|
widget._id = cuid();
|
|
@@ -23,7 +23,6 @@ import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange'
|
|
|
23
23
|
import AposPublishMixin from 'Modules/@apostrophecms/ui/mixins/AposPublishMixin';
|
|
24
24
|
import AposArchiveMixin from 'Modules/@apostrophecms/ui/mixins/AposArchiveMixin';
|
|
25
25
|
import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin';
|
|
26
|
-
import klona from 'klona';
|
|
27
26
|
|
|
28
27
|
export default {
|
|
29
28
|
name: 'AposDocContextMenu',
|
|
@@ -263,12 +262,6 @@ export default {
|
|
|
263
262
|
// moduleOptions gives us the action, etc. but here we need the schema
|
|
264
263
|
// which is always type specific, even for pages so get it ourselves
|
|
265
264
|
let schema = (apos.modules[this.context.type].schema || []).filter(field => apos.schema.components.fields[field.type]);
|
|
266
|
-
if (this.restoreOnly) {
|
|
267
|
-
schema = klona(schema);
|
|
268
|
-
for (const field of schema) {
|
|
269
|
-
field.readOnly = true;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
265
|
// Archive UI is handled via action buttons
|
|
273
266
|
schema = schema.filter(field => field.name !== 'archived');
|
|
274
267
|
return schema;
|
|
@@ -77,7 +77,15 @@
|
|
|
77
77
|
// rolling: true,
|
|
78
78
|
// secret: 'you should have a secret',
|
|
79
79
|
// name: self.apos.shortName + '.sid',
|
|
80
|
-
// cookie: {
|
|
80
|
+
// cookie: {
|
|
81
|
+
// path: '/',
|
|
82
|
+
// httpOnly: true,
|
|
83
|
+
// secure: false,
|
|
84
|
+
// // using 'strict' will confuse users if you link to your site
|
|
85
|
+
// // with the expectation that the user is still logged in on arrival.
|
|
86
|
+
// // 'lax' still protects against CSRF attacks
|
|
87
|
+
// sameSite: 'lax'
|
|
88
|
+
// }
|
|
81
89
|
// }
|
|
82
90
|
// ```
|
|
83
91
|
//
|
|
@@ -98,17 +106,16 @@
|
|
|
98
106
|
//
|
|
99
107
|
// ### `csrf`
|
|
100
108
|
//
|
|
101
|
-
// By default, Apostrophe implements
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// other sites or iframes will not be able to read the cookie and
|
|
107
|
-
// send the header.
|
|
109
|
+
// By default, Apostrophe implements [CSRF protection](https://en.wikipedia.org/wiki/Cross-site_request_forgery)
|
|
110
|
+
// by setting a cookie with the value `csrf`, which all legitimate requests originating fromt he page will send
|
|
111
|
+
// back (see the [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)).
|
|
112
|
+
// All modern browsers will refuse to allow a CSRF attacker, such as a malicious `POST`-method `form` tag on a third
|
|
113
|
+
// party site pointing to an Apostrophe site, to send cookies to the Apostrophe site.
|
|
108
114
|
//
|
|
109
115
|
// All non-safe HTTP requests (not `GET`, `HEAD`, `OPTIONS` or `TRACE`)
|
|
110
|
-
// automatically receive this
|
|
111
|
-
// rejects requests in which the
|
|
116
|
+
// automatically receive this protection via the csrf middleware, which
|
|
117
|
+
// rejects requests in which the cookie is not present.
|
|
118
|
+
//
|
|
112
119
|
// If the request was made with a valid api key or bearer token it
|
|
113
120
|
// bypasses this check.
|
|
114
121
|
//
|
|
@@ -124,12 +131,6 @@
|
|
|
124
131
|
// You may need to use this feature when implementing POST form
|
|
125
132
|
// submissions that do not use AJAX and thus don't send the header.
|
|
126
133
|
//
|
|
127
|
-
// There is also a `minimumExceptions` option, which defaults
|
|
128
|
-
// to `[ /login ]`. The login form is the only non-AJAX form
|
|
129
|
-
// that ships with Apostrophe. XSRF protection for login forms
|
|
130
|
-
// is unnecessary because the password itself is unknown to the
|
|
131
|
-
// third party site; it effectively serves as an XSRF token.
|
|
132
|
-
//
|
|
133
134
|
// ### Adding your own middleware
|
|
134
135
|
//
|
|
135
136
|
// Use the `middleware` section in your module. That function should
|
|
@@ -323,12 +324,11 @@ module.exports = {
|
|
|
323
324
|
},
|
|
324
325
|
...((self.options.csrf === false) ? {} : {
|
|
325
326
|
// Angular-compatible CSRF protection middleware. On safe requests (GET, HEAD, OPTIONS, TRACE),
|
|
326
|
-
// set the
|
|
327
|
-
// make sure our jQuery `ajaxPrefilter` set the X-XSRF-TOKEN header to match the
|
|
328
|
-
// cookie.
|
|
327
|
+
// set the csrf cookie if missing.
|
|
329
328
|
//
|
|
330
|
-
// This works because
|
|
331
|
-
// be able to
|
|
329
|
+
// This works because requests not meeting the expectations of the same-origin policy
|
|
330
|
+
// won't be able to send cookies to the origin at all, even though the value is
|
|
331
|
+
// well-known.
|
|
332
332
|
csrf(req, res, next) {
|
|
333
333
|
if (req.csrfExempt) {
|
|
334
334
|
return next();
|
|
@@ -426,7 +426,11 @@ module.exports = {
|
|
|
426
426
|
_.defaults(sessionOptions.cookie, {
|
|
427
427
|
path: '/',
|
|
428
428
|
httpOnly: true,
|
|
429
|
-
secure: false
|
|
429
|
+
secure: false,
|
|
430
|
+
// Ensure that Safari follows the same policy as other modern browsers
|
|
431
|
+
// to prevent CSRF attacks. "lax" just means that navigation links
|
|
432
|
+
// leading to the site will receive the cookie, it is not insecure
|
|
433
|
+
sameSite: 'lax'
|
|
430
434
|
// maxAge is set for us by connect-mongo,
|
|
431
435
|
// and defaults to 2 weeks
|
|
432
436
|
});
|
|
@@ -506,28 +510,30 @@ module.exports = {
|
|
|
506
510
|
// that this URL should be subject to CSRF.
|
|
507
511
|
|
|
508
512
|
csrfWithoutExceptions(req, res, next) {
|
|
509
|
-
let token;
|
|
510
513
|
// OPTIONS request cannot set a cookie, so manipulating the session here
|
|
511
514
|
// is not helpful. Do not attempt to set XSRF-TOKEN for OPTIONS
|
|
512
515
|
if (req.method === 'OPTIONS') {
|
|
513
516
|
return next();
|
|
514
517
|
}
|
|
515
|
-
// Safe request establishes
|
|
518
|
+
// Safe request establishes CSRF cookie, whose purpose is only to check
|
|
519
|
+
// that the same-origin policy is followed, not to be unique and secure
|
|
520
|
+
// in itself
|
|
516
521
|
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'TRACE') {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
522
|
+
// Use the same standard for the session and CSRF cookies
|
|
523
|
+
res.cookie(self.apos.csrfCookieName, 'csrf', {
|
|
524
|
+
// Will inherit sameSite: 'lax', which is important for
|
|
525
|
+
// CSRF protection in Safari
|
|
526
|
+
...self.sessionOptions.cookie,
|
|
527
|
+
// 1 year (the limit). The value is known, we are relying
|
|
528
|
+
// on SameSite (modern browsers)
|
|
529
|
+
maxAge: 31536000
|
|
530
|
+
});
|
|
525
531
|
} else {
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
if (
|
|
532
|
+
// Check that the request arrived with the CSRF cookie.
|
|
533
|
+
// This isn't meant to be a unique code that no one could guess,
|
|
534
|
+
// but rather a check that the request from the same origin,
|
|
535
|
+
// as cross-origin requests cannot set cookies on our origin at all.
|
|
536
|
+
if (req.cookies[self.apos.csrfCookieName] !== 'csrf') {
|
|
531
537
|
res.statusCode = 403;
|
|
532
538
|
return res.send({
|
|
533
539
|
name: 'forbidden',
|
|
@@ -86,9 +86,6 @@ module.exports = {
|
|
|
86
86
|
// `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
|
|
87
87
|
// parsed as JSON only if the content-type is application/json)
|
|
88
88
|
// `headers` (an object containing header names and values)
|
|
89
|
-
// `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
|
|
90
|
-
// due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
|
|
91
|
-
// in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
|
|
92
89
|
// `fullResponse` (if true, return an object with `status`, `headers` and `body`
|
|
93
90
|
// properties, rather than returning the body directly; the individual `headers` are canonicalized
|
|
94
91
|
// to lowercase names. If a header appears multiple times an array is returned for it)
|
|
@@ -113,9 +110,6 @@ module.exports = {
|
|
|
113
110
|
// `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
|
|
114
111
|
// parsed as JSON only if the content-type is application/json)
|
|
115
112
|
// `headers` (an object containing header names and values)
|
|
116
|
-
// `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
|
|
117
|
-
// due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
|
|
118
|
-
// in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
|
|
119
113
|
// `fullResponse` (if true, return an object with `status`, `headers` and `body`
|
|
120
114
|
// properties, rather than returning the body directly; the individual `headers` are canonicalized
|
|
121
115
|
// to lowercase names. If a header appears multiple times an array is returned for it)
|
|
@@ -140,9 +134,6 @@ module.exports = {
|
|
|
140
134
|
// `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
|
|
141
135
|
// parsed as JSON only if the content-type is application/json)
|
|
142
136
|
// `headers` (an object containing header names and values)
|
|
143
|
-
// `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
|
|
144
|
-
// due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
|
|
145
|
-
// in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
|
|
146
137
|
// `fullResponse` (if true, return an object with `status`, `headers` and `body`
|
|
147
138
|
// properties, rather than returning the body directly; the individual `headers` are canonicalized
|
|
148
139
|
// to lowercase names. If a header appears multiple times an array is returned for it)
|
|
@@ -167,9 +158,6 @@ module.exports = {
|
|
|
167
158
|
// `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
|
|
168
159
|
// parsed as JSON only if the content-type is application/json)
|
|
169
160
|
// `headers` (an object containing header names and values)
|
|
170
|
-
// `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
|
|
171
|
-
// due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
|
|
172
|
-
// in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
|
|
173
161
|
// `fullResponse` (if true, return an object with `status`, `headers` and `body`
|
|
174
162
|
// properties, rather than returning the body directly; the individual `headers` are canonicalized
|
|
175
163
|
// to lowercase names. If a header appears multiple times an array is returned for it)
|
|
@@ -228,14 +216,6 @@ module.exports = {
|
|
|
228
216
|
options.headers = options.headers || {};
|
|
229
217
|
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
230
218
|
}
|
|
231
|
-
if ((options.csrf !== false) && options.jar) {
|
|
232
|
-
options.headers = options.headers || {};
|
|
233
|
-
const cookieName = ((typeof options.csrf) === 'string') ? options.csrf : self.apos.csrfCookieName;
|
|
234
|
-
const cookieValue = self.getCookie(options.jar, url, cookieName);
|
|
235
|
-
if (cookieValue != null) {
|
|
236
|
-
options.headers['x-xsrf-token'] = cookieValue;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
219
|
const res = await fetch(url, options);
|
|
240
220
|
let body;
|
|
241
221
|
if (options.jar) {
|
|
@@ -178,6 +178,8 @@
|
|
|
178
178
|
"manageDocType": "Manage {{ type }}",
|
|
179
179
|
"manageDraftSubmissions": "Manage Draft Submissions",
|
|
180
180
|
"managePages": "Manage Pages",
|
|
181
|
+
"loginMaxAttemptsReached": "Too many attempts. You may try again in a minute.",
|
|
182
|
+
"loginMaxAttemptsReached_plural": "Too many attempts. You may try again in {{ count }} minutes.",
|
|
181
183
|
"maxLabel": "Max:",
|
|
182
184
|
"maxUi": "Max: {{ number }}",
|
|
183
185
|
"mediaCreatedDate": "Uploaded: {{ createdDate }}",
|
|
@@ -42,6 +42,9 @@ const Passport = require('passport').Passport;
|
|
|
42
42
|
const LocalStrategy = require('passport-local');
|
|
43
43
|
const Promise = require('bluebird');
|
|
44
44
|
const cuid = require('cuid');
|
|
45
|
+
const expressSession = require('express-session');
|
|
46
|
+
|
|
47
|
+
const loginAttemptsNamespace = '@apostrophecms/loginAttempt';
|
|
45
48
|
|
|
46
49
|
module.exports = {
|
|
47
50
|
cascades: [ 'requirements' ],
|
|
@@ -52,7 +55,12 @@ module.exports = {
|
|
|
52
55
|
csrfExceptions: [
|
|
53
56
|
'login'
|
|
54
57
|
],
|
|
55
|
-
bearerTokens: true
|
|
58
|
+
bearerTokens: true,
|
|
59
|
+
throttle: {
|
|
60
|
+
allowedAttempts: 3,
|
|
61
|
+
perMinutes: 1,
|
|
62
|
+
lockoutMinutes: 1
|
|
63
|
+
}
|
|
56
64
|
},
|
|
57
65
|
async init(self) {
|
|
58
66
|
self.passport = new Passport();
|
|
@@ -133,7 +141,16 @@ module.exports = {
|
|
|
133
141
|
return req.session.destroy(callback);
|
|
134
142
|
})();
|
|
135
143
|
};
|
|
144
|
+
const cookie = req.session.cookie;
|
|
136
145
|
await destroySession();
|
|
146
|
+
// Session cookie expiration isn't automatic with `req.session.destroy`.
|
|
147
|
+
// Fix that to reduce challenges for those attempting to implement custom
|
|
148
|
+
// caching strategies at the edge
|
|
149
|
+
// https://github.com/expressjs/session/issues/241
|
|
150
|
+
const expireCookie = new expressSession.Cookie(cookie);
|
|
151
|
+
expireCookie.expires = new Date(0);
|
|
152
|
+
const name = self.apos.modules['@apostrophecms/express'].sessionOptions.name;
|
|
153
|
+
req.res.header('set-cookie', expireCookie.serialize(name, 'deleted'));
|
|
137
154
|
}
|
|
138
155
|
},
|
|
139
156
|
// invokes the `props(req, user)` function for the requirement specified by
|
|
@@ -156,8 +173,16 @@ module.exports = {
|
|
|
156
173
|
},
|
|
157
174
|
async requirementVerify(req) {
|
|
158
175
|
const name = self.apos.launder.string(req.body.name);
|
|
176
|
+
const loginNamespace = `${loginAttemptsNamespace}/${name}`;
|
|
159
177
|
|
|
160
|
-
const { user } = await self.findIncompleteTokenAndUser(
|
|
178
|
+
const { user } = await self.findIncompleteTokenAndUser(
|
|
179
|
+
req,
|
|
180
|
+
req.body.incompleteToken
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (!user) {
|
|
184
|
+
throw self.apos.error('invalid');
|
|
185
|
+
}
|
|
161
186
|
|
|
162
187
|
const requirement = self.requirements[name];
|
|
163
188
|
|
|
@@ -169,7 +194,15 @@ module.exports = {
|
|
|
169
194
|
throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
|
|
170
195
|
}
|
|
171
196
|
|
|
197
|
+
const { cachedAttempts, reached } = await self
|
|
198
|
+
.checkLoginAttempts(user.username, loginNamespace);
|
|
199
|
+
|
|
200
|
+
if (reached) {
|
|
201
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached'));
|
|
202
|
+
}
|
|
203
|
+
|
|
172
204
|
try {
|
|
205
|
+
|
|
173
206
|
await requirement.verify(req, req.body.value, user);
|
|
174
207
|
|
|
175
208
|
const token = await self.bearerTokens.findOne({
|
|
@@ -188,8 +221,16 @@ module.exports = {
|
|
|
188
221
|
$pull: { requirementsToVerify: name }
|
|
189
222
|
});
|
|
190
223
|
|
|
224
|
+
await self.clearLoginAttempts(user.username, loginNamespace);
|
|
225
|
+
|
|
191
226
|
return {};
|
|
192
227
|
} catch (err) {
|
|
228
|
+
await self.addLoginAttempt(
|
|
229
|
+
user.username,
|
|
230
|
+
cachedAttempts,
|
|
231
|
+
loginNamespace
|
|
232
|
+
);
|
|
233
|
+
|
|
193
234
|
err.data = err.data || {};
|
|
194
235
|
err.data.requirement = name;
|
|
195
236
|
throw err;
|
|
@@ -543,48 +584,64 @@ module.exports = {
|
|
|
543
584
|
// Implementation detail of the login route. Log in the user, or if there are
|
|
544
585
|
// `requirements` that require password verification occur first, return an incomplete token.
|
|
545
586
|
async initialLogin(req) {
|
|
546
|
-
// Initial login step
|
|
547
587
|
const username = self.apos.launder.string(req.body.username);
|
|
548
588
|
const password = self.apos.launder.string(req.body.password);
|
|
589
|
+
|
|
549
590
|
if (!(username && password)) {
|
|
550
591
|
throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
|
|
551
592
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
throw e;
|
|
560
|
-
}
|
|
593
|
+
|
|
594
|
+
const { cachedAttempts, reached } = await self.checkLoginAttempts(username);
|
|
595
|
+
|
|
596
|
+
if (reached) {
|
|
597
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached', {
|
|
598
|
+
count: self.options.throttle.lockoutMinutes
|
|
599
|
+
}));
|
|
561
600
|
}
|
|
562
|
-
|
|
563
|
-
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
// Initial login step
|
|
604
|
+
const { earlyRequirements, lateRequirements } = self.filterRequirements();
|
|
605
|
+
for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
|
|
606
|
+
try {
|
|
607
|
+
await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
e.data = e.data || {};
|
|
610
|
+
e.data.requirement = name;
|
|
611
|
+
throw e;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const user = await self.apos.login.verifyLogin(username, password);
|
|
615
|
+
if (!user) {
|
|
564
616
|
// For security reasons we may not tell the user which case applies
|
|
565
|
-
|
|
566
|
-
|
|
617
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
|
|
618
|
+
}
|
|
567
619
|
|
|
568
|
-
|
|
620
|
+
const requirementsToVerify = Object.keys(lateRequirements);
|
|
569
621
|
|
|
570
|
-
|
|
571
|
-
|
|
622
|
+
if (requirementsToVerify.length) {
|
|
623
|
+
const token = cuid();
|
|
624
|
+
|
|
625
|
+
await self.bearerTokens.insert({
|
|
626
|
+
_id: token,
|
|
627
|
+
userId: user._id,
|
|
628
|
+
requirementsToVerify,
|
|
629
|
+
// Default lifetime of 1 hour is generous to permit situations like
|
|
630
|
+
// installing a TOTP app for the first time
|
|
631
|
+
expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
await self.clearLoginAttempts(user.username);
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
incompleteToken: token
|
|
638
|
+
};
|
|
639
|
+
}
|
|
572
640
|
|
|
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
641
|
const session = self.apos.launder.boolean(req.body.session);
|
|
586
642
|
if (session) {
|
|
587
643
|
await self.passportLogin(req, user);
|
|
644
|
+
await self.clearLoginAttempts(user.username);
|
|
588
645
|
} else {
|
|
589
646
|
const token = cuid();
|
|
590
647
|
await self.bearerTokens.insert({
|
|
@@ -592,17 +649,30 @@ module.exports = {
|
|
|
592
649
|
userId: user._id,
|
|
593
650
|
expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
|
|
594
651
|
});
|
|
652
|
+
|
|
653
|
+
await self.clearLoginAttempts(user.username);
|
|
654
|
+
|
|
595
655
|
return {
|
|
596
656
|
token
|
|
597
657
|
};
|
|
598
658
|
}
|
|
659
|
+
} catch (err) {
|
|
660
|
+
await self.addLoginAttempt(username, cachedAttempts);
|
|
661
|
+
|
|
662
|
+
throw err;
|
|
599
663
|
}
|
|
600
664
|
},
|
|
601
665
|
|
|
602
666
|
filterRequirements() {
|
|
603
667
|
return {
|
|
604
|
-
earlyRequirements: Object.fromEntries(
|
|
605
|
-
|
|
668
|
+
earlyRequirements: Object.fromEntries(
|
|
669
|
+
Object.entries(self.requirements)
|
|
670
|
+
.filter(([ _, requirement ]) => requirement.phase === 'beforeSubmit')
|
|
671
|
+
),
|
|
672
|
+
lateRequirements: Object.fromEntries(
|
|
673
|
+
Object.entries(self.requirements)
|
|
674
|
+
.filter(([ _, requirement ]) => requirement.phase === 'afterPasswordVerified')
|
|
675
|
+
)
|
|
606
676
|
};
|
|
607
677
|
},
|
|
608
678
|
|
|
@@ -614,6 +684,63 @@ module.exports = {
|
|
|
614
684
|
})(user);
|
|
615
685
|
};
|
|
616
686
|
await passportLogin(user);
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
async addLoginAttempt (
|
|
690
|
+
username,
|
|
691
|
+
attempts,
|
|
692
|
+
namespace = loginAttemptsNamespace
|
|
693
|
+
) {
|
|
694
|
+
if (typeof attempts !== 'number') {
|
|
695
|
+
await self.apos.cache.set(namespace,
|
|
696
|
+
username,
|
|
697
|
+
1,
|
|
698
|
+
self.options.throttle.perMinutes * 60
|
|
699
|
+
);
|
|
700
|
+
} else {
|
|
701
|
+
await self.apos.cache.cacheCollection.updateOne(
|
|
702
|
+
{
|
|
703
|
+
namespace,
|
|
704
|
+
key: username
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
$inc: {
|
|
708
|
+
value: 1
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
async checkLoginAttempts (username, namespace = loginAttemptsNamespace) {
|
|
716
|
+
const cachedAttempts = await self.apos.cache.get(namespace, username);
|
|
717
|
+
const { allowedAttempts } = self.options.throttle;
|
|
718
|
+
|
|
719
|
+
if (!cachedAttempts || cachedAttempts < allowedAttempts) {
|
|
720
|
+
return { cachedAttempts };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// When this is the first time we reach the limit
|
|
724
|
+
// we set the lifetime only once with lockoutMinutes
|
|
725
|
+
if (cachedAttempts === allowedAttempts) {
|
|
726
|
+
await self.apos.cache.set(namespace,
|
|
727
|
+
username,
|
|
728
|
+
cachedAttempts + 1,
|
|
729
|
+
self.options.throttle.lockoutMinutes * 60
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
cachedAttempts,
|
|
735
|
+
reached: true
|
|
736
|
+
};
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
async clearLoginAttempts (username, namespace = loginAttemptsNamespace) {
|
|
740
|
+
await self.apos.cache.cacheCollection.deleteOne({
|
|
741
|
+
namespace,
|
|
742
|
+
key: username
|
|
743
|
+
});
|
|
617
744
|
}
|
|
618
745
|
};
|
|
619
746
|
},
|