apostrophe 3.14.2 → 3.15.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.15.0 (2022-03-02)
4
+
5
+ ### Adds
6
+
7
+ * Adds throttle system based on username (even when not existing), on initial login route. Also added for each late login requirement, e.g. for 2FA attempts.
8
+
3
9
  ## 3.14.2 (2022-02-27)
4
10
 
5
11
  * 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.
@@ -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 }}",
@@ -44,6 +44,8 @@ const Promise = require('bluebird');
44
44
  const cuid = require('cuid');
45
45
  const expressSession = require('express-session');
46
46
 
47
+ const loginAttemptsNamespace = '@apostrophecms/loginAttempt';
48
+
47
49
  module.exports = {
48
50
  cascades: [ 'requirements' ],
49
51
  options: {
@@ -53,7 +55,12 @@ module.exports = {
53
55
  csrfExceptions: [
54
56
  'login'
55
57
  ],
56
- bearerTokens: true
58
+ bearerTokens: true,
59
+ throttle: {
60
+ allowedAttempts: 3,
61
+ perMinutes: 1,
62
+ lockoutMinutes: 1
63
+ }
57
64
  },
58
65
  async init(self) {
59
66
  self.passport = new Passport();
@@ -166,8 +173,16 @@ module.exports = {
166
173
  },
167
174
  async requirementVerify(req) {
168
175
  const name = self.apos.launder.string(req.body.name);
176
+ const loginNamespace = `${loginAttemptsNamespace}/${name}`;
169
177
 
170
- const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
178
+ const { user } = await self.findIncompleteTokenAndUser(
179
+ req,
180
+ req.body.incompleteToken
181
+ );
182
+
183
+ if (!user) {
184
+ throw self.apos.error('invalid');
185
+ }
171
186
 
172
187
  const requirement = self.requirements[name];
173
188
 
@@ -179,7 +194,15 @@ module.exports = {
179
194
  throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
180
195
  }
181
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
+
182
204
  try {
205
+
183
206
  await requirement.verify(req, req.body.value, user);
184
207
 
185
208
  const token = await self.bearerTokens.findOne({
@@ -198,8 +221,16 @@ module.exports = {
198
221
  $pull: { requirementsToVerify: name }
199
222
  });
200
223
 
224
+ await self.clearLoginAttempts(user.username, loginNamespace);
225
+
201
226
  return {};
202
227
  } catch (err) {
228
+ await self.addLoginAttempt(
229
+ user.username,
230
+ cachedAttempts,
231
+ loginNamespace
232
+ );
233
+
203
234
  err.data = err.data || {};
204
235
  err.data.requirement = name;
205
236
  throw err;
@@ -553,48 +584,64 @@ module.exports = {
553
584
  // Implementation detail of the login route. Log in the user, or if there are
554
585
  // `requirements` that require password verification occur first, return an incomplete token.
555
586
  async initialLogin(req) {
556
- // Initial login step
557
587
  const username = self.apos.launder.string(req.body.username);
558
588
  const password = self.apos.launder.string(req.body.password);
589
+
559
590
  if (!(username && password)) {
560
591
  throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
561
592
  }
562
- const { earlyRequirements, lateRequirements } = self.filterRequirements();
563
- for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
564
- try {
565
- await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
566
- } catch (e) {
567
- e.data = e.data || {};
568
- e.data.requirement = name;
569
- throw e;
570
- }
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
+ }));
571
600
  }
572
- const user = await self.apos.login.verifyLogin(username, password);
573
- if (!user) {
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) {
574
616
  // For security reasons we may not tell the user which case applies
575
- throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
576
- }
617
+ throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
618
+ }
577
619
 
578
- const requirementsToVerify = Object.keys(lateRequirements);
620
+ const requirementsToVerify = Object.keys(lateRequirements);
579
621
 
580
- if (requirementsToVerify.length) {
581
- const token = cuid();
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
+ }
582
640
 
583
- await self.bearerTokens.insert({
584
- _id: token,
585
- userId: user._id,
586
- requirementsToVerify,
587
- // Default lifetime of 1 hour is generous to permit situations like
588
- // installing a TOTP app for the first time
589
- expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
590
- });
591
- return {
592
- incompleteToken: token
593
- };
594
- } else {
595
641
  const session = self.apos.launder.boolean(req.body.session);
596
642
  if (session) {
597
643
  await self.passportLogin(req, user);
644
+ await self.clearLoginAttempts(user.username);
598
645
  } else {
599
646
  const token = cuid();
600
647
  await self.bearerTokens.insert({
@@ -602,17 +649,30 @@ module.exports = {
602
649
  userId: user._id,
603
650
  expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
604
651
  });
652
+
653
+ await self.clearLoginAttempts(user.username);
654
+
605
655
  return {
606
656
  token
607
657
  };
608
658
  }
659
+ } catch (err) {
660
+ await self.addLoginAttempt(username, cachedAttempts);
661
+
662
+ throw err;
609
663
  }
610
664
  },
611
665
 
612
666
  filterRequirements() {
613
667
  return {
614
- earlyRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'beforeSubmit')),
615
- lateRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'afterPasswordVerified'))
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
+ )
616
676
  };
