apostrophe 3.12.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 +13 -0
- package/modules/@apostrophecms/asset/lib/globalIcons.js +2 -0
- package/modules/@apostrophecms/attachment/index.js +1 -1
- 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 +3 -1
- package/modules/@apostrophecms/login/index.js +272 -42
- 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/page/index.js +3 -3
- package/modules/@apostrophecms/ui/ui/apos/lib/i18next.js +2 -1
- package/package.json +1 -1
- package/test/login-requirements.js +328 -0
- package/test/pages-rest.js +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.13.0 - 2022-02-04
|
|
4
|
+
|
|
5
|
+
### Adds
|
|
6
|
+
|
|
7
|
+
* Additional requirements and related UI may be imposed on native ApostropheCMS logins using the new `requirements` feature, which can be extended in modules that `improve` the `@apostrophecms/login` module. These requirements are not imposed for single sign-on logins via `@apostrophecms/passport-bridge`. See the documentation for more information.
|
|
8
|
+
* Adds latest Slovak translation strings to SK.json in `i18n/` folder. Thanks to [Michael Huna](https://github.com/Miselrkba) for the contribution.
|
|
9
|
+
* Verifies `afterPasswordVerified` requirements one by one when emitting done event, allows to manage errors ans success before to go to the next requirement. Stores and validate each requirement in the token. Checks the new `askForConfirmation` requirement option to go to the next step when emitting done event or waiting for the confirm event (in order to manage success messages). Removes support for `afterSubmit` for now.
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
* Decodes the testReq `param` property in `serveNotFound`. This fixes a problem where page titles using diacritics triggered false 404 errors.
|
|
14
|
+
* Registers the default namespace in the Vue instance of i18n, fixing a lack of support for un-namespaced l10n keys in the UI.
|
|
15
|
+
|
|
3
16
|
## 3.12.0 - 2022-01-21
|
|
4
17
|
|
|
5
18
|
### Adds
|
|
@@ -12,6 +12,8 @@ module.exports = {
|
|
|
12
12
|
'checkbox-blank-icon': 'CheckboxBlankOutline',
|
|
13
13
|
'check-all-icon': 'CheckAll',
|
|
14
14
|
'check-bold-icon': 'CheckBold',
|
|
15
|
+
'check-circle-icon': 'CheckCircle',
|
|
16
|
+
'check-decagram-icon': 'CheckDecagram',
|
|
15
17
|
'checkbox-marked-icon': 'CheckboxMarked',
|
|
16
18
|
'chevron-down-icon': 'ChevronDown',
|
|
17
19
|
'chevron-left-icon': 'ChevronLeft',
|
|
@@ -1114,7 +1114,7 @@ module.exports = {
|
|
|
1114
1114
|
await copyOut(uploadfsPath, tempFile);
|
|
1115
1115
|
await self.sanitizeSvg(tempFile);
|
|
1116
1116
|
await copyIn(tempFile, uploadfsPath);
|
|
1117
|
-
await self.db.
|
|
1117
|
+
await self.db.updateOne({
|
|
1118
1118
|
_id: attachment._id
|
|
1119
1119
|
}, {
|
|
1120
1120
|
$set: {
|
|
@@ -194,11 +194,11 @@ module.exports = {
|
|
|
194
194
|
_id: doc._id.replace(':draft', ':previous')
|
|
195
195
|
});
|
|
196
196
|
if (published) {
|
|
197
|
-
await self.apos.doc.db.
|
|
197
|
+
await self.apos.doc.db.removeOne({ _id: published._id });
|
|
198
198
|
await self.emit('afterDelete', req, published, { checkForChildren: false });
|
|
199
199
|
}
|
|
200
200
|
if (previous) {
|
|
201
|
-
await self.apos.doc.db.
|
|
201
|
+
await self.apos.doc.db.removeOne({ _id: previous._id });
|
|
202
202
|
await self.emit('afterDelete', req, previous, { checkForChildren: false });
|
|
203
203
|
}
|
|
204
204
|
},
|
|
@@ -307,7 +307,13 @@ module.exports = {
|
|
|
307
307
|
// "expires" ourselves too
|
|
308
308
|
const bearer = await self.apos.login.bearerTokens.findOne({
|
|
309
309
|
_id: req.token,
|
|
310
|
-
expires: { $gte: new Date() }
|
|
310
|
+
expires: { $gte: new Date() },
|
|
311
|
+
// requirementsToVerify array should be empty or inexistant
|
|
312
|
+
// for the token to be usable to log in.
|
|
313
|
+
$or: [
|
|
314
|
+
{ requirementsToVerify: { $exists: false } },
|
|
315
|
+
{ requirementsToVerify: { $ne: [] } }
|
|
316
|
+
]
|
|
311
317
|
});
|
|
312
318
|
return bearer && bearer.userId;
|
|
313
319
|
}
|
|
@@ -167,6 +167,7 @@
|
|
|
167
167
|
"localizeNewRelated": "Localize new related documents",
|
|
168
168
|
"localizingBusy": "Localizing Content",
|
|
169
169
|
"login": "Login",
|
|
170
|
+
"loginErrorGeneric": "An error occurred. Please try again.",
|
|
170
171
|
"loginDisabled": "Login Disabled",
|
|
171
172
|
"loginPageBothRequired": "Both the username and the password are required.",
|
|
172
173
|
"loginPageBadCredentials": "Your credentials are incorrect, or there is no such user",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"addItem": "Pridať položku",
|
|
4
4
|
"addWidgetType": "Pridať {{ label }}",
|
|
5
5
|
"admin": "Admin",
|
|
6
|
+
"affirmativeLabel": "Áno, pokračovať.",
|
|
6
7
|
"altText": "Alternatívny text",
|
|
7
8
|
"altTextHelp": "Alternatívny popis obrázkov pre zlepšenú prístupnosť",
|
|
8
9
|
"any": "ľubovoľný",
|
|
@@ -10,6 +11,8 @@
|
|
|
10
11
|
"applyToSubpages": "Aplikovať na podstánky",
|
|
11
12
|
"arrayCancelDescription": "Chcete zahodiť zmeny v tomto zozname?",
|
|
12
13
|
"archive": "Archív",
|
|
14
|
+
"archivingBatchConfirmation": "Naozaj chcete archivovať {{ count }} {{ type }}?",
|
|
15
|
+
"archivingBatchConfirmationButton": "Áno, archivovať obsah.",
|
|
13
16
|
"archiveImage": "Archivovať obrázok",
|
|
14
17
|
"archiveOnlyThisPage": "Archivovať len túto stránku",
|
|
15
18
|
"archivePageAndSubpages": "Archivovať túto stránku a všetky podradené stránky",
|
|
@@ -121,6 +124,7 @@
|
|
|
121
124
|
"errorPageMessage": "Došlo k chybe",
|
|
122
125
|
"errorPageStatusCode": "500",
|
|
123
126
|
"errorPageTitle": "Došlo k chybe v nadpise stánky",
|
|
127
|
+
"errorBatchOperationNoti": "Skupinová operácia {{ operation }} zlyhala.",
|
|
124
128
|
"everythingElse": "Všetko ostatné",
|
|
125
129
|
"exit": "Odísť",
|
|
126
130
|
"fetchPublishedVersionFailed": "Pri načítaní zverejnenej verzie dokumentu sa vyskytla chyba.",
|
|
@@ -173,6 +177,8 @@
|
|
|
173
177
|
"manageDocType": "Spravovať {{ type }}",
|
|
174
178
|
"manageDraftSubmissions": "Spravujte návrhy príspevkov",
|
|
175
179
|
"managePages": "Spravovať stránky",
|
|
180
|
+
"maxLabel": "Max:",
|
|
181
|
+
"maxUi": "Max: {{ number }}",
|
|
176
182
|
"mediaCreatedDate": "Nahraté: {{ createdDate }}",
|
|
177
183
|
"mediaDimensions": "Rozmery: {{ width }} 𝗑 {{ height }}",
|
|
178
184
|
"mediaFileSize": "Veľkosť súboru: {{ fileSize }}",
|
|
@@ -180,6 +186,10 @@
|
|
|
180
186
|
"mediaMB": "{{ size }}MB",
|
|
181
187
|
"mediaUploadViaDrop": "Presuňte ich sem, keď budete pripravení",
|
|
182
188
|
"mediaUploadViaExplorer": "Alebo kliknutím otvorte adresárovú štruktúru",
|
|
189
|
+
"minLabel": "Min:",
|
|
190
|
+
"minUi": "Min: {{ number }}",
|
|
191
|
+
"modify": "Zmeniť",
|
|
192
|
+
"modifyOrDelete": "Zmeniť / Vymazať",
|
|
183
193
|
"moreOptions": "Viac možností",
|
|
184
194
|
"moreOperations": "Viac úprav",
|
|
185
195
|
"multipleEditors": "Viaceré editory",
|
|
@@ -202,17 +212,19 @@
|
|
|
202
212
|
"notFoundPageStatusCode": "404",
|
|
203
213
|
"notFoundPageTitle": "404 - Stránka nenájdená",
|
|
204
214
|
"notInLocale": "Aktuálna stránka v {{ label }} neexistuje. Preložte verziu z {{ currentLocale }}?",
|
|
215
|
+
"notificationClearEventError": "Pri vymazávaní zaregistrovanej notifikačnej udalosti sa vyskytla chyba.",
|
|
205
216
|
"noTypeFound": "Nenašiel sa žiadny {{ type }}",
|
|
206
217
|
"parentNotLocalized": "Najprv preložte nadradenú stránku",
|
|
207
218
|
"notYetPublished": "Tento dokument ešte nebol zverejnený.",
|
|
208
219
|
"nudgeDown": "Posunúť nadol",
|
|
209
220
|
"nudgeUp": "Posunúť nahor",
|
|
221
|
+
"numberAdded": "{{ count }} Pridané",
|
|
210
222
|
"office": "Kancelária",
|
|
211
223
|
"openGlobal": "Otvorte globálne nastavenia webu",
|
|
212
224
|
"page": "Stránka",
|
|
213
225
|
"pageDoesNotExistYet": "Stránka zatiaľ neexistuje",
|
|
214
226
|
"pageDoesNotExistYetDescription": "Stránka, ktorá poskytuje záznam pre tento diel, ešte nie je k dispozícii ako {{ mode }} v jazykovej mutácii {{ locale }}.",
|
|
215
|
-
"pageIsParked": "Táto stránka je
|
|
227
|
+
"pageIsParked": "Táto stránka je uzamknutá nemožno ju presunúť",
|
|
216
228
|
"pageNumber": "Stránka {{ number }}",
|
|
217
229
|
"pageTitle": "Názov stránky",
|
|
218
230
|
"pages": "Stránky",
|
|
@@ -242,6 +254,8 @@
|
|
|
242
254
|
"richTextUndo": "Vrátiť späť",
|
|
243
255
|
"richTextStyleConfigWarning": "Nesprávne nakonfigurovaný štýl: popiska: {{ label }}, {{ tag }}",
|
|
244
256
|
"password": "Heslo",
|
|
257
|
+
"passwordErrorMin": "Minimum {{ min }} znakov",
|
|
258
|
+
"passwordErrorMax": "Maximum {{ max }} znakov",
|
|
245
259
|
"passwordResetRequest": "Vaša žiadosť o obnovenie hesla zo stránky {{ site }}",
|
|
246
260
|
"pasteWidget": "Prilepiť {{ widget }}",
|
|
247
261
|
"pending": "Čaká na spracovanie",
|
|
@@ -277,6 +291,8 @@
|
|
|
277
291
|
"relatedDocsOnly": "Len súvisiace dokumenty",
|
|
278
292
|
"relatedDocsDefinition": "Súvisiace dokumenty sú dokumenty, na ktoré sa odkazuje v tomto dokumente. Obvykle to zahŕňa obrázky, obsah definovaný vzťahmi atď.",
|
|
279
293
|
"restore": "Obnoviť",
|
|
294
|
+
"restoreBatchConfirmation": "Naozaj chcete obnoviť {{ count }} {{ type }}?",
|
|
295
|
+
"restoreBatchConfirmationButton": "Áno, obnoviť obsah.",
|
|
280
296
|
"resolveErrorsBeforeSaving": "Pred uložením vyriešte chyby.",
|
|
281
297
|
"resolveErrorsFirst": "Najprv vyriešte chyby.",
|
|
282
298
|
"restoreOnlyThisPage": "Obnovte iba túto stránku",
|
|
@@ -305,6 +321,12 @@
|
|
|
305
321
|
"select": "Vybrať",
|
|
306
322
|
"selectedMenuItem": "✓ {{ label }}",
|
|
307
323
|
"selectAll": "Vybrať všetko",
|
|
324
|
+
"selectBoxMessage": "{{ num }} {{ label }} vybraté.",
|
|
325
|
+
"selectBoxMessagePage": "{{ num }} {{ label }} na tejto stránke vybraté.",
|
|
326
|
+
"selectBoxMessageAllButton": "Vybrať všetko {{ num }} {{ label }}.",
|
|
327
|
+
"selectBoxMessageButton": "Vybrať {{ num }} {{ label }}.",
|
|
328
|
+
"selectBoxMessageSelected": "{{ num }} {{ label }} vybraté.",
|
|
329
|
+
"selectBoxMessageAllSelected": "Všetky {{ num }} {{ label }} vybraté.",
|
|
308
330
|
"deselectAll": "Odznačiť všetko",
|
|
309
331
|
"selectContent": "Vyberte obsah",
|
|
310
332
|
"selectContentToLocalize": "Ktorý obsah chcete lokalizovať?",
|
|
@@ -47,6 +47,7 @@ module.exports = {
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
async init(self) {
|
|
50
|
+
self.defaultNamespace = 'default';
|
|
50
51
|
self.namespaces = {};
|
|
51
52
|
self.debug = process.env.APOS_DEBUG_I18N ? true : self.options.debug;
|
|
52
53
|
self.show = process.env.APOS_SHOW_I18N ? true : self.options.show;
|
|
@@ -84,7 +85,7 @@ module.exports = {
|
|
|
84
85
|
// Nunjucks and Vue will already do this
|
|
85
86
|
escapeValue: false
|
|
86
87
|
},
|
|
87
|
-
defaultNS:
|
|
88
|
+
defaultNS: self.defaultNamespace,
|
|
88
89
|
debug: self.debug
|
|
89
90
|
});
|
|
90
91
|
if (self.show) {
|
|
@@ -540,6 +541,7 @@ module.exports = {
|
|
|
540
541
|
i18n,
|
|
541
542
|
locale: req.locale,
|
|
542
543
|
defaultLocale: self.defaultLocale,
|
|
544
|
+
defaultNamespace: self.defaultNamespace,
|
|
543
545
|
locales: self.locales,
|
|
544
546
|
debug: self.debug,
|
|
545
547
|
show: self.show,
|
|
@@ -44,6 +44,7 @@ const Promise = require('bluebird');
|
|
|
44
44
|
const cuid = require('cuid');
|
|
45
45
|
|
|
46
46
|
module.exports = {
|
|
47
|
+
cascades: [ 'requirements' ],
|
|
47
48
|
options: {
|
|
48
49
|
alias: 'login',
|
|
49
50
|
localLogin: true,
|
|
@@ -106,34 +107,13 @@ module.exports = {
|
|
|
106
107
|
return {
|
|
107
108
|
post: {
|
|
108
109
|
async login(req) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
const user = await self.apos.login.verifyLogin(username, password);
|
|
116
|
-
if (!user) {
|
|
117
|
-
// For security reasons we may not tell the user which case applies
|
|
118
|
-
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
|
|
119
|
-
}
|
|
120
|
-
if (session) {
|
|
121
|
-
const passportLogin = (user) => {
|
|
122
|
-
return require('util').promisify(function(user, callback) {
|
|
123
|
-
return req.login(user, callback);
|
|
124
|
-
})(user);
|
|
125
|
-
};
|
|
126
|
-
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);
|
|
127
115
|
} else {
|
|
128
|
-
|
|
129
|
-
await self.bearerTokens.insert({
|
|
130
|
-
_id: token,
|
|
131
|
-
userId: user._id,
|
|
132
|
-
expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
|
|
133
|
-
});
|
|
134
|
-
return {
|
|
135
|
-
token
|
|
136
|
-
};
|
|
116
|
+
return self.initialLogin(req);
|
|
137
117
|
}
|
|
138
118
|
},
|
|
139
119
|
async logout(req) {
|
|
@@ -141,7 +121,7 @@ module.exports = {
|
|
|
141
121
|
throw self.apos.error('forbidden', req.t('apostrophe:logOutNotLoggedIn'));
|
|
142
122
|
}
|
|
143
123
|
if (req.token) {
|
|
144
|
-
await self.bearerTokens.
|
|
124
|
+
await self.bearerTokens.removeOne({
|
|
145
125
|
userId: req.user._id,
|
|
146
126
|
_id: req.token
|
|
147
127
|
});
|
|
@@ -156,6 +136,68 @@ module.exports = {
|
|
|
156
136
|
await destroySession();
|
|
157
137
|
}
|
|
158
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
|
+
},
|
|
159
201
|
...(self.options.passwordReset ? {
|
|
160
202
|
async resetRequest(req) {
|
|
161
203
|
const site = (req.headers.host || '').replace(/:\d+$/, '');
|
|
@@ -217,19 +259,12 @@ module.exports = {
|
|
|
217
259
|
} : {})
|
|
218
260
|
},
|
|
219
261
|
get: {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return {
|
|
229
|
-
env: process.env.NODE_ENV || 'development',
|
|
230
|
-
name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
|
|
231
|
-
version: aposPackage.version || '3'
|
|
232
|
-
};
|
|
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);
|
|
233
268
|
}
|
|
234
269
|
}
|
|
235
270
|
};
|
|
@@ -237,6 +272,33 @@ module.exports = {
|
|
|
237
272
|
methods(self) {
|
|
238
273
|
return {
|
|
239
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
|
+
|
|
240
302
|
// return the loginUrl option
|
|
241
303
|
login(url) {
|
|
242
304
|
return self.options.loginUrl ? self.options.loginUrl : '/login';
|
|
@@ -368,7 +430,17 @@ module.exports = {
|
|
|
368
430
|
username: req.user.username,
|
|
369
431
|
email: req.user.email
|
|
370
432
|
}
|
|
371
|
-
} : {})
|
|
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
|
+
)
|
|
372
444
|
};
|
|
373
445
|
},
|
|
374
446
|
|
|
@@ -384,6 +456,164 @@ module.exports = {
|
|
|
384
456
|
async enableBearerTokens() {
|
|
385
457
|
self.bearerTokens = self.apos.db.collection('aposBearerTokens');
|
|
386
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);
|
|
387
617
|
}
|
|
388
618
|
};
|
|
389
619
|
},
|