@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.
- package/CHANGELOG.md +24 -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 +10 -2
- 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/PasswordResetToken.d.ts +43 -12
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
- package/dist/src/authentication/PasswordResetToken.js +86 -21
- package/dist/src/authentication/PasswordResetToken.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/routes/devLogin.d.ts +1 -1
- package/dist/src/routes/devLogin.js +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +6 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +16 -7
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +10 -2
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +55 -34
- 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/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/DbTraffic.d.ts +29 -0
- package/dist/test/DbTraffic.d.ts.map +1 -0
- package/dist/test/DbTraffic.js +106 -0
- package/dist/test/DbTraffic.js.map +1 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
- package/dist/test/ExecutePasswordReset.test.js +513 -124
- package/dist/test/ExecutePasswordReset.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
- package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
- package/dist/test/InitiatePasswordReset.test.js +783 -0
- package/dist/test/InitiatePasswordReset.test.js.map +1 -0
- package/dist/test/LogCapture.d.ts +22 -0
- package/dist/test/LogCapture.d.ts.map +1 -0
- package/dist/test/LogCapture.js +127 -0
- package/dist/test/LogCapture.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/MalformedResetTokens.d.ts +11 -0
- package/dist/test/MalformedResetTokens.d.ts.map +1 -0
- package/dist/test/MalformedResetTokens.js +35 -0
- package/dist/test/MalformedResetTokens.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/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/ValidateResetToken.test.js +156 -33
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/dist/test/emulatorLock.test.d.ts +2 -0
- package/dist/test/emulatorLock.test.d.ts.map +1 -0
- package/dist/test/emulatorLock.test.js +32 -0
- package/dist/test/emulatorLock.test.js.map +1 -0
- package/dist/test/emulatorLockName.test.d.ts +2 -0
- package/dist/test/emulatorLockName.test.d.ts.map +1 -0
- package/dist/test/emulatorLockName.test.js +36 -0
- package/dist/test/emulatorLockName.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +6 -1
- package/jest.config.js +2 -2
- package/package.json +3 -3
- package/src/authentication/PasswordHasher.ts +18 -1
- package/src/authentication/PasswordResetToken.ts +74 -20
- package/src/authentication/authenticate.ts +36 -9
- package/src/routes/devLogin.ts +1 -1
- package/src/routes/executePasswordReset.ts +10 -2
- package/src/routes/initiatePasswordReset.ts +46 -25
- package/src/routes/login.ts +39 -8
- 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/DbTraffic.ts +43 -0
- package/test/DevLoginBootstrapAdmin.test.ts +3 -4
- package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
- package/test/ExecutePasswordReset.test.ts +308 -70
- package/test/InitiatePasswordReset.test.ts +456 -0
- package/test/LogCapture.ts +54 -0
- package/test/LoginThrottle.integration.test.ts +339 -0
- package/test/MalformedResetTokens.ts +27 -0
- package/test/RequestDigests.test.ts +81 -0
- package/test/SlidingWindow.test.ts +85 -0
- package/test/ValidateResetToken.test.ts +108 -24
- package/test/emulatorLock.js +5 -6
- package/test/emulatorLock.test.ts +32 -0
- package/test/emulatorLockName.test.ts +33 -0
- package/test/setup.js +2 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import moment from 'moment';
|
|
3
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
4
|
+
import { EmailSender, MailSink } from '@proteinjs/email-server';
|
|
5
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
6
|
+
import { tables } from '@proteinjs/user';
|
|
7
|
+
import { initiatePasswordReset } from '../src/routes/initiatePasswordReset';
|
|
8
|
+
import { executePasswordReset } from '../src/routes/executePasswordReset';
|
|
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';
|
|
13
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
14
|
+
import { LogCapture } from './LogCapture';
|
|
15
|
+
|
|
16
|
+
const testEnv = new UserServerTestEnvironment();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `POST /user/initiate-password-reset`, end to end against the Spanner emulator and the mail
|
|
20
|
+
* sink — outcomes only (the mail recorded, the row written):
|
|
21
|
+
* - the token travels in the mailed link and nowhere else: the row stores its SHA-256 digest and
|
|
22
|
+
* an expiry an hour out, never the token, and the link's token then resets the password;
|
|
23
|
+
* - a send that fails withdraws the token, so asking again straight away mails a link;
|
|
24
|
+
* - a second request inside five minutes is throttled: no new mail, the row unchanged;
|
|
25
|
+
* - an unknown address gets the same response and no mail;
|
|
26
|
+
* - a request with no body, or no usable email: 400, no mail, nothing changes;
|
|
27
|
+
* - no minted token, no leading characters of one and no full digest reach the log — on the
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
type RouteOutcome = { status: number; body?: any };
|
|
38
|
+
|
|
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> => {
|
|
52
|
+
const outcome: RouteOutcome = { status: 200 };
|
|
53
|
+
const response = {
|
|
54
|
+
status(code: number) {
|
|
55
|
+
outcome.status = code;
|
|
56
|
+
return this;
|
|
57
|
+
},
|
|
58
|
+
send(body?: unknown) {
|
|
59
|
+
events?.push('answered');
|
|
60
|
+
outcome.body = body;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
await route.onRequest({ ...fromAddress(), ...request } as never, response as never);
|
|
64
|
+
return outcome;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const GENERIC = { message: 'If that address has an account, a reset link is on its way.' };
|
|
68
|
+
|
|
69
|
+
const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
70
|
+
|
|
71
|
+
const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
72
|
+
|
|
73
|
+
/** Every stored reset token in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
74
|
+
const tokensByEmail = async (): Promise<Record<string, string | null | undefined>> => {
|
|
75
|
+
const byEmail: Record<string, string | null | undefined> = {};
|
|
76
|
+
for (const user of await getDbAsSystem().query(tables.User, {})) {
|
|
77
|
+
byEmail[user.email] = user.passwordResetToken;
|
|
78
|
+
}
|
|
79
|
+
return byEmail;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The token in a reset mail's link. */
|
|
83
|
+
const tokenInLink = (mailText: string | undefined): string => {
|
|
84
|
+
const token = /[?&]token=([0-9a-f]{64})\b/.exec(mailText ?? '')?.[1];
|
|
85
|
+
if (!token) {
|
|
86
|
+
throw new Error('The mail carries no reset link');
|
|
87
|
+
}
|
|
88
|
+
return token;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The token in the newest mailed reset link. */
|
|
92
|
+
const mailedToken = (): string => tokenInLink(MailSink.get().list(1)[0]?.text);
|
|
93
|
+
|
|
94
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
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
|
+
|
|
115
|
+
describe('initiatePasswordReset route', () => {
|
|
116
|
+
const originalTransport = process.env.EMAIL_TRANSPORT;
|
|
117
|
+
|
|
118
|
+
beforeAll(async () => {
|
|
119
|
+
await testEnv.beforeAll();
|
|
120
|
+
process.env.EMAIL_TRANSPORT = 'sink';
|
|
121
|
+
const objectCache = (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
122
|
+
objectCache['@proteinjs/email-server/DefaultEmailConfigFactory'] = [
|
|
123
|
+
{
|
|
124
|
+
getEmailConfig: () => ({
|
|
125
|
+
host: 'smtp.test.local',
|
|
126
|
+
port: 465,
|
|
127
|
+
secure: true,
|
|
128
|
+
from: '"Example" <hi@example.com>',
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
objectCache['@proteinjs/email-server/DefaultPasswordResetEmailConfigFactory'] = [
|
|
133
|
+
{
|
|
134
|
+
getConfig: () => ({
|
|
135
|
+
getEmailContent: (resetPathWithToken: string) => ({
|
|
136
|
+
text: `Reset your password: https://app.test.local/${resetPathWithToken}`,
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
MailSink.get().clear();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterAll(async () => {
|
|
148
|
+
if (originalTransport === undefined) {
|
|
149
|
+
delete process.env.EMAIL_TRANSPORT;
|
|
150
|
+
} else {
|
|
151
|
+
process.env.EMAIL_TRANSPORT = originalTransport;
|
|
152
|
+
}
|
|
153
|
+
await testEnv.afterAll();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("the token travels only in the mailed link: the row stores its digest and an hour's expiry, and the link's token resets the password", async () => {
|
|
157
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-live@test.local' });
|
|
158
|
+
|
|
159
|
+
const outcome = await invoke(initiatePasswordReset, { body: { email: 'Initiate-Live@test.local' } });
|
|
160
|
+
|
|
161
|
+
expect(outcome).toEqual({ status: 200, body: GENERIC });
|
|
162
|
+
const [mail] = MailSink.get().list();
|
|
163
|
+
expect(mail.to).toEqual(['initiate-live@test.local']);
|
|
164
|
+
const token = mailedToken();
|
|
165
|
+
const armed = await userRow('initiate-live@test.local');
|
|
166
|
+
expect(armed.passwordResetToken).toBe(sha256Hex(token));
|
|
167
|
+
expect(JSON.stringify(armed)).not.toContain(token);
|
|
168
|
+
const minutesOut = moment(armed.passwordResetTokenExpiration).diff(moment(), 'minutes', true);
|
|
169
|
+
expect(minutesOut).toBeGreaterThan(59);
|
|
170
|
+
expect(minutesOut).toBeLessThanOrEqual(60);
|
|
171
|
+
|
|
172
|
+
const reset = await invoke(executePasswordReset, { body: { token, newPassword: 'a new password' } });
|
|
173
|
+
|
|
174
|
+
expect(reset.status).toBe(200);
|
|
175
|
+
const row = await userRow('initiate-live@test.local');
|
|
176
|
+
await expect(new PasswordHasher().verify(row.password, 'a new password')).resolves.toBe(true);
|
|
177
|
+
expect(row.passwordResetToken).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('a send that fails withdraws the token — asking again straight away mails a link', async () => {
|
|
181
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-send-fails@test.local' });
|
|
182
|
+
const send = jest
|
|
183
|
+
.spyOn(EmailSender.prototype, 'sendEmail')
|
|
184
|
+
.mockRejectedValueOnce(new Error('Failed to send email'));
|
|
185
|
+
try {
|
|
186
|
+
const failed = await invoke(initiatePasswordReset, { body: { email: 'initiate-send-fails@test.local' } });
|
|
187
|
+
|
|
188
|
+
expect(failed).toEqual({ status: 200, body: GENERIC });
|
|
189
|
+
const withdrawn = await userRow('initiate-send-fails@test.local');
|
|
190
|
+
expect(withdrawn.passwordResetToken).toBeNull();
|
|
191
|
+
expect(withdrawn.passwordResetTokenExpiration).toBeNull();
|
|
192
|
+
} finally {
|
|
193
|
+
send.mockRestore();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const again = await invoke(initiatePasswordReset, { body: { email: 'initiate-send-fails@test.local' } });
|
|
197
|
+
|
|
198
|
+
expect(again).toEqual({ status: 200, body: GENERIC });
|
|
199
|
+
expect(MailSink.get().list()).toHaveLength(1);
|
|
200
|
+
expect((await userRow('initiate-send-fails@test.local')).passwordResetToken).toBe(sha256Hex(mailedToken()));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('a second request inside five minutes is throttled: no new mail, the row unchanged', async () => {
|
|
204
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-throttled@test.local' });
|
|
205
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-throttled@test.local' } });
|
|
206
|
+
const armed = await userRow('initiate-throttled@test.local');
|
|
207
|
+
|
|
208
|
+
const second = await invoke(initiatePasswordReset, { body: { email: 'initiate-throttled@test.local' } });
|
|
209
|
+
|
|
210
|
+
expect(second).toEqual({ status: 200, body: GENERIC });
|
|
211
|
+
expect(MailSink.get().list()).toHaveLength(1);
|
|
212
|
+
expect((await userRow('initiate-throttled@test.local')).passwordResetToken).toBe(armed.passwordResetToken);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('a token minted more than five minutes ago is replaced: a new link is mailed and the old token stops working', async () => {
|
|
216
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-replaced@test.local' });
|
|
217
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-replaced@test.local' } });
|
|
218
|
+
const first = mailedToken();
|
|
219
|
+
const armed = await userRow('initiate-replaced@test.local');
|
|
220
|
+
await getDbAsSystem().update(tables.User, {
|
|
221
|
+
id: armed.id,
|
|
222
|
+
passwordResetTokenExpiration: moment().add(54, 'minutes'),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-replaced@test.local' } });
|
|
226
|
+
|
|
227
|
+
expect(MailSink.get().list()).toHaveLength(2);
|
|
228
|
+
const second = mailedToken();
|
|
229
|
+
expect(second).not.toBe(first);
|
|
230
|
+
expect((await userRow('initiate-replaced@test.local')).passwordResetToken).toBe(sha256Hex(second));
|
|
231
|
+
const stale = await invoke(executePasswordReset, { body: { token: first, newPassword: 'hijacked' } });
|
|
232
|
+
expect(stale.status).toBe(400);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('no minted token, no leading characters of one and no full digest reach the log — mailed, throttled, unknown, failed send', async () => {
|
|
236
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-log@test.local' });
|
|
237
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-log-send-fails@test.local' });
|
|
238
|
+
let mailed = '';
|
|
239
|
+
let withdrawn = '';
|
|
240
|
+
|
|
241
|
+
const log = await LogCapture.during(async () => {
|
|
242
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-log@test.local' } });
|
|
243
|
+
mailed = mailedToken();
|
|
244
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-log@test.local' } });
|
|
245
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-log-nobody@test.local' } });
|
|
246
|
+
// The send fails after the mail was built: the token it carried is the one withdrawn.
|
|
247
|
+
const send = jest.spyOn(EmailSender.prototype, 'sendEmail').mockImplementationOnce(async (mail) => {
|
|
248
|
+
withdrawn = tokenInLink(mail.text as string);
|
|
249
|
+
throw new Error('Failed to send email');
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
const failed = await invoke(initiatePasswordReset, { body: { email: 'initiate-log-send-fails@test.local' } });
|
|
253
|
+
expect(failed).toEqual({ status: 200, body: GENERIC });
|
|
254
|
+
} finally {
|
|
255
|
+
send.mockRestore();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// The capture saw the route's lines: the throttle, the unknown address and the failed send logged.
|
|
260
|
+
expect(log.text).toContain('Password reset requested too soon for user');
|
|
261
|
+
expect(log.text).toContain('Password reset requested for non-existent user');
|
|
262
|
+
expect(log.text).toContain('Failed to send password reset email');
|
|
263
|
+
expect(withdrawn).not.toBe(mailed);
|
|
264
|
+
for (const token of [mailed, withdrawn]) {
|
|
265
|
+
log.expectFreeOf(token);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('an unknown address gets the same response and no mail', async () => {
|
|
270
|
+
const outcome = await invoke(initiatePasswordReset, { body: { email: 'initiate-nobody@test.local' } });
|
|
271
|
+
|
|
272
|
+
expect(outcome).toEqual({ status: 200, body: GENERIC });
|
|
273
|
+
expect(MailSink.get().list()).toHaveLength(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it.each([
|
|
277
|
+
['no body', {}],
|
|
278
|
+
['a null body', { body: null }],
|
|
279
|
+
['no email', { body: {} }],
|
|
280
|
+
['a blank email', { body: { email: '' } }],
|
|
281
|
+
['an email that is not a string', { body: { email: { toLowerCase: null } } }],
|
|
282
|
+
])('a request with %s: 400, no mail, no token minted', async (_label, request) => {
|
|
283
|
+
const tokensBefore = await tokensByEmail();
|
|
284
|
+
|
|
285
|
+
const outcome = await invoke(initiatePasswordReset, request);
|
|
286
|
+
|
|
287
|
+
expect(outcome.status).toBe(400);
|
|
288
|
+
expect(MailSink.get().list()).toHaveLength(0);
|
|
289
|
+
expect(await tokensByEmail()).toEqual(tokensBefore);
|
|
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
|
+
});
|
|
456
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything the process wrote while a run executed, as a log reader would see it: every
|
|
5
|
+
* `console` call at every level (where the default log writer sends each entry, whichever logger
|
|
6
|
+
* produced it) and every direct stdout/stderr write, one string per write. Nothing is filtered by
|
|
7
|
+
* logger name or level, so the capture holds exactly what the default configuration emits.
|
|
8
|
+
*/
|
|
9
|
+
export class LogCapture {
|
|
10
|
+
/** How many leading characters of a token count as the token showing up in a log. */
|
|
11
|
+
private static readonly TOKEN_PREFIX_LENGTH = 12;
|
|
12
|
+
|
|
13
|
+
private constructor(readonly lines: string[]) {}
|
|
14
|
+
|
|
15
|
+
static async during(run: () => Promise<void>): Promise<LogCapture> {
|
|
16
|
+
const lines: string[] = [];
|
|
17
|
+
const consoleSpies = (['log', 'info', 'warn', 'error', 'debug'] as const).map((level) =>
|
|
18
|
+
jest.spyOn(console, level).mockImplementation((...parts: unknown[]) => {
|
|
19
|
+
lines.push(parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' '));
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
const streamSpies = [process.stdout, process.stderr].map((stream) => {
|
|
23
|
+
const write = stream.write.bind(stream) as (...args: unknown[]) => boolean;
|
|
24
|
+
return jest.spyOn(stream, 'write').mockImplementation(((...args: unknown[]) => {
|
|
25
|
+
lines.push(String(args[0]));
|
|
26
|
+
return write(...args);
|
|
27
|
+
}) as never);
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
await run();
|
|
31
|
+
} finally {
|
|
32
|
+
[...consoleSpies, ...streamSpies].forEach((spy) => spy.mockRestore());
|
|
33
|
+
}
|
|
34
|
+
return new LogCapture(lines);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get text(): string {
|
|
38
|
+
return this.lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fails unless the log is free of `token` in every form that would let a log reader act on it
|
|
43
|
+
* or tie a line to a row: the token, the SHA-256 digest a row stores for it, and the token's
|
|
44
|
+
* own leading characters — a log reference to a token is cut from its digest, never from the
|
|
45
|
+
* token. Letter case is ignored.
|
|
46
|
+
*/
|
|
47
|
+
expectFreeOf(token: string): void {
|
|
48
|
+
const text = this.text.toLowerCase();
|
|
49
|
+
const digest = createHash('sha256').update(token).digest('hex');
|
|
50
|
+
expect(text).not.toContain(token.toLowerCase());
|
|
51
|
+
expect(text).not.toContain(digest);
|
|
52
|
+
expect(text).not.toContain(token.slice(0, LogCapture.TOKEN_PREFIX_LENGTH).toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
}
|