@proteinjs/user-server 1.22.0 → 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 (133) hide show
  1. package/CHANGELOG.md +24 -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 +10 -2
  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/PasswordResetToken.d.ts +43 -12
  13. package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
  14. package/dist/src/authentication/PasswordResetToken.js +86 -21
  15. package/dist/src/authentication/PasswordResetToken.js.map +1 -1
  16. package/dist/src/authentication/authenticate.d.ts +15 -0
  17. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  18. package/dist/src/authentication/authenticate.js +55 -20
  19. package/dist/src/authentication/authenticate.js.map +1 -1
  20. package/dist/src/routes/devLogin.d.ts +1 -1
  21. package/dist/src/routes/devLogin.js +1 -1
  22. package/dist/src/routes/executePasswordReset.d.ts +6 -1
  23. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  24. package/dist/src/routes/executePasswordReset.js +16 -7
  25. package/dist/src/routes/executePasswordReset.js.map +1 -1
  26. package/dist/src/routes/initiatePasswordReset.d.ts +10 -2
  27. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  28. package/dist/src/routes/initiatePasswordReset.js +55 -34
  29. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  30. package/dist/src/routes/login.d.ts +8 -0
  31. package/dist/src/routes/login.d.ts.map +1 -1
  32. package/dist/src/routes/login.js +63 -22
  33. package/dist/src/routes/login.js.map +1 -1
  34. package/dist/src/throttle/ClientAddress.d.ts +35 -0
  35. package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
  36. package/dist/src/throttle/ClientAddress.js +48 -0
  37. package/dist/src/throttle/ClientAddress.js.map +1 -0
  38. package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
  39. package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
  40. package/dist/src/throttle/PasswordResetThrottle.js +59 -0
  41. package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
  42. package/dist/src/throttle/RequestDigests.d.ts +42 -0
  43. package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
  44. package/dist/src/throttle/RequestDigests.js +96 -0
  45. package/dist/src/throttle/RequestDigests.js.map +1 -0
  46. package/dist/src/throttle/SignInThrottle.d.ts +67 -0
  47. package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
  48. package/dist/src/throttle/SignInThrottle.js +91 -0
  49. package/dist/src/throttle/SignInThrottle.js.map +1 -0
  50. package/dist/src/throttle/SlidingWindow.d.ts +51 -0
  51. package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
  52. package/dist/src/throttle/SlidingWindow.js +85 -0
  53. package/dist/src/throttle/SlidingWindow.js.map +1 -0
  54. package/dist/test/ClientAddress.test.d.ts +2 -0
  55. package/dist/test/ClientAddress.test.d.ts.map +1 -0
  56. package/dist/test/ClientAddress.test.js +51 -0
  57. package/dist/test/ClientAddress.test.js.map +1 -0
  58. package/dist/test/DbTraffic.d.ts +29 -0
  59. package/dist/test/DbTraffic.d.ts.map +1 -0
  60. package/dist/test/DbTraffic.js +106 -0
  61. package/dist/test/DbTraffic.js.map +1 -0
  62. package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
  63. package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
  64. package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
  65. package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
  66. package/dist/test/ExecutePasswordReset.test.js +513 -124
  67. package/dist/test/ExecutePasswordReset.test.js.map +1 -1
  68. package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
  69. package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
  70. package/dist/test/InitiatePasswordReset.test.js +783 -0
  71. package/dist/test/InitiatePasswordReset.test.js.map +1 -0
  72. package/dist/test/LogCapture.d.ts +22 -0
  73. package/dist/test/LogCapture.d.ts.map +1 -0
  74. package/dist/test/LogCapture.js +127 -0
  75. package/dist/test/LogCapture.js.map +1 -0
  76. package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
  77. package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
  78. package/dist/test/LoginThrottle.integration.test.js +616 -0
  79. package/dist/test/LoginThrottle.integration.test.js.map +1 -0
  80. package/dist/test/MalformedResetTokens.d.ts +11 -0
  81. package/dist/test/MalformedResetTokens.d.ts.map +1 -0
  82. package/dist/test/MalformedResetTokens.js +35 -0
  83. package/dist/test/MalformedResetTokens.js.map +1 -0
  84. package/dist/test/RequestDigests.test.d.ts +2 -0
  85. package/dist/test/RequestDigests.test.d.ts.map +1 -0
  86. package/dist/test/RequestDigests.test.js +73 -0
  87. package/dist/test/RequestDigests.test.js.map +1 -0
  88. package/dist/test/SlidingWindow.test.d.ts +2 -0
  89. package/dist/test/SlidingWindow.test.d.ts.map +1 -0
  90. package/dist/test/SlidingWindow.test.js +72 -0
  91. package/dist/test/SlidingWindow.test.js.map +1 -0
  92. package/dist/test/ValidateResetToken.test.js +156 -33
  93. package/dist/test/ValidateResetToken.test.js.map +1 -1
  94. package/dist/test/emulatorLock.test.d.ts +2 -0
  95. package/dist/test/emulatorLock.test.d.ts.map +1 -0
  96. package/dist/test/emulatorLock.test.js +32 -0
  97. package/dist/test/emulatorLock.test.js.map +1 -0
  98. package/dist/test/emulatorLockName.test.d.ts +2 -0
  99. package/dist/test/emulatorLockName.test.d.ts.map +1 -0
  100. package/dist/test/emulatorLockName.test.js +36 -0
  101. package/dist/test/emulatorLockName.test.js.map +1 -0
  102. package/generated/index.ts +1 -1
  103. package/index.ts +6 -1
  104. package/jest.config.js +2 -2
  105. package/package.json +3 -3
  106. package/src/authentication/PasswordHasher.ts +18 -1
  107. package/src/authentication/PasswordResetToken.ts +74 -20
  108. package/src/authentication/authenticate.ts +36 -9
  109. package/src/routes/devLogin.ts +1 -1
  110. package/src/routes/executePasswordReset.ts +10 -2
  111. package/src/routes/initiatePasswordReset.ts +46 -25
  112. package/src/routes/login.ts +39 -8
  113. package/src/throttle/ClientAddress.ts +47 -0
  114. package/src/throttle/PasswordResetThrottle.ts +62 -0
  115. package/src/throttle/RequestDigests.ts +97 -0
  116. package/src/throttle/SignInThrottle.ts +98 -0
  117. package/src/throttle/SlidingWindow.ts +90 -0
  118. package/test/ClientAddress.test.ts +54 -0
  119. package/test/DbTraffic.ts +43 -0
  120. package/test/DevLoginBootstrapAdmin.test.ts +3 -4
  121. package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
  122. package/test/ExecutePasswordReset.test.ts +308 -70
  123. package/test/InitiatePasswordReset.test.ts +456 -0
  124. package/test/LogCapture.ts +54 -0
  125. package/test/LoginThrottle.integration.test.ts +339 -0
  126. package/test/MalformedResetTokens.ts +27 -0
  127. package/test/RequestDigests.test.ts +81 -0
  128. package/test/SlidingWindow.test.ts +85 -0
  129. package/test/ValidateResetToken.test.ts +108 -24
  130. package/test/emulatorLock.js +5 -6
  131. package/test/emulatorLock.test.ts +32 -0
  132. package/test/emulatorLockName.test.ts +33 -0
  133. package/test/setup.js +2 -0
