@proteinjs/user-server 1.22.0 → 1.22.1
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 +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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/routes/devLogin.d.ts +1 -1
- package/dist/src/routes/devLogin.js +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +2 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +9 -7
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +3 -2
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +30 -27
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- 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 +415 -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/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/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 +1 -1
- package/jest.config.js +2 -2
- package/package.json +3 -3
- package/src/authentication/PasswordResetToken.ts +74 -20
- package/src/routes/devLogin.ts +1 -1
- package/src/routes/executePasswordReset.ts +3 -2
- package/src/routes/initiatePasswordReset.ts +20 -17
- 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 +250 -0
- package/test/LogCapture.ts +54 -0
- package/test/MalformedResetTokens.ts +27 -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
|
@@ -0,0 +1,250 @@
|
|
|
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 { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
11
|
+
import { LogCapture } from './LogCapture';
|
|
12
|
+
|
|
13
|
+
const testEnv = new UserServerTestEnvironment();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `POST /user/initiate-password-reset`, end to end against the Spanner emulator and the mail
|
|
17
|
+
* sink — outcomes only (the mail recorded, the row written):
|
|
18
|
+
* - the token travels in the mailed link and nowhere else: the row stores its SHA-256 digest and
|
|
19
|
+
* an expiry an hour out, never the token, and the link's token then resets the password;
|
|
20
|
+
* - a send that fails withdraws the token, so asking again straight away mails a link;
|
|
21
|
+
* - a second request inside five minutes is throttled: no new mail, the row unchanged;
|
|
22
|
+
* - an unknown address gets the same response and no mail;
|
|
23
|
+
* - a request with no body, or no usable email: 400, no mail, nothing changes;
|
|
24
|
+
* - 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.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
type RouteOutcome = { status: number; body?: any };
|
|
29
|
+
|
|
30
|
+
const invoke = async (route: typeof initiatePasswordReset, request: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
31
|
+
const outcome: RouteOutcome = { status: 200 };
|
|
32
|
+
const response = {
|
|
33
|
+
status(code: number) {
|
|
34
|
+
outcome.status = code;
|
|
35
|
+
return this;
|
|
36
|
+
},
|
|
37
|
+
send(body?: unknown) {
|
|
38
|
+
outcome.body = body;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
await route.onRequest(request as never, response as never);
|
|
42
|
+
return outcome;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const GENERIC = { message: 'If an account with that email exists, we have sent a password reset link.' };
|
|
46
|
+
|
|
47
|
+
const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
48
|
+
|
|
49
|
+
const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
50
|
+
|
|
51
|
+
/** Every stored reset token in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
52
|
+
const tokensByEmail = async (): Promise<Record<string, string | null | undefined>> => {
|
|
53
|
+
const byEmail: Record<string, string | null | undefined> = {};
|
|
54
|
+
for (const user of await getDbAsSystem().query(tables.User, {})) {
|
|
55
|
+
byEmail[user.email] = user.passwordResetToken;
|
|
56
|
+
}
|
|
57
|
+
return byEmail;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The token in a reset mail's link. */
|
|
61
|
+
const tokenInLink = (mailText: string | undefined): string => {
|
|
62
|
+
const token = /[?&]token=([0-9a-f]{64})\b/.exec(mailText ?? '')?.[1];
|
|
63
|
+
if (!token) {
|
|
64
|
+
throw new Error('The mail carries no reset link');
|
|
65
|
+
}
|
|
66
|
+
return token;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The token in the newest mailed reset link. */
|
|
70
|
+
const mailedToken = (): string => tokenInLink(MailSink.get().list(1)[0]?.text);
|
|
71
|
+
|
|
72
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
73
|
+
|
|
74
|
+
describe('initiatePasswordReset route', () => {
|
|
75
|
+
const originalTransport = process.env.EMAIL_TRANSPORT;
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
await testEnv.beforeAll();
|
|
79
|
+
process.env.EMAIL_TRANSPORT = 'sink';
|
|
80
|
+
const objectCache = (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
81
|
+
objectCache['@proteinjs/email-server/DefaultEmailConfigFactory'] = [
|
|
82
|
+
{
|
|
83
|
+
getEmailConfig: () => ({
|
|
84
|
+
host: 'smtp.test.local',
|
|
85
|
+
port: 465,
|
|
86
|
+
secure: true,
|
|
87
|
+
from: '"Example" <hi@example.com>',
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
objectCache['@proteinjs/email-server/DefaultPasswordResetEmailConfigFactory'] = [
|
|
92
|
+
{
|
|
93
|
+
getConfig: () => ({
|
|
94
|
+
getEmailContent: (resetPathWithToken: string) => ({
|
|
95
|
+
text: `Reset your password: https://app.test.local/${resetPathWithToken}`,
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
MailSink.get().clear();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
if (originalTransport === undefined) {
|
|
108
|
+
delete process.env.EMAIL_TRANSPORT;
|
|
109
|
+
} else {
|
|
110
|
+
process.env.EMAIL_TRANSPORT = originalTransport;
|
|
111
|
+
}
|
|
112
|
+
await testEnv.afterAll();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
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 () => {
|
|
116
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-live@test.local' });
|
|
117
|
+
|
|
118
|
+
const outcome = await invoke(initiatePasswordReset, { body: { email: 'Initiate-Live@test.local' } });
|
|
119
|
+
|
|
120
|
+
expect(outcome).toEqual({ status: 200, body: GENERIC });
|
|
121
|
+
const [mail] = MailSink.get().list();
|
|
122
|
+
expect(mail.to).toEqual(['initiate-live@test.local']);
|
|
123
|
+
const token = mailedToken();
|
|
124
|
+
const armed = await userRow('initiate-live@test.local');
|
|
125
|
+
expect(armed.passwordResetToken).toBe(sha256Hex(token));
|
|
126
|
+
expect(JSON.stringify(armed)).not.toContain(token);
|
|
127
|
+
const minutesOut = moment(armed.passwordResetTokenExpiration).diff(moment(), 'minutes', true);
|
|
128
|
+
expect(minutesOut).toBeGreaterThan(59);
|
|
129
|
+
expect(minutesOut).toBeLessThanOrEqual(60);
|
|
130
|
+
|
|
131
|
+
const reset = await invoke(executePasswordReset, { body: { token, newPassword: 'a new password' } });
|
|
132
|
+
|
|
133
|
+
expect(reset.status).toBe(200);
|
|
134
|
+
const row = await userRow('initiate-live@test.local');
|
|
135
|
+
await expect(new PasswordHasher().verify(row.password, 'a new password')).resolves.toBe(true);
|
|
136
|
+
expect(row.passwordResetToken).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('a send that fails withdraws the token — asking again straight away mails a link', async () => {
|
|
140
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-send-fails@test.local' });
|
|
141
|
+
const send = jest
|
|
142
|
+
.spyOn(EmailSender.prototype, 'sendEmail')
|
|
143
|
+
.mockRejectedValueOnce(new Error('Failed to send email'));
|
|
144
|
+
try {
|
|
145
|
+
const failed = await invoke(initiatePasswordReset, { body: { email: 'initiate-send-fails@test.local' } });
|
|
146
|
+
|
|
147
|
+
expect(failed.status).toBe(500);
|
|
148
|
+
const withdrawn = await userRow('initiate-send-fails@test.local');
|
|
149
|
+
expect(withdrawn.passwordResetToken).toBeNull();
|
|
150
|
+
expect(withdrawn.passwordResetTokenExpiration).toBeNull();
|
|
151
|
+
} finally {
|
|
152
|
+
send.mockRestore();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const again = await invoke(initiatePasswordReset, { body: { email: 'initiate-send-fails@test.local' } });
|
|
156
|
+
|
|
157
|
+
expect(again).toEqual({ status: 200, body: GENERIC });
|
|
158
|
+
expect(MailSink.get().list()).toHaveLength(1);
|
|
159
|
+
expect((await userRow('initiate-send-fails@test.local')).passwordResetToken).toBe(sha256Hex(mailedToken()));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('a second request inside five minutes is throttled: no new mail, the row unchanged', async () => {
|
|
163
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-throttled@test.local' });
|
|
164
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-throttled@test.local' } });
|
|
165
|
+
const armed = await userRow('initiate-throttled@test.local');
|
|
166
|
+
|
|
167
|
+
const second = await invoke(initiatePasswordReset, { body: { email: 'initiate-throttled@test.local' } });
|
|
168
|
+
|
|
169
|
+
expect(second).toEqual({ status: 200, body: GENERIC });
|
|
170
|
+
expect(MailSink.get().list()).toHaveLength(1);
|
|
171
|
+
expect((await userRow('initiate-throttled@test.local')).passwordResetToken).toBe(armed.passwordResetToken);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('a token minted more than five minutes ago is replaced: a new link is mailed and the old token stops working', async () => {
|
|
175
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-replaced@test.local' });
|
|
176
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-replaced@test.local' } });
|
|
177
|
+
const first = mailedToken();
|
|
178
|
+
const armed = await userRow('initiate-replaced@test.local');
|
|
179
|
+
await getDbAsSystem().update(tables.User, {
|
|
180
|
+
id: armed.id,
|
|
181
|
+
passwordResetTokenExpiration: moment().add(54, 'minutes'),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-replaced@test.local' } });
|
|
185
|
+
|
|
186
|
+
expect(MailSink.get().list()).toHaveLength(2);
|
|
187
|
+
const second = mailedToken();
|
|
188
|
+
expect(second).not.toBe(first);
|
|
189
|
+
expect((await userRow('initiate-replaced@test.local')).passwordResetToken).toBe(sha256Hex(second));
|
|
190
|
+
const stale = await invoke(executePasswordReset, { body: { token: first, newPassword: 'hijacked' } });
|
|
191
|
+
expect(stale.status).toBe(400);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('no minted token, no leading characters of one and no full digest reach the log — mailed, throttled, unknown, failed send', async () => {
|
|
195
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-log@test.local' });
|
|
196
|
+
await testEnv.createUser({ name: 'Reset User', email: 'initiate-log-send-fails@test.local' });
|
|
197
|
+
let mailed = '';
|
|
198
|
+
let withdrawn = '';
|
|
199
|
+
|
|
200
|
+
const log = await LogCapture.during(async () => {
|
|
201
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-log@test.local' } });
|
|
202
|
+
mailed = mailedToken();
|
|
203
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-log@test.local' } });
|
|
204
|
+
await invoke(initiatePasswordReset, { body: { email: 'initiate-log-nobody@test.local' } });
|
|
205
|
+
// The send fails after the mail was built: the token it carried is the one withdrawn.
|
|
206
|
+
const send = jest.spyOn(EmailSender.prototype, 'sendEmail').mockImplementationOnce(async (mail) => {
|
|
207
|
+
withdrawn = tokenInLink(mail.text as string);
|
|
208
|
+
throw new Error('Failed to send email');
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
const failed = await invoke(initiatePasswordReset, { body: { email: 'initiate-log-send-fails@test.local' } });
|
|
212
|
+
expect(failed.status).toBe(500);
|
|
213
|
+
} finally {
|
|
214
|
+
send.mockRestore();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// The capture saw the route's lines: the throttle, the unknown address and the failed send logged.
|
|
219
|
+
expect(log.text).toContain('Password reset requested too soon for user');
|
|
220
|
+
expect(log.text).toContain('Password reset requested for non-existent user');
|
|
221
|
+
expect(log.text).toContain('Failed to send password reset email');
|
|
222
|
+
expect(withdrawn).not.toBe(mailed);
|
|
223
|
+
for (const token of [mailed, withdrawn]) {
|
|
224
|
+
log.expectFreeOf(token);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('an unknown address gets the same response and no mail', async () => {
|
|
229
|
+
const outcome = await invoke(initiatePasswordReset, { body: { email: 'initiate-nobody@test.local' } });
|
|
230
|
+
|
|
231
|
+
expect(outcome).toEqual({ status: 200, body: GENERIC });
|
|
232
|
+
expect(MailSink.get().list()).toHaveLength(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it.each([
|
|
236
|
+
['no body', {}],
|
|
237
|
+
['a null body', { body: null }],
|
|
238
|
+
['no email', { body: {} }],
|
|
239
|
+
['a blank email', { body: { email: '' } }],
|
|
240
|
+
['an email that is not a string', { body: { email: { toLowerCase: null } } }],
|
|
241
|
+
])('a request with %s: 400, no mail, no token minted', async (_label, request) => {
|
|
242
|
+
const tokensBefore = await tokensByEmail();
|
|
243
|
+
|
|
244
|
+
const outcome = await invoke(initiatePasswordReset, request);
|
|
245
|
+
|
|
246
|
+
expect(outcome.status).toBe(400);
|
|
247
|
+
expect(MailSink.get().list()).toHaveLength(0);
|
|
248
|
+
expect(await tokensByEmail()).toEqual(tokensBefore);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
1
2
|
import moment from 'moment';
|
|
2
3
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
4
|
import { tables } from '@proteinjs/user';
|
|
4
5
|
import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
|
|
5
6
|
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
6
7
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
8
|
+
import { DbTraffic } from './DbTraffic';
|
|
9
|
+
import { LogCapture } from './LogCapture';
|
|
10
|
+
import { MalformedResetTokens } from './MalformedResetTokens';
|
|
7
11
|
|
|
8
12
|
const testEnv = new UserServerTestEnvironment();
|
|
9
13
|
|
|
@@ -14,8 +18,15 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
14
18
|
* with the stored credential. The email only ever rides a VALID token's response — the
|
|
15
19
|
* token was delivered to that very inbox, so it reveals nothing the holder doesn't know —
|
|
16
20
|
* while invalid/expired verdicts stay email-free (no account-probing oracle). A query value
|
|
17
|
-
* that is not a well-formed token
|
|
18
|
-
* invalid without a lookup
|
|
21
|
+
* that is not a well-formed token — every type a parsed query string can deliver, and every
|
|
22
|
+
* near-miss cut from a live token — is invalid without a lookup: nothing reaches the database
|
|
23
|
+
* (no lookup built, no statement run), which the verdict alone could not prove, since a wrongly
|
|
24
|
+
* admitted value still matches no row. The row stores the token's SHA-256 digest, never the
|
|
25
|
+
* token: the stored digest presented as a token is invalid — it has a token's shape, so the one
|
|
26
|
+
* lookup it builds is by the digest OF the presented value — and so is a token an earlier
|
|
27
|
+
* release stored in clear. No token, no leading characters of one and no full digest reach the
|
|
28
|
+
* log. Live tokens are seeded through `PasswordResetToken.mint`, so the suite moves with what
|
|
29
|
+
* the owner stores.
|
|
19
30
|
*/
|
|
20
31
|
|
|
21
32
|
type RouteOutcome = { status?: number; body?: any };
|
|
@@ -35,15 +46,19 @@ const invokeValidate = async (query: Record<string, unknown>): Promise<RouteOutc
|
|
|
35
46
|
return outcome;
|
|
36
47
|
};
|
|
37
48
|
|
|
38
|
-
const
|
|
49
|
+
const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
/** A well-formed token that was never issued to anyone. */
|
|
52
|
+
const unissuedToken = () => sha256Hex(`never issued ${Math.random()}`);
|
|
53
|
+
|
|
54
|
+
/** A user with a token minted by the owner; `expiration` (when given) replaces the expiry the mint stored. */
|
|
55
|
+
const armResetToken = async (email: string, expiration?: moment.Moment): Promise<string> => {
|
|
41
56
|
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
42
|
-
await
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
const token = await new PasswordResetToken().mint(user);
|
|
58
|
+
if (expiration !== undefined) {
|
|
59
|
+
await getDbAsSystem().update(tables.User, { id: user.id, passwordResetTokenExpiration: expiration });
|
|
60
|
+
}
|
|
61
|
+
return token;
|
|
47
62
|
};
|
|
48
63
|
|
|
49
64
|
describe('validateResetPasswordToken route', () => {
|
|
@@ -56,8 +71,7 @@ describe('validateResetPasswordToken route', () => {
|
|
|
56
71
|
});
|
|
57
72
|
|
|
58
73
|
it('a valid token resolves isValid WITH the account email (the reset form identifier)', async () => {
|
|
59
|
-
const token =
|
|
60
|
-
await armResetToken('reset-valid@test.local', token, moment().add(1, 'hour'));
|
|
74
|
+
const token = await armResetToken('reset-valid@test.local');
|
|
61
75
|
|
|
62
76
|
const outcome = await invokeValidate({ token });
|
|
63
77
|
|
|
@@ -66,8 +80,7 @@ describe('validateResetPasswordToken route', () => {
|
|
|
66
80
|
});
|
|
67
81
|
|
|
68
82
|
it('an expired token resolves invalid and leaks no email', async () => {
|
|
69
|
-
const token =
|
|
70
|
-
await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
|
|
83
|
+
const token = await armResetToken('reset-expired@test.local', moment().subtract(1, 'minute'));
|
|
71
84
|
|
|
72
85
|
const outcome = await invokeValidate({ token });
|
|
73
86
|
|
|
@@ -77,28 +90,99 @@ describe('validateResetPasswordToken route', () => {
|
|
|
77
90
|
});
|
|
78
91
|
|
|
79
92
|
it('an unknown token resolves invalid and leaks no email', async () => {
|
|
80
|
-
const outcome = await invokeValidate({ token:
|
|
93
|
+
const outcome = await invokeValidate({ token: unissuedToken() });
|
|
81
94
|
|
|
82
95
|
expect(outcome.status).toBe(200);
|
|
83
96
|
expect(outcome.body.isValid).toBe(false);
|
|
84
97
|
expect(outcome.body.email).toBeUndefined();
|
|
85
98
|
});
|
|
86
99
|
|
|
87
|
-
it
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const outcome = await
|
|
100
|
+
it('the stored digest presented as a token resolves invalid — the row holds nothing that can be presented', async () => {
|
|
101
|
+
const token = await armResetToken('reset-digest@test.local');
|
|
102
|
+
const row = await getDbAsSystem().get(tables.User, { email: 'reset-digest@test.local' });
|
|
103
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
104
|
+
|
|
105
|
+
const { result: outcome, traffic } = await DbTraffic.during(
|
|
106
|
+
testEnv.spannerDriver,
|
|
107
|
+
async () => await invokeValidate({ token: row.passwordResetToken })
|
|
108
|
+
);
|
|
93
109
|
|
|
94
110
|
expect(outcome.status).toBe(200);
|
|
95
111
|
expect(outcome.body).toEqual({ isValid: false, message: 'Invalid token' });
|
|
112
|
+
expect(traffic.lookups).toEqual([{ passwordResetToken: sha256Hex(sha256Hex(token)) }]);
|
|
113
|
+
expect(traffic.statements).toBe(1);
|
|
114
|
+
expect(traffic.writes).toBe(0);
|
|
96
115
|
});
|
|
97
116
|
|
|
98
|
-
it('a
|
|
99
|
-
const
|
|
117
|
+
it('a token an earlier release stored in clear resolves invalid — no migration, the person asks again', async () => {
|
|
118
|
+
const clearToken = unissuedToken();
|
|
119
|
+
const user = await testEnv.createUser({ name: 'Reset User', email: 'reset-stored-in-clear@test.local' });
|
|
120
|
+
await getDbAsSystem().update(tables.User, {
|
|
121
|
+
id: user.id,
|
|
122
|
+
passwordResetToken: clearToken,
|
|
123
|
+
passwordResetTokenExpiration: moment().add(1, 'hour'),
|
|
124
|
+
});
|
|
100
125
|
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
const outcome = await invokeValidate({ token: clearToken });
|
|
127
|
+
|
|
128
|
+
expect(outcome.status).toBe(200);
|
|
129
|
+
expect(outcome.body).toEqual({ isValid: false, message: 'Invalid token' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('a query without a well-formed token is invalid without a lookup', () => {
|
|
133
|
+
let liveToken: string;
|
|
134
|
+
|
|
135
|
+
beforeAll(async () => {
|
|
136
|
+
liveToken = await armResetToken('reset-shapes@test.local');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each(MalformedResetTokens.CASES)(
|
|
140
|
+
'%s: invalid, no email, and nothing reaches the database',
|
|
141
|
+
async (_label, tokenField) => {
|
|
142
|
+
const query = tokenField(liveToken);
|
|
143
|
+
|
|
144
|
+
const { result: outcome, traffic } = await DbTraffic.during(
|
|
145
|
+
testEnv.spannerDriver,
|
|
146
|
+
async () => await invokeValidate(query)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Nothing usable in the query is a 400; anything else that is not a token is an invalid token.
|
|
150
|
+
expect(outcome).toEqual(
|
|
151
|
+
query.token
|
|
152
|
+
? { status: 200, body: { isValid: false, message: 'Invalid token' } }
|
|
153
|
+
: { status: 400, body: { isValid: false, message: 'No token provided' } }
|
|
154
|
+
);
|
|
155
|
+
expect(traffic).toEqual(DbTraffic.NONE);
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
it('after every refusal the live token is still valid', async () => {
|
|
160
|
+
const outcome = await invokeValidate({ token: liveToken });
|
|
161
|
+
|
|
162
|
+
expect(outcome.body).toEqual({ isValid: true, email: 'reset-shapes@test.local' });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('no token, no leading characters of one and no full digest reach the log — every verdict', async () => {
|
|
167
|
+
const live = await armResetToken('reset-log-live@test.local');
|
|
168
|
+
const expired = await armResetToken('reset-log-expired@test.local', moment().subtract(1, 'minute'));
|
|
169
|
+
const unissued = unissuedToken();
|
|
170
|
+
|
|
171
|
+
const log = await LogCapture.during(async () => {
|
|
172
|
+
await invokeValidate({ token: unissued });
|
|
173
|
+
await invokeValidate({ token: live.toUpperCase() });
|
|
174
|
+
await invokeValidate({ token: sha256Hex(live) });
|
|
175
|
+
await invokeValidate({ token: expired });
|
|
176
|
+
await invokeValidate({ token: live });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The capture saw the route's lines: the refusals above logged.
|
|
180
|
+
expect(log.text).toContain('Invalid reset token used');
|
|
181
|
+
expect(log.text).toContain('Expired reset token used');
|
|
182
|
+
expect(log.lines.length).toBeGreaterThanOrEqual(4);
|
|
183
|
+
// The digest presented as a token is covered by `live`: its full digest is what was presented.
|
|
184
|
+
for (const token of [unissued, live, expired]) {
|
|
185
|
+
log.expectFreeOf(token);
|
|
186
|
+
}
|
|
103
187
|
});
|
|
104
188
|
});
|
package/test/emulatorLock.js
CHANGED
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
* FAILED_PRECONDITION). This lock closes that gap for this package: a lockfile in os.tmpdir()
|
|
8
8
|
* keyed by the emulator host string, held for the whole jest run via globalSetup/globalTeardown.
|
|
9
9
|
*
|
|
10
|
-
* The lockfile PATH FORMULA must stay byte-identical
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* on @proteinjs/user) — future extraction target: @proteinjs/db-driver-spanner/test, beside
|
|
10
|
+
* The lockfile PATH FORMULA must stay byte-identical in every copy of this lock (other
|
|
11
|
+
* repositories' suites, a consumer application's suites) so runs pointed at the same emulator
|
|
12
|
+
* actually contend on the same file. The logic is duplicated here because no shared package
|
|
13
|
+
* carries it yet — future extraction target: @proteinjs/db-driver-spanner/test, beside
|
|
15
14
|
* SpannerEmulatorProvisioner.
|
|
16
15
|
*/
|
|
17
16
|
const crypto = require('crypto');
|
|
@@ -30,7 +29,7 @@ function lockFilePathFor(host) {
|
|
|
30
29
|
.replace(/^-+|-+$/g, '')
|
|
31
30
|
.slice(0, 64);
|
|
32
31
|
const hash = crypto.createHash('sha1').update(host).digest('hex').slice(0, 8);
|
|
33
|
-
return path.join(os.tmpdir(), `
|
|
32
|
+
return path.join(os.tmpdir(), `spanner-emulator-${slug}-${hash}.lock`);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
function isPidAlive(pid) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
6
|
+
const { lockFilePathFor } = require('./emulatorLock') as { lockFilePathFor: (host: string) => string };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The lockfile path is a CONTRACT between every copy of the emulator lock: two jest runs only
|
|
10
|
+
* serialize when they compute the same file for the same emulator host. The formula is pinned
|
|
11
|
+
* here byte for byte — a neutral `spanner-emulator-` name (no application's name belongs in a
|
|
12
|
+
* shared lock), the sanitized host slug, and the first 8 hex characters of the host's sha1.
|
|
13
|
+
*/
|
|
14
|
+
describe('emulatorLock.lockFilePathFor', () => {
|
|
15
|
+
const expectedFor = (host: string, slug: string) =>
|
|
16
|
+
path.join(
|
|
17
|
+
os.tmpdir(),
|
|
18
|
+
`spanner-emulator-${slug}-${crypto.createHash('sha1').update(host).digest('hex').slice(0, 8)}.lock`
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
it('names the lock by the emulator alone: spanner-emulator-<slug>-<sha1[0..8]>.lock in os.tmpdir()', () => {
|
|
22
|
+
expect(lockFilePathFor('localhost:9010')).toBe(expectedFor('localhost:9010', 'localhost-9010'));
|
|
23
|
+
expect(lockFilePathFor('127.0.0.1:9710')).toBe(expectedFor('127.0.0.1:9710', '127-0-0-1-9710'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('gives two emulators two files, and one emulator the same file every time', () => {
|
|
27
|
+
expect(lockFilePathFor('localhost:9010')).toBe(lockFilePathFor('localhost:9010'));
|
|
28
|
+
expect(lockFilePathFor('localhost:9010')).not.toBe(lockFilePathFor('localhost:9011'));
|
|
29
|
+
// The hash keeps hosts apart even when their slugs collide.
|
|
30
|
+
expect(lockFilePathFor('localhost:9010')).not.toBe(lockFilePathFor('localhost-9010'));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
5
|
+
const { lockFilePathFor } = require('./emulatorLock') as { lockFilePathFor: (host: string) => string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The emulator lock's FILE NAME is a contract between every copy of the lock: two jest runs
|
|
9
|
+
* serialize on a shared emulator only when they compute the same file for the same host. No
|
|
10
|
+
* shared package carries the lock yet, so the contract is pinned by value — this table is the
|
|
11
|
+
* same, row for row, in every repository that keeps a copy. A change to a row here is a change
|
|
12
|
+
* to all of them, made in the same window; a copy that drifts stops contending, silently.
|
|
13
|
+
*/
|
|
14
|
+
// [emulator host, lock file name, what the row pins]
|
|
15
|
+
const LOCK_FILE_NAMES: [string, string, string][] = [
|
|
16
|
+
['localhost:9010', 'spanner-emulator-localhost-9010-0091e79a.lock', 'the default shared emulator'],
|
|
17
|
+
[
|
|
18
|
+
'localhost-9010',
|
|
19
|
+
'spanner-emulator-localhost-9010-493b66d1.lock',
|
|
20
|
+
'the same slug as the row above: the hash alone keeps two hosts apart',
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'[emulator.a-host-name-long-enough-to-run-past-the-sixty-four-cap.example.test]:9010',
|
|
24
|
+
'spanner-emulator-emulator-a-host-name-long-enough-to-run-past-the-sixty-four-cap--fe958a1a.lock',
|
|
25
|
+
'edge dashes trimmed first, the slug capped at 64 second (the cap lands on a dash, and it stays)',
|
|
26
|
+
],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe('the emulator lock file name (the contract every copy of the lock shares)', () => {
|
|
30
|
+
it.each(LOCK_FILE_NAMES)('%s locks on %s (%s)', (host, fileName) => {
|
|
31
|
+
expect(lockFilePathFor(host)).toBe(path.join(os.tmpdir(), fileName));
|
|
32
|
+
});
|
|
33
|
+
});
|