617
677
  },
618
678
 
@@ -624,6 +684,63 @@ module.exports = {
624
684
  })(user);
625
685
  };
626
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
+ });
627
744
  }
628
745
  };
629
746
  },
@@ -128,7 +128,8 @@ export default {
128
128
  return this.mounted && this.beforeCreateFinished;
129
129
  },
130
130
  disabled() {
131
- return this.doc.hasErrors || !!this.beforeSubmitRequirements.find(requirement => !requirement.done);
131
+ return this.doc.hasErrors ||
132
+ !!this.beforeSubmitRequirements.find(requirement => !requirement.done);
132
133
  },
133
134
  beforeSubmitRequirements() {
134
135
  return this.requirements.filter(requirement => requirement.phase === 'beforeSubmit');
@@ -274,12 +275,14 @@ export default {
274
275
  location.assign(`${apos.prefix}/`);
275
276
  },
276
277
  async requirementBlock(requirementBlock) {
277
- const requirement = this.requirements.find(requirement => requirement.name === requirementBlock.name);
278
+ const requirement = this.requirements
279
+ .find(requirement => requirement.name === requirementBlock.name);
278
280
  requirement.done = false;
279
281
  requirement.value = undefined;
280
282
  },
281
283
  async requirementDone(requirementDone, value) {
282
- const requirement = this.requirements.find(requirement => requirement.name === requirementDone.name);
284
+ const requirement = this.requirements
285
+ .find(requirement => requirement.name === requirementDone.name);
283
286
 
284
287
  if (requirement.phase === 'beforeSubmit') {
285
288
  requirement.done = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.14.2",
3
+ "version": "3.15.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -4,6 +4,8 @@ const assert = require('assert');
4
4
  let apos;
5
5
 
6
6
  describe('Login', function() {
7
+ const extraSecretErr = 'extra secret incorrect';
8
+ const captchaErr = 'captcha code incorrect';
7
9
 
8
10
  this.timeout(20000);
9
11
 
@@ -30,7 +32,7 @@ describe('Login', function() {
30
32
  },
31
33
  async verify(req, data) {
32
34
  if (data !== 'xyz') {
33
- throw self.apos.error('invalid', 'captcha code incorrect');
35
+ throw self.apos.error('invalid', captchaErr);
34
36
  }
35
37
  }
36
38
  },
@@ -45,7 +47,7 @@ describe('Login', function() {
45
47
  },
46
48
  async verify(req, data, user) {
47
49
  if (data !== user.extraSecret) {
48
- throw self.apos.error('invalid', 'extra secret incorrect');
50
+ throw self.apos.error('invalid', extraSecretErr);
49
51
  }
50
52
  }
51
53
  }
@@ -119,7 +121,7 @@ describe('Login', function() {
119
121
  assert(false);
120
122
  } catch (e) {
121
123
  assert(e.status === 400);
122
- assert.strictEqual(e.body.message, 'captcha code incorrect');
124
+ assert.strictEqual(e.body.message, captchaErr);
123
125
  assert.strictEqual(e.body.data.requirement, 'WeakCaptcha');
124
126
  }
125
127
 
@@ -167,7 +169,7 @@ describe('Login', function() {
167
169
  assert(false);
168
170
  } catch (e) {
169
171
  assert(e.status === 400);
170
- assert.strictEqual(e.body.message, 'captcha code incorrect');
172
+ assert.strictEqual(e.body.message, captchaErr);
171
173
  assert.strictEqual(e.body.data.requirement, 'WeakCaptcha');
172
174
  }
173
175
 
@@ -182,7 +184,10 @@ describe('Login', function() {
182
184
  assert(page.match(/logged out/));
183
185
  });
184
186
 
185
- it('initial login should produce an incompleteToken, convertible with the afterPasswordVerified requirements', async function() {
187
+ it('should throttle requirements verify attemps and show a proper error when the limit is reached', async function () {
188
+ const loginModule = apos.modules['@apostrophecms/login'];
189
+ const { allowedAttempts } = loginModule.options.throttle;
190
+ const namespace = '@apostrophecms/loginAttempt/ExtraSecret';
186
191
 
187
192
  const jar = apos.http.jar();
188
193
 
@@ -224,6 +229,71 @@ describe('Login', function() {
224
229
 
225
230
  assert(page.match(/logged out/));
226
231
 
232
+ const token = result.incompleteToken;
233
+
234
+ for (let index = 0; index <= allowedAttempts; index++) {
235
+ try {
236
+ await apos.http.post('/api/v1/@apostrophecms/login/requirement-verify', {
237
+ body: {
238
+ incompleteToken: token,
239
+ session: true,
240
+ name: 'ExtraSecret',
241
+ value: 'roll-off'
242
+ },
243
+ jar
244
+ });
245
+ } catch ({ status, body }) {
246
+ if (index < allowedAttempts) {
247
+ assert(body.message === extraSecretErr);
248
+ } else {
249
+ assert(body.message === 'Too many attempts. You may try again in a minute.');
250
+ }
251
+ }
252
+ }
253
+
254
+ await loginModule.clearLoginAttempts('HarryPutter', namespace);
255
+ });
256
+
257
+ it('initial login should produce an incompleteToken, convertible with the afterPasswordVerified requirements', async function() {
258
+
259
+ const jar = apos.http.jar();
260
+
261
+ // establish session
262
+ let page = await apos.http.get(
263
+ '/',
264
+ {
265
+ jar
266
+ }
267
+ );
268
+
269
+ const result = await apos.http.post(
270
+ '/api/v1/@apostrophecms/login/login',
271
+ {
272
+ method: 'POST',
273
+ body: {
274
+ username: 'HarryPutter',
275
+ password: 'crookshanks',
276
+ session: true,
277
+ requirements: {
278
+ WeakCaptcha: 'xyz'
279
+ }
280
+ },
281
+ jar
282
+ }
283
+ );
284
+
285
+ assert(result.incompleteToken);
286
+
287
+ // Make sure it did not create a login session prematurely
288
+ page = await apos.http.get(
289
+ '/',
290
+ {
291
+ jar
292
+ }
293
+ );
294
+
295
+ assert(page.match(/logged out/));
296
+
227
297
  // Make sure it won't convert with an incorrect ExtraSecret
228
298
 
229
299
  const token = result.incompleteToken;
@@ -240,7 +310,7 @@ describe('Login', function() {
240
310
  });
241
311
  } catch ({ status, body }) {
242
312
  assert(status === 400);
243
- assert.strictEqual(body.message, 'extra secret incorrect');
313
+ assert.strictEqual(body.message, extraSecretErr);
244
314
  assert.strictEqual(body.data.requirement, 'ExtraSecret');
245
315
  }
246
316
 
package/test/login.js CHANGED
@@ -41,6 +41,48 @@ describe('Login', function() {
41
41
  assert(doc._id);
42
42
  });
43
43
 
44
+ it('should throttle login attempts and show a proper error when the limit is reached', async function () {
45
+ const loginModule = apos.modules['@apostrophecms/login'];
46
+ const { allowedAttempts } = loginModule.options.throttle;
47
+ const jar = apos.http.jar();
48
+ const username = 'HarryPutter';
49
+ // establish session
50
+ const page = await apos.http.get(
51
+ '/',
52
+ {
53
+ jar
54
+ }
55
+ );
56
+
57
+ assert(page.match(/logged out/));
58
+
59
+ for (let index = 0; index <= allowedAttempts; index++) {
60
+ try {
61
+ await apos.http.post(
62
+ '/api/v1/@apostrophecms/login/login',
63
+ {
64
+ method: 'POST',
65
+ body: {
66
+ username,
67
+ password: 'badpassword',
68
+ session: true
69
+ },
70
+ jar
71
+ }
72
+ );
73
+
74
+ } catch ({ body }) {
75
+ if (index < allowedAttempts) {
76
+ assert(body.message === 'Your credentials are incorrect, or there is no such user');
77
+ } else {
78
+ assert(body.message === 'Too many attempts. You may try again in a minute.');
79
+ }
80
+ }
81
+ }
82
+
83
+ await loginModule.clearLoginAttempts(username);
84
+ });
85
+
44
86
  it('should be able to login a user with their username', async function() {
45
87
 
46
88
  const jar = apos.http.jar();