@@ -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
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `token` field of a request that does not carry a well-formed reset token: every type a
3
+ * parsed body or query string can deliver, and every near-miss of the token's shape. The
4
+ * near-misses are cut from a LIVE token, so a shape check that loosens (a lost anchor, a wider
5
+ * length, a case-insensitive class, a missing type check) would be looking up something derived
6
+ * from a real credential — and the suites that use this table assert that no lookup is built.
7
+ */
8
+ export class MalformedResetTokens {
9
+ static readonly CASES: [label: string, tokenField: (liveToken: string) => Record<string, unknown>][] = [
10
+ ['absent', () => ({})],
11
+ ['undefined', () => ({ token: undefined })],
12
+ ['null', () => ({ token: null })],
13
+ ['empty', () => ({ token: '' })],
14
+ ['a string of another shape', () => ({ token: 'tok-valid-1' })],
15
+ ['a number', () => ({ token: 123 })],
16
+ ['true', () => ({ token: true })],
17
+ ['an array', () => ({ token: ['a', 'b'] })],
18
+ ['an array holding the live token', (liveToken) => ({ token: [liveToken] })],
19
+ ['an object', () => ({ token: { passwordResetToken: null } })],
20
+ ['63 hex characters of the live token', (liveToken) => ({ token: liveToken.slice(0, 63) })],
21
+ ['the live token and one more hex character', (liveToken) => ({ token: `${liveToken}a` })],
22
+ ['the live token in uppercase hex', (liveToken) => ({ token: liveToken.toUpperCase() })],
23
+ ['the live token and a trailing newline', (liveToken) => ({ token: `${liveToken}\n` })],
24
+ ['the live token after a leading space', (liveToken) => ({ token: ` ${liveToken}` })],
25
+ ['64 characters that are not hex', () => ({ token: 'g'.repeat(64) })],
26
+ ];
27
+ }
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'crypto';
2
+ import { RequestDigests } from '../src/throttle/RequestDigests';
3
+
4
+ /**
5
+ * The two keyed digests the throttled doors key their windows on and write on their log lines:
6
+ * the account digest (a keyed hash of the lowercased address) and the coarse IP hash. Both are
7
+ * stable for one key (so an operator can say "one account, one device" across lines, pods and
8
+ * restarts) and useless without it (never the address, never a plain hash a list of addresses
9
+ * could reverse).
10
+ */
11
+ describe('RequestDigests', () => {
12
+ const digests = new RequestDigests({ secret: 'test-session-secret' });
13
+
14
+ it('the account digest is stable for one address whatever its case or surrounding spaces', () => {
15
+ const digest = digests.account('Ada.Lovelace@Example.com');
16
+
17
+ expect(digest).toMatch(/^[0-9a-f]{16}$/);
18
+ expect(digests.account('ada.lovelace@example.com')).toBe(digest);
19
+ expect(digests.account(' ADA.LOVELACE@EXAMPLE.COM ')).toBe(digest);
20
+ expect(digests.account('ada.lovelace@example.org')).not.toBe(digest);
21
+ });
22
+
23
+ it('the account digest is keyed: another key gives another digest, and it is no plain hash of the address', () => {
24
+ const address = 'ada.lovelace@example.com';
25
+ const digest = digests.account(address);
26
+
27
+ expect(new RequestDigests({ secret: 'another-secret' }).account(address)).not.toBe(digest);
28
+ expect(createHash('sha256').update(address).digest('hex')).not.toContain(digest);
29
+ expect(digest).not.toContain('ada');
30
+ });
31
+
32
+ it('the account digest and the coarse IP hash are separate: one value never reads as the other', () => {
33
+ expect(digests.account('203.0.113.7')).not.toBe(digests.coarseIp('203.0.113.7'));
34
+ });
35
+
36
+ it('the coarse IP hash is stable for one IPv4 address and differs for the next', () => {
37
+ const hash = digests.coarseIp('203.0.113.7');
38
+
39
+ expect(hash).toMatch(/^[0-9a-f]{16}$/);
40
+ expect(digests.coarseIp('203.0.113.7')).toBe(hash);
41
+ expect(digests.coarseIp('::ffff:203.0.113.7')).toBe(hash);
42
+ expect(digests.coarseIp('203.0.113.8')).not.toBe(hash);
43
+ });
44
+
45
+ it('IPv6 is coarsened to its /64 — one device rotates freely inside its own /64', () => {
46
+ const hash = digests.coarseIp('2001:db8:1:2:aaaa:bbbb:cccc:dddd');
47
+
48
+ expect(digests.coarseIp('2001:db8:1:2::1')).toBe(hash);
49
+ expect(digests.coarseIp('2001:0db8:0001:0002:ffff:0:0:1')).toBe(hash);
50
+ expect(digests.coarseIp('2001:db8:1:3::1')).not.toBe(hash);
51
+ });
52
+
53
+ it('with no key configured the digests refuse to run — never a plain hash, never a key of their own', () => {
54
+ const original = process.env.SESSION_SECRET;
55
+ delete process.env.SESSION_SECRET;
56
+ try {
57
+ expect(() => new RequestDigests().account('ada.lovelace@example.com')).toThrow(/SESSION_SECRET/);
58
+ expect(() => new RequestDigests({ secret: '' }).coarseIp('203.0.113.7')).toThrow(/SESSION_SECRET/);
59
+ } finally {
60
+ if (original !== undefined) {
61
+ process.env.SESSION_SECRET = original;
62
+ }
63
+ }
64
+ });
65
+
66
+ it('reads the key the deployment configures (SESSION_SECRET) when none is passed', () => {
67
+ const original = process.env.SESSION_SECRET;
68
+ process.env.SESSION_SECRET = 'test-session-secret';
69
+ try {
70
+ expect(new RequestDigests().account('ada.lovelace@example.com')).toBe(
71
+ digests.account('ada.lovelace@example.com')
72
+ );
73
+ } finally {
74
+ if (original === undefined) {
75
+ delete process.env.SESSION_SECRET;
76
+ } else {
77
+ process.env.SESSION_SECRET = original;
78
+ }
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,85 @@
1
+ import { SlidingWindow } from '../src/throttle/SlidingWindow';
2
+
3
+ /**
4
+ * The per-key sliding window every throttled door shares (the sign-in and reset doors here, the
5
+ * invite-request door in the consuming app): `hit` records and answers whether the key is over,
6
+ * `isOver` only reads, `record` only counts, `clear` forgets. Attempts refused as over the window
7
+ * are not counted, so a key drains exactly one window after its last counted attempt.
8
+ */
9
+ describe('SlidingWindow', () => {
10
+ const MINUTE = 60 * 1000;
11
+ let now = 1_700_000_000_000;
12
+ const clock = () => now;
13
+
14
+ it('admits up to the limit inside the window and refuses the next', () => {
15
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 3, now: clock });
16
+
17
+ expect([window.hit('a'), window.hit('a'), window.hit('a')]).toEqual([false, false, false]);
18
+ expect(window.hit('a')).toBe(true);
19
+ expect(window.isOver('a')).toBe(true);
20
+ });
21
+
22
+ it('keeps each key in its own window', () => {
23
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
24
+ window.hit('a');
25
+ window.hit('a');
26
+
27
+ expect(window.hit('a')).toBe(true);
28
+ expect(window.hit('b')).toBe(false);
29
+ });
30
+
31
+ it('drains: an attempt older than the window no longer counts, and refused attempts never counted', () => {
32
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
33
+ window.hit('a');
34
+ now += 4 * MINUTE;
35
+ window.hit('a');
36
+ now += 5 * MINUTE;
37
+ expect(window.hit('a')).toBe(true); // both counted attempts are still inside the window
38
+
39
+ now += 1 * MINUTE + 1; // the first counted attempt left the window; the refused one never counted
40
+ expect(window.hit('a')).toBe(false);
41
+ expect(window.hit('a')).toBe(true);
42
+ });
43
+
44
+ it('isOver reads without counting; record counts without answering; clear forgets the key', () => {
45
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
46
+
47
+ expect(window.isOver('a')).toBe(false);
48
+ expect(window.isOver('a')).toBe(false);
49
+ window.record('a');
50
+ expect(window.isOver('a')).toBe(false);
51
+ window.record('a');
52
+ expect(window.isOver('a')).toBe(true);
53
+
54
+ window.clear('a');
55
+ expect(window.isOver('a')).toBe(false);
56
+ });
57
+
58
+ it('forgive uncounts the latest attempt only', () => {
59
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
60
+ window.hit('a');
61
+ window.hit('a');
62
+ expect(window.isOver('a')).toBe(true);
63
+
64
+ window.forgive('a');
65
+
66
+ expect(window.isOver('a')).toBe(false);
67
+ expect(window.hit('a')).toBe(false);
68
+ expect(window.hit('a')).toBe(true);
69
+ window.forgive('b'); // a key never seen: nothing to uncount, nothing thrown
70
+ expect(window.isOver('b')).toBe(false);
71
+ });
72
+
73
+ it('bounds the keys it tracks: at the bound the oldest-touched key is forgotten first', () => {
74
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 1, maxKeys: 2, now: clock });
75
+ window.hit('a');
76
+ window.hit('b');
77
+ window.hit('a'); // refused, but 'a' is now the most recently touched
78
+
79
+ window.hit('c'); // at the bound: 'b' (oldest-touched) is forgotten
80
+
81
+ expect(window.isOver('a')).toBe(true);
82
+ expect(window.isOver('b')).toBe(false);
83
+ expect(window.isOver('c')).toBe(true);
84
+ });
85
+ });