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 +6 -0
- package/modules/@apostrophecms/i18n/i18n/en.json +2 -0
- package/modules/@apostrophecms/login/index.js +150 -33
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +6 -3
- package/package.json +1 -1
- package/test/login-requirements.js +76 -6
- package/test/login.js +42 -0
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(
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
617
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
|
|
618
|
+
}
|
|
577
619
|
|
|
578
|
-
|
|
620
|
+
const requirementsToVerify = Object.keys(lateRequirements);
|
|
579
621
|
|
|
580
|
-
|
|
581
|
-
|
|
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(
|
|
615
|
-
|
|
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 ||
|
|
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
|
|
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
|
|
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
|
@@ -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',
|
|
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',
|
|
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,
|
|
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,
|
|
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('
|
|
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,
|
|
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();
|