@proteinjs/user-server 1.22.1 → 1.22.3
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 +28 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordHasher.d.ts +9 -0
- package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
- package/dist/src/authentication/PasswordHasher.js +25 -0
- package/dist/src/authentication/PasswordHasher.js.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
- package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
- package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
- package/dist/src/authentication/authenticate.d.ts +15 -0
- package/dist/src/authentication/authenticate.d.ts.map +1 -1
- package/dist/src/authentication/authenticate.js +55 -20
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +3 -2
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +29 -14
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +4 -0
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +12 -4
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +7 -0
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +28 -10
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/login.d.ts +8 -0
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +63 -22
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/routes/signup.d.ts.map +1 -1
- package/dist/src/routes/signup.js +5 -2
- package/dist/src/routes/signup.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +5 -1
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/src/services/MachineCredentials.d.ts +10 -1
- package/dist/src/services/MachineCredentials.d.ts.map +1 -1
- package/dist/src/services/MachineCredentials.js +46 -15
- package/dist/src/services/MachineCredentials.js.map +1 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +6 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/Signup.d.ts +5 -0
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +28 -6
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/src/throttle/ClientAddress.d.ts +35 -0
- package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
- package/dist/src/throttle/ClientAddress.js +48 -0
- package/dist/src/throttle/ClientAddress.js.map +1 -0
- package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
- package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
- package/dist/src/throttle/PasswordResetThrottle.js +59 -0
- package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
- package/dist/src/throttle/RequestDigests.d.ts +42 -0
- package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
- package/dist/src/throttle/RequestDigests.js +96 -0
- package/dist/src/throttle/RequestDigests.js.map +1 -0
- package/dist/src/throttle/SignInThrottle.d.ts +67 -0
- package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
- package/dist/src/throttle/SignInThrottle.js +91 -0
- package/dist/src/throttle/SignInThrottle.js.map +1 -0
- package/dist/src/throttle/SlidingWindow.d.ts +51 -0
- package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
- package/dist/src/throttle/SlidingWindow.js +85 -0
- package/dist/src/throttle/SlidingWindow.js.map +1 -0
- package/dist/test/ClientAddress.test.d.ts +2 -0
- package/dist/test/ClientAddress.test.d.ts.map +1 -0
- package/dist/test/ClientAddress.test.js +51 -0
- package/dist/test/ClientAddress.test.js.map +1 -0
- package/dist/test/DevLogin.test.js +68 -0
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.js +373 -5
- package/dist/test/InitiatePasswordReset.test.js.map +1 -1
- package/dist/test/LogCapture.d.ts +10 -0
- package/dist/test/LogCapture.d.ts.map +1 -1
- package/dist/test/LogCapture.js +19 -0
- package/dist/test/LogCapture.js.map +1 -1
- package/dist/test/LogCapture.test.d.ts +2 -0
- package/dist/test/LogCapture.test.d.ts.map +1 -0
- package/dist/test/LogCapture.test.js +86 -0
- package/dist/test/LogCapture.test.js.map +1 -0
- package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
- package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
- package/dist/test/LoginThrottle.integration.test.js +616 -0
- package/dist/test/LoginThrottle.integration.test.js.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +156 -28
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
- package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
- package/dist/test/NoAddressesInLogs.integration.test.js +717 -0
- package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
- package/dist/test/RequestDigests.test.d.ts +2 -0
- package/dist/test/RequestDigests.test.d.ts.map +1 -0
- package/dist/test/RequestDigests.test.js +73 -0
- package/dist/test/RequestDigests.test.js.map +1 -0
- package/dist/test/SignupRoute.test.js +80 -1
- package/dist/test/SignupRoute.test.js.map +1 -1
- package/dist/test/SlidingWindow.test.d.ts +2 -0
- package/dist/test/SlidingWindow.test.d.ts.map +1 -0
- package/dist/test/SlidingWindow.test.js +72 -0
- package/dist/test/SlidingWindow.test.js.map +1 -0
- package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
- package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
- package/dist/test/SocketIOSessionWatcher.test.js +144 -0
- package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +5 -0
- package/package.json +6 -5
- package/src/authentication/PasswordHasher.ts +18 -1
- package/src/authentication/SocketIOSessionWatcher.ts +13 -1
- package/src/authentication/UserStatusTableWatcher.ts +2 -1
- package/src/authentication/authenticate.ts +36 -9
- package/src/authorization/userCache.ts +3 -2
- package/src/routes/devLogin.ts +22 -10
- package/src/routes/executePasswordReset.ts +11 -3
- package/src/routes/initiatePasswordReset.ts +29 -11
- package/src/routes/login.ts +39 -8
- package/src/routes/signup.ts +5 -2
- package/src/routes/validateResetPasswordToken.ts +5 -1
- package/src/services/MachineCredentials.ts +44 -9
- package/src/services/Roles.ts +6 -2
- package/src/services/Signup.ts +30 -4
- package/src/throttle/ClientAddress.ts +47 -0
- package/src/throttle/PasswordResetThrottle.ts +62 -0
- package/src/throttle/RequestDigests.ts +97 -0
- package/src/throttle/SignInThrottle.ts +98 -0
- package/src/throttle/SlidingWindow.ts +90 -0
- package/test/ClientAddress.test.ts +54 -0
- package/test/DevLogin.test.ts +35 -1
- package/test/InitiatePasswordReset.test.ts +212 -6
- package/test/LogCapture.test.ts +29 -0
- package/test/LogCapture.ts +16 -0
- package/test/LoginThrottle.integration.test.ts +339 -0
- package/test/MachineAccounts.integration.test.ts +97 -22
- package/test/NoAddressesInLogs.integration.test.ts +409 -0
- package/test/RequestDigests.test.ts +81 -0
- package/test/SignupRoute.test.ts +54 -1
- package/test/SlidingWindow.test.ts +85 -0
- package/test/SocketIOSessionWatcher.test.ts +86 -0
- package/test/setup.js +2 -0
|
@@ -7,6 +7,9 @@ import { tables } from '@proteinjs/user';
|
|
|
7
7
|
import { initiatePasswordReset } from '../src/routes/initiatePasswordReset';
|
|
8
8
|
import { executePasswordReset } from '../src/routes/executePasswordReset';
|
|
9
9
|
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
10
|
+
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
11
|
+
import { login } from '../src/routes/login';
|
|
12
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
10
13
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
11
14
|
import { LogCapture } from './LogCapture';
|
|
12
15
|
|
|
@@ -22,12 +25,30 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
22
25
|
* - an unknown address gets the same response and no mail;
|
|
23
26
|
* - a request with no body, or no usable email: 400, no mail, nothing changes;
|
|
24
27
|
* - no minted token, no leading characters of one and no full digest reach the log — on the
|
|
25
|
-
* mailed path, the throttled repeat, the unknown address, or the failed send's error line
|
|
28
|
+
* mailed path, the throttled repeat, the unknown address, or the failed send's error line;
|
|
29
|
+
* - ONE answer, whatever happens behind it: an account mailed, no account, too soon, over a
|
|
30
|
+
* window, a failed send — the same 200 and the same sentence, sent before the account is
|
|
31
|
+
* looked up (so its timing says nothing either);
|
|
32
|
+
* - per address three requests an hour and per client address ten: past either, nothing is mailed;
|
|
33
|
+
* - every request is one outcome line carrying the account digest and the coarse IP hash, never
|
|
34
|
+
* the address.
|
|
26
35
|
*/
|
|
27
36
|
|
|
28
37
|
type RouteOutcome = { status: number; body?: any };
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
/** Each request arrives from its own client address unless the test names one (the dev shape: no proxy). */
|
|
40
|
+
let nextAddress = 0;
|
|
41
|
+
const fromAddress = (ip?: string) => ({
|
|
42
|
+
headers: {},
|
|
43
|
+
socket: { remoteAddress: ip ?? `10.20.${Math.floor(nextAddress / 250)}.${(nextAddress++ % 250) + 1}` },
|
|
44
|
+
app: { get: () => false },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const invoke = async (
|
|
48
|
+
route: typeof initiatePasswordReset,
|
|
49
|
+
request: Record<string, unknown>,
|
|
50
|
+
events?: string[]
|
|
51
|
+
): Promise<RouteOutcome> => {
|
|
31
52
|
const outcome: RouteOutcome = { status: 200 };
|
|
32
53
|
const response = {
|
|
33
54
|
status(code: number) {
|
|
@@ -35,14 +56,15 @@ const invoke = async (route: typeof initiatePasswordReset, request: Record<strin
|
|
|
35
56
|
return this;
|
|
36
57
|
},
|
|
37
58
|
send(body?: unknown) {
|
|
59
|
+
events?.push('answered');
|
|
38
60
|
outcome.body = body;
|
|
39
61
|
},
|
|
40
62
|
};
|
|
41
|
-
await route.onRequest(request as never, response as never);
|
|
63
|
+
await route.onRequest({ ...fromAddress(), ...request } as never, response as never);
|
|
42
64
|
return outcome;
|
|
43
65
|
};
|
|
44
66
|
|
|
45
|
-
const GENERIC = { message: 'If
|
|
67
|
+
const GENERIC = { message: 'If that address has an account, a reset link is on its way.' };
|
|
46
68
|
|
|
47
69
|
const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
48
70
|
|
|
@@ -71,6 +93,25 @@ const mailedToken = (): string => tokenInLink(MailSink.get().list(1)[0]?.text);
|
|
|
71
93
|
|
|
72
94
|
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
73
95
|
|
|
96
|
+
/** Moves an account's last mint back past the five-minute gap, so only the windows can refuse. */
|
|
97
|
+
const ageLastMint = async (email: string) => {
|
|
98
|
+
const armed = await userRow(email);
|
|
99
|
+
await getDbAsSystem().update(tables.User, {
|
|
100
|
+
id: armed.id,
|
|
101
|
+
passwordResetTokenExpiration: moment().add(54, 'minutes'),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Log text without terminal colour codes. */
|
|
106
|
+
const plain = (text: string) => text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '');
|
|
107
|
+
|
|
108
|
+
/** The `field: '<16 hex>'` value on each line containing `marker` ('missing' where absent). */
|
|
109
|
+
const fieldsOn = (log: LogCapture, marker: string, field: string): string[] =>
|
|
110
|
+
log.lines
|
|
111
|
+
.map(plain)
|
|
112
|
+
.filter((line) => line.includes(marker))
|
|
113
|
+
.map((line) => new RegExp(`${field}: '([0-9a-f]{16})'`).exec(line)?.[1] ?? 'missing');
|
|
114
|
+
|
|
74
115
|
describe('initiatePasswordReset route', () => {
|
|
75
116
|
const originalTransport = process.env.EMAIL_TRANSPORT;
|
|
76
117
|
|
|
@@ -144,7 +185,7 @@ describe('initiatePasswordReset route', () => {
|
|
|
144
185
|
try {
|
|
145
186
|
const failed = await invoke(initiatePasswordReset, { body: { email: 'initiate-send-fails@test.local' } });
|
|
146
187
|
|
|
147
|
-
expect(failed
|
|
188
|
+
expect(failed).toEqual({ status: 200, body: GENERIC });
|
|
148
189
|
const withdrawn = await userRow('initiate-send-fails@test.local');
|
|
149
190
|
expect(withdrawn.passwordResetToken).toBeNull();
|
|
150
191
|
expect(withdrawn.passwordResetTokenExpiration).toBeNull();
|
|
@@ -209,7 +250,7 @@ describe('initiatePasswordReset route', () => {
|
|
|
209
250
|
});
|
|
210
251
|
try {
|
|
211
252
|
const failed = await invoke(initiatePasswordReset, { body: { email: 'initiate-log-send-fails@test.local' } });
|
|
212
|
-
expect(failed
|
|
253
|
+
expect(failed).toEqual({ status: 200, body: GENERIC });
|
|
213
254
|
} finally {
|
|
214
255
|
send.mockRestore();
|
|
215
256
|
}
|
|
@@ -247,4 +288,169 @@ describe('initiatePasswordReset route', () => {
|
|
|
247
288
|
expect(MailSink.get().list()).toHaveLength(0);
|
|
248
289
|
expect(await tokensByEmail()).toEqual(tokensBefore);
|
|
249
290
|
});
|
|
291
|
+
|
|
292
|
+
it('one answer whatever happens behind it: mailed, no account, too soon, over a window, a failed send', async () => {
|
|
293
|
+
await testEnv.createUser({ name: 'Reset User', email: 'one-answer@test.local' });
|
|
294
|
+
await testEnv.createUser({ name: 'Reset User', email: 'one-answer-fails@test.local' });
|
|
295
|
+
const answers: RouteOutcome[] = [];
|
|
296
|
+
const ask = async (email: string, ip?: string) =>
|
|
297
|
+
answers.push(await invoke(initiatePasswordReset, { ...fromAddress(ip), body: { email } }));
|
|
298
|
+
|
|
299
|
+
await ask('one-answer@test.local'); // mailed
|
|
300
|
+
await ask('one-answer-nobody@test.local'); // no account
|
|
301
|
+
await ask('one-answer@test.local'); // too soon
|
|
302
|
+
await ask('one-answer@test.local');
|
|
303
|
+
await ask('one-answer@test.local'); // over the address window
|
|
304
|
+
for (let i = 0; i < 11; i++) {
|
|
305
|
+
await ask(`one-answer-sweep-${i}@test.local`, '198.18.0.1'); // the eleventh is over the client window
|
|
306
|
+
}
|
|
307
|
+
const send = jest
|
|
308
|
+
.spyOn(EmailSender.prototype, 'sendEmail')
|
|
309
|
+
.mockRejectedValueOnce(new Error('Failed to send email'));
|
|
310
|
+
try {
|
|
311
|
+
await ask('one-answer-fails@test.local'); // a failed send
|
|
312
|
+
} finally {
|
|
313
|
+
send.mockRestore();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
expect(answers).toHaveLength(17);
|
|
317
|
+
expect(new Set(answers.map((answer) => JSON.stringify(answer)))).toEqual(
|
|
318
|
+
new Set([JSON.stringify({ status: 200, body: GENERIC })])
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('the answer goes out before the account is looked up, a token minted or a mail sent — its timing says nothing', async () => {
|
|
323
|
+
await testEnv.createUser({ name: 'Reset User', email: 'answer-first@test.local' });
|
|
324
|
+
const events: string[] = [];
|
|
325
|
+
const realMint = PasswordResetToken.prototype.mint;
|
|
326
|
+
const mint = jest.spyOn(PasswordResetToken.prototype, 'mint').mockImplementation(async function (
|
|
327
|
+
this: PasswordResetToken,
|
|
328
|
+
...args: Parameters<PasswordResetToken['mint']>
|
|
329
|
+
) {
|
|
330
|
+
events.push('minted');
|
|
331
|
+
return await realMint.apply(this, args);
|
|
332
|
+
});
|
|
333
|
+
const send = jest.spyOn(EmailSender.prototype, 'sendEmail').mockImplementation(async () => {
|
|
334
|
+
events.push('mailed');
|
|
335
|
+
});
|
|
336
|
+
try {
|
|
337
|
+
await invoke(initiatePasswordReset, { body: { email: 'answer-first@test.local' } }, events);
|
|
338
|
+
} finally {
|
|
339
|
+
mint.mockRestore();
|
|
340
|
+
send.mockRestore();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
expect(events).toEqual(['answered', 'minted', 'mailed']);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('per address: the fourth request inside the hour mails nothing, even past the five-minute gap', async () => {
|
|
347
|
+
await testEnv.createUser({ name: 'Reset User', email: 'address-window@test.local' });
|
|
348
|
+
for (let i = 0; i < 3; i++) {
|
|
349
|
+
await invoke(initiatePasswordReset, { body: { email: 'Address-Window@test.local' } });
|
|
350
|
+
await ageLastMint('address-window@test.local');
|
|
351
|
+
}
|
|
352
|
+
expect(MailSink.get().list()).toHaveLength(3);
|
|
353
|
+
const armed = await userRow('address-window@test.local');
|
|
354
|
+
|
|
355
|
+
const fourth = await invoke(initiatePasswordReset, { body: { email: 'address-window@test.local' } });
|
|
356
|
+
|
|
357
|
+
expect(fourth).toEqual({ status: 200, body: GENERIC });
|
|
358
|
+
expect(MailSink.get().list()).toHaveLength(3);
|
|
359
|
+
expect((await userRow('address-window@test.local')).passwordResetToken).toBe(armed.passwordResetToken);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('per client address: after ten requests the eleventh mails nothing, even for an account; another address still gets its link', async () => {
|
|
363
|
+
await testEnv.createUser({ name: 'Reset User', email: 'client-window@test.local' });
|
|
364
|
+
for (let i = 0; i < 10; i++) {
|
|
365
|
+
await invoke(initiatePasswordReset, {
|
|
366
|
+
...fromAddress('198.18.1.1'),
|
|
367
|
+
body: { email: `client-window-${i}@test.local` },
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const eleventh = await invoke(initiatePasswordReset, {
|
|
372
|
+
...fromAddress('198.18.1.1'),
|
|
373
|
+
body: { email: 'client-window@test.local' },
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(eleventh).toEqual({ status: 200, body: GENERIC });
|
|
377
|
+
expect(MailSink.get().list()).toHaveLength(0);
|
|
378
|
+
|
|
379
|
+
await invoke(initiatePasswordReset, { ...fromAddress('198.18.1.2'), body: { email: 'client-window@test.local' } });
|
|
380
|
+
expect(
|
|
381
|
+
MailSink.get()
|
|
382
|
+
.list()
|
|
383
|
+
.map((mail) => mail.to)
|
|
384
|
+
).toEqual([['client-window@test.local']]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('every request is one outcome line with the account digest and the coarse IP hash — never the address', async () => {
|
|
388
|
+
await testEnv.createUser({ name: 'Reset User', email: 'reset.privacy.person@test.local' });
|
|
389
|
+
|
|
390
|
+
const log = await LogCapture.during(async () => {
|
|
391
|
+
const ask = (email: string) => invoke(initiatePasswordReset, { ...fromAddress('198.18.2.7'), body: { email } });
|
|
392
|
+
await ask('reset.privacy.person@test.local'); // mailed
|
|
393
|
+
await ask('Reset.Privacy.Person@TEST.local'); // too soon
|
|
394
|
+
await ask('RESET.PRIVACY.PERSON@test.local'); // too soon
|
|
395
|
+
await ask('reset.privacy.person@test.local'); // over the address window
|
|
396
|
+
await ask('reset.privacy.nobody@test.local'); // no account
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const markers = [
|
|
400
|
+
'Password reset link mailed',
|
|
401
|
+
'Password reset requested too soon for user',
|
|
402
|
+
'Password reset throttled',
|
|
403
|
+
'Password reset requested for non-existent user',
|
|
404
|
+
];
|
|
405
|
+
const accounts = markers.map((marker) => fieldsOn(log, marker, 'account'));
|
|
406
|
+
expect(accounts.map((values) => values.length)).toEqual([1, 2, 1, 1]);
|
|
407
|
+
const person = accounts[0][0];
|
|
408
|
+
expect(person).toMatch(/^[0-9a-f]{16}$/);
|
|
409
|
+
expect([...accounts[1], ...accounts[2]]).toEqual([person, person, person]);
|
|
410
|
+
expect(accounts[3][0]).toMatch(/^[0-9a-f]{16}$/);
|
|
411
|
+
expect(accounts[3][0]).not.toBe(person);
|
|
412
|
+
|
|
413
|
+
const ips = ([] as string[]).concat(...markers.map((marker) => fieldsOn(log, marker, 'ip')));
|
|
414
|
+
expect(ips).toHaveLength(5);
|
|
415
|
+
expect(ips[0]).toMatch(/^[0-9a-f]{16}$/);
|
|
416
|
+
expect(new Set(ips)).toEqual(new Set([ips[0]]));
|
|
417
|
+
|
|
418
|
+
const text = plain(log.text).toLowerCase();
|
|
419
|
+
for (const fragment of ['reset.privacy.person', 'reset.privacy.nobody', 'reset.privacy', '198.18.2.']) {
|
|
420
|
+
expect(text).not.toContain(fragment);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("a reset link redeemed while wrong guesses hold the account's sign-in window shut lets the new password in at once", async () => {
|
|
425
|
+
await testEnv.createUser({ name: 'Reset User', email: 'reset-while-shut@test.local' });
|
|
426
|
+
const signIn = async (password: string) => {
|
|
427
|
+
let sent: any;
|
|
428
|
+
const response: any = {
|
|
429
|
+
send: (body: any) => {
|
|
430
|
+
sent = body;
|
|
431
|
+
},
|
|
432
|
+
status: () => response,
|
|
433
|
+
};
|
|
434
|
+
const { request } = await createPassportRequest({
|
|
435
|
+
body: { email: 'reset-while-shut@test.local', password },
|
|
436
|
+
...fromAddress(),
|
|
437
|
+
});
|
|
438
|
+
await login.onRequest(request, response);
|
|
439
|
+
return { sent, signedInAs: request.session.passport?.user as string | undefined };
|
|
440
|
+
};
|
|
441
|
+
for (let i = 0; i < 10; i++) {
|
|
442
|
+
await signIn('wrong guess');
|
|
443
|
+
}
|
|
444
|
+
expect((await signIn('wrong guess')).sent).toEqual({ error: 'Too many attempts. Try again in a few minutes.' });
|
|
445
|
+
|
|
446
|
+
await invoke(initiatePasswordReset, { body: { email: 'reset-while-shut@test.local' } });
|
|
447
|
+
const redeemed = await invoke(executePasswordReset, {
|
|
448
|
+
body: { token: mailedToken(), newPassword: 'a brand new password' },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(redeemed.status).toBe(200);
|
|
452
|
+
const after = await signIn('a brand new password');
|
|
453
|
+
expect(after.sent).toEqual({});
|
|
454
|
+
expect(after.signedInAs).toBe('reset-while-shut@test.local');
|
|
455
|
+
});
|
|
250
456
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LogCapture } from './LogCapture';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The log capture's address reader — what `expect(log.addresses).toEqual([])` stands on. It reads
|
|
5
|
+
* an address in every spelling a log line can carry: bare, inside a name-and-address form, and
|
|
6
|
+
* URL-encoded (`%40` for the `@`, `%2B` for a `+`) as a logged request path carries it. A digest
|
|
7
|
+
* or a bare domain is not an address.
|
|
8
|
+
*/
|
|
9
|
+
describe('LogCapture.addresses', () => {
|
|
10
|
+
it('reads an address bare, in a name-and-address form, and URL-encoded in a logged path', async () => {
|
|
11
|
+
const log = await LogCapture.during(async () => {
|
|
12
|
+
console.log('Email sent successfully to ada@example.com');
|
|
13
|
+
console.info('Invite from "Grace" <grace.hopper@example.org>');
|
|
14
|
+
console.info('Started /dev/login?email=ada%2Blane%40example.com');
|
|
15
|
+
console.warn('Sign-in refused', { account: '0123456789abcdef', domain: 'example.com' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(log.addresses).toEqual(['ada@example.com', 'grace.hopper@example.org', 'ada%2Blane%40example.com']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('reads nothing from a log that names people by digest and domain only', async () => {
|
|
22
|
+
const log = await LogCapture.during(async () => {
|
|
23
|
+
console.info('Email sent — 1 recipient (example.com)', { recipients: ['0123456789abcdef'] });
|
|
24
|
+
console.info('Finished /user/login 200');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(log.addresses).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/test/LogCapture.ts
CHANGED
|
@@ -9,6 +9,12 @@ import { createHash } from 'crypto';
|
|
|
9
9
|
export class LogCapture {
|
|
10
10
|
/** How many leading characters of a token count as the token showing up in a log. */
|
|
11
11
|
private static readonly TOKEN_PREFIX_LENGTH = 12;
|
|
12
|
+
/**
|
|
13
|
+
* An e-mail address, as the house's log check reads one: bare, or URL-encoded (`%40` for the `@`)
|
|
14
|
+
* as a logged request path carries it — a check that read only the bare spelling missed the one
|
|
15
|
+
* place an address is known to reach a log, the request log's URL.
|
|
16
|
+
*/
|
|
17
|
+
private static readonly ADDRESS_SHAPE = /[A-Za-z0-9._%+-]+(?:@|%40)[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
12
18
|
|
|
13
19
|
private constructor(readonly lines: string[]) {}
|
|
14
20
|
|
|
@@ -38,6 +44,16 @@ export class LogCapture {
|
|
|
38
44
|
return this.lines.join('\n');
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
/** Every e-mail address the log holds, in order of appearance — a log names people by their digests, never by address. */
|
|
48
|
+
get addresses(): string[] {
|
|
49
|
+
return this.text.match(LogCapture.ADDRESS_SHAPE) ?? [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The capture narrowed to the writes that contain `text` — one door's lines, read with the same readers. */
|
|
53
|
+
linesContaining(text: string): LogCapture {
|
|
54
|
+
return new LogCapture(this.lines.filter((line) => line.includes(text)));
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* Fails unless the log is free of `token` in every form that would let a log reader act on it
|
|
43
59
|
* or tie a line to a row: the token, the SHA-256 digest a row stores for it, and the token's
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { tables } from '@proteinjs/user';
|
|
4
|
+
import { login } from '../src/routes/login';
|
|
5
|
+
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
6
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
7
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
8
|
+
import { LogCapture } from './LogCapture';
|
|
9
|
+
|
|
10
|
+
const testEnv = new UserServerTestEnvironment();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `POST /user/login`, end to end against the Spanner emulator, outcomes only (what the door
|
|
14
|
+
* answers, whether a session was bound, what the log holds):
|
|
15
|
+
* - per account: ten refused passwords in the window, then every try — the right password too —
|
|
16
|
+
* answers "Too many attempts. Try again in a few minutes." and binds no session; an address
|
|
17
|
+
* with no account walks the same steps with the same answers; a success clears the count;
|
|
18
|
+
* - per client address: fifty tries that are not successes, then the same answer for any account;
|
|
19
|
+
* successes never count, however many;
|
|
20
|
+
* - a blank submission counts toward the address window, never an account's;
|
|
21
|
+
* - the throttled answer takes as long as a refused password, and a refusal for an address with
|
|
22
|
+
* no account takes as long as one for an account (no timing tell either way);
|
|
23
|
+
* - every refusal is a "Sign-in refused" line and every throttled answer its own "Sign-in
|
|
24
|
+
* throttled" line, each carrying the account digest and the coarse IP hash — one digest for
|
|
25
|
+
* one address however it was typed — and never the address;
|
|
26
|
+
* - tries in flight at the same moment cannot all pass the account's window together;
|
|
27
|
+
* - a refusal for an account still in the legacy password format takes as long as any other;
|
|
28
|
+
* - a field that is not text is a blank one: the door answers, it never throws.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const WRONG = 'User name or password incorrect';
|
|
32
|
+
const THROTTLED = 'Too many attempts. Try again in a few minutes.';
|
|
33
|
+
const BLANK = 'Email and password cannot be blank';
|
|
34
|
+
const RIGHT_PASSWORD = 'the right horse battery';
|
|
35
|
+
|
|
36
|
+
type Attempt = { sent: any; signedInAs?: string };
|
|
37
|
+
|
|
38
|
+
/** One sign-in attempt from `ip` as the server sees it with no proxy in front (the dev shape). */
|
|
39
|
+
const attempt = async (ip: string, email: string, password: string): Promise<Attempt> => {
|
|
40
|
+
let sent: any;
|
|
41
|
+
const response: any = {
|
|
42
|
+
send: (body: any) => {
|
|
43
|
+
sent = body;
|
|
44
|
+
},
|
|
45
|
+
status: () => response,
|
|
46
|
+
};
|
|
47
|
+
const { request } = await createPassportRequest({
|
|
48
|
+
body: { email, password },
|
|
49
|
+
headers: {},
|
|
50
|
+
socket: { remoteAddress: ip },
|
|
51
|
+
app: { get: () => false },
|
|
52
|
+
});
|
|
53
|
+
await login.onRequest(request, response);
|
|
54
|
+
return { sent, signedInAs: request.session.passport?.user };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const errors = async (count: number, ip: (i: number) => string, email: (i: number) => string, password: string) => {
|
|
58
|
+
const answers: string[] = [];
|
|
59
|
+
for (let i = 0; i < count; i++) {
|
|
60
|
+
answers.push((await attempt(ip(i), email(i), password)).sent?.error);
|
|
61
|
+
}
|
|
62
|
+
return answers;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const createAccount = async (email: string) => {
|
|
66
|
+
const user = await testEnv.createUser({ name: 'Throttle User', email });
|
|
67
|
+
await getDbAsSystem().update(tables.User, { id: user.id, password: await new PasswordHasher().hash(RIGHT_PASSWORD) });
|
|
68
|
+
return user;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const elapsedMs = async (run: () => Promise<unknown>): Promise<number> => {
|
|
72
|
+
const start = process.hrtime.bigint();
|
|
73
|
+
await run();
|
|
74
|
+
return Number(process.hrtime.bigint() - start) / 1e6;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const median = (values: number[]) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
|
78
|
+
|
|
79
|
+
/** Log text without terminal colour codes. */
|
|
80
|
+
const plain = (text: string) => text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '');
|
|
81
|
+
|
|
82
|
+
/** The `field: '<16 hex>'` values on the lines that contain `marker`. */
|
|
83
|
+
const fieldsOn = (log: LogCapture, marker: string, field: string): string[] =>
|
|
84
|
+
log.lines
|
|
85
|
+
.map(plain)
|
|
86
|
+
.filter((line) => line.includes(marker))
|
|
87
|
+
.map((line) => new RegExp(`${field}: '([0-9a-f]{16})'`).exec(line)?.[1] ?? 'missing');
|
|
88
|
+
|
|
89
|
+
describe('login route throttle', () => {
|
|
90
|
+
const originalSecret = process.env.SESSION_SECRET;
|
|
91
|
+
|
|
92
|
+
beforeAll(async () => {
|
|
93
|
+
process.env.SESSION_SECRET = 'login-throttle-test-secret';
|
|
94
|
+
await testEnv.beforeAll();
|
|
95
|
+
}, 120000);
|
|
96
|
+
|
|
97
|
+
afterAll(async () => {
|
|
98
|
+
if (originalSecret === undefined) {
|
|
99
|
+
delete process.env.SESSION_SECRET;
|
|
100
|
+
} else {
|
|
101
|
+
process.env.SESSION_SECRET = originalSecret;
|
|
102
|
+
}
|
|
103
|
+
await testEnv.afterAll();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('ten wrong passwords are refused as today; the eleventh try — even with the right password — is "Too many attempts…" and no session', async () => {
|
|
107
|
+
await createAccount('throttle-known@test.local');
|
|
108
|
+
// Spread over many client addresses, and typed in varying case: the ACCOUNT window catches it.
|
|
109
|
+
const ip = (i: number) => `198.51.100.${i + 1}`;
|
|
110
|
+
const email = (i: number) => (i % 2 ? 'Throttle-Known@Test.local' : 'throttle-known@test.local');
|
|
111
|
+
|
|
112
|
+
expect(await errors(10, ip, email, 'wrong guess')).toEqual(Array(10).fill(WRONG));
|
|
113
|
+
|
|
114
|
+
expect((await attempt('198.51.100.50', 'throttle-known@test.local', 'wrong guess')).sent).toEqual({
|
|
115
|
+
error: THROTTLED,
|
|
116
|
+
});
|
|
117
|
+
const right = await attempt('198.51.100.51', 'throttle-known@test.local', RIGHT_PASSWORD);
|
|
118
|
+
expect(right.sent).toEqual({ error: THROTTLED });
|
|
119
|
+
expect(right.signedInAs).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('an address with no account walks the same steps with the same answers', async () => {
|
|
123
|
+
const ip = (i: number) => `198.51.101.${i + 1}`;
|
|
124
|
+
const email = () => 'throttle-nobody@test.local';
|
|
125
|
+
|
|
126
|
+
expect(await errors(10, ip, email, 'wrong guess')).toEqual(Array(10).fill(WRONG));
|
|
127
|
+
expect((await attempt('198.51.101.50', 'throttle-nobody@test.local', 'wrong guess')).sent).toEqual({
|
|
128
|
+
error: THROTTLED,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("a success clears the account's count: after it, ten more wrong tries are refused as usual before the throttle", async () => {
|
|
133
|
+
await createAccount('throttle-cleared@test.local');
|
|
134
|
+
const ip = (i: number) => `198.51.102.${i + 1}`;
|
|
135
|
+
const email = () => 'throttle-cleared@test.local';
|
|
136
|
+
expect(await errors(9, ip, email, 'wrong guess')).toEqual(Array(9).fill(WRONG));
|
|
137
|
+
|
|
138
|
+
const success = await attempt('198.51.102.100', 'throttle-cleared@test.local', RIGHT_PASSWORD);
|
|
139
|
+
expect(success.sent).toEqual({});
|
|
140
|
+
expect(success.signedInAs).toBe('throttle-cleared@test.local');
|
|
141
|
+
|
|
142
|
+
const again = (i: number) => `198.51.102.${i + 120}`;
|
|
143
|
+
expect(await errors(10, again, email, 'wrong guess')).toEqual(Array(10).fill(WRONG));
|
|
144
|
+
expect((await attempt('198.51.102.200', 'throttle-cleared@test.local', 'wrong guess')).sent).toEqual({
|
|
145
|
+
error: THROTTLED,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('fifty wrong tries from one client address across fifty accounts, then the same answer for any account; another address is unaffected', async () => {
|
|
150
|
+
await createAccount('throttle-ip-target@test.local');
|
|
151
|
+
const ip = () => '203.0.113.20';
|
|
152
|
+
const email = (i: number) => `throttle-ip-${i}@test.local`;
|
|
153
|
+
expect(await errors(50, ip, email, 'wrong guess')).toEqual(Array(50).fill(WRONG));
|
|
154
|
+
|
|
155
|
+
const right = await attempt('203.0.113.20', 'throttle-ip-target@test.local', RIGHT_PASSWORD);
|
|
156
|
+
expect(right.sent).toEqual({ error: THROTTLED });
|
|
157
|
+
expect(right.signedInAs).toBeUndefined();
|
|
158
|
+
|
|
159
|
+
const elsewhere = await attempt('203.0.113.21', 'throttle-ip-target@test.local', RIGHT_PASSWORD);
|
|
160
|
+
expect(elsewhere.sent).toEqual({});
|
|
161
|
+
expect(elsewhere.signedInAs).toBe('throttle-ip-target@test.local');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('successes never count against the address: thirty sign-ins from one address, then fifty wrong tries are still judged before the window shuts', async () => {
|
|
165
|
+
await createAccount('throttle-office@test.local');
|
|
166
|
+
const ip = () => '203.0.113.30';
|
|
167
|
+
for (let i = 0; i < 30; i++) {
|
|
168
|
+
const signedIn = await attempt(ip(), 'throttle-office@test.local', RIGHT_PASSWORD);
|
|
169
|
+
expect(signedIn.sent).toEqual({});
|
|
170
|
+
expect(signedIn.signedInAs).toBe('throttle-office@test.local');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const email = (i: number) => `throttle-office-guess-${i}@test.local`;
|
|
174
|
+
expect(await errors(50, ip, email, 'wrong guess')).toEqual(Array(50).fill(WRONG));
|
|
175
|
+
expect((await attempt(ip(), 'throttle-office@test.local', RIGHT_PASSWORD)).sent).toEqual({ error: THROTTLED });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("blank submissions count toward the client address's window, never an account's", async () => {
|
|
179
|
+
await createAccount('throttle-blank@test.local');
|
|
180
|
+
// Fifteen blank passwords for one account, from fifteen addresses: the account is not throttled.
|
|
181
|
+
const ip = (i: number) => `192.0.2.${i + 1}`;
|
|
182
|
+
expect(await errors(15, ip, () => 'throttle-blank@test.local', '')).toEqual(Array(15).fill(BLANK));
|
|
183
|
+
expect((await attempt('192.0.2.100', 'throttle-blank@test.local', 'wrong guess')).sent).toEqual({ error: WRONG });
|
|
184
|
+
|
|
185
|
+
// Fifty blank submissions from one address: its fifty-first try is throttled.
|
|
186
|
+
expect(
|
|
187
|
+
await errors(
|
|
188
|
+
50,
|
|
189
|
+
() => '192.0.2.200',
|
|
190
|
+
() => '',
|
|
191
|
+
''
|
|
192
|
+
)
|
|
193
|
+
).toEqual(Array(50).fill(BLANK));
|
|
194
|
+
expect((await attempt('192.0.2.200', 'throttle-blank@test.local', RIGHT_PASSWORD)).sent).toEqual({
|
|
195
|
+
error: THROTTLED,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('no timing tell: the throttled answer takes as long as a refused password, and an address with no account is refused as slowly as one with', async () => {
|
|
200
|
+
await createAccount('throttle-timing-open@test.local');
|
|
201
|
+
await createAccount('throttle-timing-shut@test.local');
|
|
202
|
+
// Push one account over its window (from addresses the samples never use).
|
|
203
|
+
await errors(
|
|
204
|
+
10,
|
|
205
|
+
(i) => `100.64.0.${i + 1}`,
|
|
206
|
+
() => 'throttle-timing-shut@test.local',
|
|
207
|
+
'wrong guess'
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const refused: number[] = [];
|
|
211
|
+
const unknown: number[] = [];
|
|
212
|
+
const throttled: number[] = [];
|
|
213
|
+
// Interleaved, so machine load drifts over all three series alike; seven samples each keeps
|
|
214
|
+
// the open account under its own window.
|
|
215
|
+
for (let i = 0; i < 7; i++) {
|
|
216
|
+
refused.push(
|
|
217
|
+
await elapsedMs(() => attempt(`100.64.1.${i + 1}`, 'throttle-timing-open@test.local', 'wrong guess'))
|
|
218
|
+
);
|
|
219
|
+
unknown.push(
|
|
220
|
+
await elapsedMs(() => attempt(`100.64.2.${i + 1}`, 'throttle-timing-none@test.local', 'wrong guess'))
|
|
221
|
+
);
|
|
222
|
+
throttled.push(
|
|
223
|
+
await elapsedMs(() => attempt(`100.64.3.${i + 1}`, 'throttle-timing-shut@test.local', 'wrong guess'))
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const [r, u, t] = [median(refused), median(unknown), median(throttled)];
|
|
228
|
+
// A refusal costs one database read and one password verification (argon2, tens of ms).
|
|
229
|
+
// The tolerance is wide on purpose — a throttled answer or an unknown address that SKIPPED
|
|
230
|
+
// the verification lands near zero or at the read alone, far outside it.
|
|
231
|
+
expect(t / r).toBeGreaterThan(0.6);
|
|
232
|
+
expect(t / r).toBeLessThan(1.6);
|
|
233
|
+
expect(u / r).toBeGreaterThan(0.6);
|
|
234
|
+
expect(u / r).toBeLessThan(1.6);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('every refusal and every throttled answer is its own line with the account digest and the coarse IP hash — one digest per address however typed, never the address', async () => {
|
|
238
|
+
await createAccount('throttle.privacy.person@test.local');
|
|
239
|
+
const spellings = [
|
|
240
|
+
'throttle.privacy.person@test.local',
|
|
241
|
+
'Throttle.Privacy.Person@TEST.local',
|
|
242
|
+
' THROTTLE.PRIVACY.PERSON@test.local',
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const log = await LogCapture.during(async () => {
|
|
246
|
+
for (let i = 0; i < 11; i++) {
|
|
247
|
+
await attempt('198.51.103.7', spellings[i % 3], 'wrong guess');
|
|
248
|
+
}
|
|
249
|
+
await attempt('198.51.103.8', 'throttle.privacy.other@test.local', 'wrong guess');
|
|
250
|
+
await attempt('198.51.103.8', '', '');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const refusedAccounts = fieldsOn(log, 'Sign-in refused', 'account');
|
|
254
|
+
const throttledAccounts = fieldsOn(log, 'Sign-in throttled', 'account');
|
|
255
|
+
// Ten for the person, one for the other address, one for the blank submission (no account).
|
|
256
|
+
expect(refusedAccounts).toHaveLength(12);
|
|
257
|
+
expect(throttledAccounts).toHaveLength(1);
|
|
258
|
+
const person = refusedAccounts[0];
|
|
259
|
+
expect(person).toMatch(/^[0-9a-f]{16}$/);
|
|
260
|
+
expect(refusedAccounts.slice(0, 10)).toEqual(Array(10).fill(person));
|
|
261
|
+
expect(throttledAccounts).toEqual([person]);
|
|
262
|
+
expect(refusedAccounts[10]).toMatch(/^[0-9a-f]{16}$/);
|
|
263
|
+
expect(refusedAccounts[10]).not.toBe(person);
|
|
264
|
+
expect(refusedAccounts[11]).toBe('missing');
|
|
265
|
+
|
|
266
|
+
const ips = [...fieldsOn(log, 'Sign-in refused', 'ip'), ...fieldsOn(log, 'Sign-in throttled', 'ip')];
|
|
267
|
+
expect(ips).toHaveLength(13);
|
|
268
|
+
expect(new Set(ips.slice(0, 10)).size).toBe(1);
|
|
269
|
+
expect(ips[10]).not.toBe(ips[0]);
|
|
270
|
+
expect(ips[12]).toBe(ips[0]);
|
|
271
|
+
|
|
272
|
+
const text = plain(log.text).toLowerCase();
|
|
273
|
+
for (const fragment of ['throttle.privacy.person', 'throttle.privacy.other', 'throttle.privacy', '198.51.103.']) {
|
|
274
|
+
expect(text).not.toContain(fragment);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('thirty wrong passwords for one account fired at once from thirty addresses: at most ten are judged, the rest are throttled', async () => {
|
|
279
|
+
await createAccount('throttle-burst@test.local');
|
|
280
|
+
|
|
281
|
+
const answers = await Promise.all(
|
|
282
|
+
Array.from({ length: 30 }, (_, i) => attempt(`198.51.104.${i + 1}`, 'throttle-burst@test.local', 'wrong guess'))
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const judged = answers.filter((answer) => answer.sent?.error === WRONG).length;
|
|
286
|
+
const throttled = answers.filter((answer) => answer.sent?.error === THROTTLED).length;
|
|
287
|
+
expect(judged).toBeLessThanOrEqual(10);
|
|
288
|
+
expect(judged + throttled).toBe(30);
|
|
289
|
+
expect((await attempt('198.51.104.50', 'throttle-burst@test.local', RIGHT_PASSWORD)).sent).toEqual({
|
|
290
|
+
error: THROTTLED,
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('a refusal for an account still in the legacy sha256 format takes as long as one in the current format', async () => {
|
|
295
|
+
await createAccount('throttle-timing-current@test.local');
|
|
296
|
+
const legacy = await testEnv.createUser({ name: 'Throttle User', email: 'throttle-timing-legacy@test.local' });
|
|
297
|
+
await getDbAsSystem().update(tables.User, {
|
|
298
|
+
id: legacy.id,
|
|
299
|
+
password: createHash('sha256').update(RIGHT_PASSWORD).digest('hex'),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const current: number[] = [];
|
|
303
|
+
const legacyRow: number[] = [];
|
|
304
|
+
for (let i = 0; i < 7; i++) {
|
|
305
|
+
current.push(
|
|
306
|
+
await elapsedMs(() => attempt(`100.64.4.${i + 1}`, 'throttle-timing-current@test.local', 'wrong guess'))
|
|
307
|
+
);
|
|
308
|
+
legacyRow.push(
|
|
309
|
+
await elapsedMs(() => attempt(`100.64.5.${i + 1}`, 'throttle-timing-legacy@test.local', 'wrong guess'))
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// A legacy row verifies in microseconds; without the stand-in its refusal lands far under 0.6.
|
|
314
|
+
const ratio = median(legacyRow) / median(current);
|
|
315
|
+
expect(ratio).toBeGreaterThan(0.6);
|
|
316
|
+
expect(ratio).toBeLessThan(1.6);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('a field that is not text is a blank one: the door answers and never throws', async () => {
|
|
320
|
+
let sent: any;
|
|
321
|
+
const response: any = {
|
|
322
|
+
send: (body: any) => {
|
|
323
|
+
sent = body;
|
|
324
|
+
},
|
|
325
|
+
status: () => response,
|
|
326
|
+
};
|
|
327
|
+
const { request } = await createPassportRequest({
|
|
328
|
+
body: { email: 123, password: { not: 'text' } },
|
|
329
|
+
headers: {},
|
|
330
|
+
socket: { remoteAddress: '198.51.105.1' },
|
|
331
|
+
app: { get: () => false },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await login.onRequest(request, response);
|
|
335
|
+
|
|
336
|
+
expect(sent).toEqual({ error: BLANK });
|
|
337
|
+
expect(request.session.passport?.user).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
});
|