@proteinjs/user-server 1.22.1 → 1.22.2

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +9 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/src/authentication/PasswordHasher.d.ts +9 -0
  9. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
  10. package/dist/src/authentication/PasswordHasher.js +25 -0
  11. package/dist/src/authentication/PasswordHasher.js.map +1 -1
  12. package/dist/src/authentication/authenticate.d.ts +15 -0
  13. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  14. package/dist/src/authentication/authenticate.js +55 -20
  15. package/dist/src/authentication/authenticate.js.map +1 -1
  16. package/dist/src/routes/executePasswordReset.d.ts +4 -0
  17. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  18. package/dist/src/routes/executePasswordReset.js +7 -0
  19. package/dist/src/routes/executePasswordReset.js.map +1 -1
  20. package/dist/src/routes/initiatePasswordReset.d.ts +7 -0
  21. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  22. package/dist/src/routes/initiatePasswordReset.js +28 -10
  23. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  24. package/dist/src/routes/login.d.ts +8 -0
  25. package/dist/src/routes/login.d.ts.map +1 -1
  26. package/dist/src/routes/login.js +63 -22
  27. package/dist/src/routes/login.js.map +1 -1
  28. package/dist/src/throttle/ClientAddress.d.ts +35 -0
  29. package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
  30. package/dist/src/throttle/ClientAddress.js +48 -0
  31. package/dist/src/throttle/ClientAddress.js.map +1 -0
  32. package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
  33. package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
  34. package/dist/src/throttle/PasswordResetThrottle.js +59 -0
  35. package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
  36. package/dist/src/throttle/RequestDigests.d.ts +42 -0
  37. package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
  38. package/dist/src/throttle/RequestDigests.js +96 -0
  39. package/dist/src/throttle/RequestDigests.js.map +1 -0
  40. package/dist/src/throttle/SignInThrottle.d.ts +67 -0
  41. package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
  42. package/dist/src/throttle/SignInThrottle.js +91 -0
  43. package/dist/src/throttle/SignInThrottle.js.map +1 -0
  44. package/dist/src/throttle/SlidingWindow.d.ts +51 -0
  45. package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
  46. package/dist/src/throttle/SlidingWindow.js +85 -0
  47. package/dist/src/throttle/SlidingWindow.js.map +1 -0
  48. package/dist/test/ClientAddress.test.d.ts +2 -0
  49. package/dist/test/ClientAddress.test.d.ts.map +1 -0
  50. package/dist/test/ClientAddress.test.js +51 -0
  51. package/dist/test/ClientAddress.test.js.map +1 -0
  52. package/dist/test/InitiatePasswordReset.test.js +373 -5
  53. package/dist/test/InitiatePasswordReset.test.js.map +1 -1
  54. package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
  55. package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
  56. package/dist/test/LoginThrottle.integration.test.js +616 -0
  57. package/dist/test/LoginThrottle.integration.test.js.map +1 -0
  58. package/dist/test/RequestDigests.test.d.ts +2 -0
  59. package/dist/test/RequestDigests.test.d.ts.map +1 -0
  60. package/dist/test/RequestDigests.test.js +73 -0
  61. package/dist/test/RequestDigests.test.js.map +1 -0
  62. package/dist/test/SlidingWindow.test.d.ts +2 -0
  63. package/dist/test/SlidingWindow.test.d.ts.map +1 -0
  64. package/dist/test/SlidingWindow.test.js +72 -0
  65. package/dist/test/SlidingWindow.test.js.map +1 -0
  66. package/generated/index.ts +1 -1
  67. package/index.ts +5 -0
  68. package/package.json +2 -2
  69. package/src/authentication/PasswordHasher.ts +18 -1
  70. package/src/authentication/authenticate.ts +36 -9
  71. package/src/routes/executePasswordReset.ts +7 -0
  72. package/src/routes/initiatePasswordReset.ts +29 -11
  73. package/src/routes/login.ts +39 -8
  74. package/src/throttle/ClientAddress.ts +47 -0
  75. package/src/throttle/PasswordResetThrottle.ts +62 -0
  76. package/src/throttle/RequestDigests.ts +97 -0
  77. package/src/throttle/SignInThrottle.ts +98 -0
  78. package/src/throttle/SlidingWindow.ts +90 -0
  79. package/test/ClientAddress.test.ts +54 -0
  80. package/test/InitiatePasswordReset.test.ts +212 -6
  81. package/test/LoginThrottle.integration.test.ts +339 -0
  82. package/test/RequestDigests.test.ts +81 -0
  83. package/test/SlidingWindow.test.ts +85 -0
  84. 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
- const invoke = async (route: typeof initiatePasswordReset, request: Record<string, unknown>): Promise<RouteOutcome> => {
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 an account with that email exists, we have sent a password reset link.' };
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.status).toBe(500);
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.status).toBe(500);
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,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
+ });