@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
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
1
2
|
import moment, { Moment } from 'moment';
|
|
2
3
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
-
import { Logger } from '@proteinjs/logger';
|
|
4
4
|
import { tables, User } from '@proteinjs/user';
|
|
5
5
|
import { executePasswordReset } from '../src/routes/executePasswordReset';
|
|
6
6
|
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
7
7
|
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
8
8
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
9
|
+
import { DbTraffic } from './DbTraffic';
|
|
10
|
+
import { LogCapture } from './LogCapture';
|
|
11
|
+
import { MalformedResetTokens } from './MalformedResetTokens';
|
|
9
12
|
|
|
10
13
|
const testEnv = new UserServerTestEnvironment();
|
|
11
14
|
|
|
@@ -14,21 +17,36 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
14
17
|
* the lookup must never be built from anything but a well-formed token: a `null` in the body
|
|
15
18
|
* renders as `IS NULL` and matches every account with no pending reset, an empty string matches
|
|
16
19
|
* an emptied column, and other types reach the driver. Covered here, outcomes only (rows
|
|
17
|
-
* written), against the Spanner emulator:
|
|
18
|
-
* - an absent, null, empty or malformed token
|
|
19
|
-
*
|
|
20
|
+
* written, statements run, lines logged), against the Spanner emulator:
|
|
21
|
+
* - an absent, null, empty or malformed token — every type a body can carry and every near-miss
|
|
22
|
+
* cut from a live token: 400, nothing in the table changes, and nothing reaches the database
|
|
23
|
+
* (no lookup built, no statement run); the live token still works afterwards. With a digest
|
|
24
|
+
* lookup a wrongly admitted value still matches no row and still answers 400, so the response
|
|
25
|
+
* alone proves nothing about the shape check — the absence of database traffic does;
|
|
26
|
+
* - a request with no body at all: 400, and nothing changes;
|
|
27
|
+
* - the row stores the token's SHA-256 digest, never the token: the stored digest presented as
|
|
28
|
+
* a token is refused — it has a token's shape, so one lookup is built, by the digest OF the
|
|
29
|
+
* presented value, and nothing is written — and so is a token an earlier release stored in clear;
|
|
20
30
|
* - a live token resets the password once: the row verifies the new password, the token and its
|
|
21
31
|
* expiry are cleared, and the same token presented again is refused;
|
|
32
|
+
* - two simultaneous presentations of one token: exactly one 200, and the row verifies only the
|
|
33
|
+
* winner's password;
|
|
22
34
|
* - an expired token, or a token whose row carries no expiry, is refused;
|
|
23
35
|
* - a blank new password is refused without consuming the token;
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
36
|
+
* - no token, no leading characters of a token and no full digest reach the log, on any path
|
|
37
|
+
* through the route;
|
|
38
|
+
* - the token owner's own contract: mint shape and what it stores, the shape refusal, the
|
|
39
|
+
* stored-vs-presented digest match, liveness, the conditional redemption (one at a time and
|
|
40
|
+
* concurrent), the withdrawal, the mint time, and the log reference.
|
|
41
|
+
*
|
|
42
|
+
* Every live token is seeded through `PasswordResetToken.mint`, so the suite moves with what the
|
|
43
|
+
* owner stores. Only the two named column states the owner never writes are seeded raw: an
|
|
44
|
+
* emptied column, and a token stored in clear by a release that predates the digest.
|
|
27
45
|
*/
|
|
28
46
|
|
|
29
47
|
type RouteOutcome = { status: number; body?: any };
|
|
30
48
|
|
|
31
|
-
const
|
|
49
|
+
const invokeExecuteRequest = async (request: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
32
50
|
const outcome: RouteOutcome = { status: 200 };
|
|
33
51
|
const response = {
|
|
34
52
|
status(code: number) {
|
|
@@ -39,35 +57,68 @@ const invokeExecute = async (body: Record<string, unknown>): Promise<RouteOutcom
|
|
|
39
57
|
outcome.body = body;
|
|
40
58
|
},
|
|
41
59
|
};
|
|
42
|
-
await executePasswordReset.onRequest(
|
|
60
|
+
await executePasswordReset.onRequest(request as never, response as never);
|
|
43
61
|
return outcome;
|
|
44
62
|
};
|
|
45
63
|
|
|
46
|
-
const
|
|
64
|
+
const invokeExecute = async (body: Record<string, unknown>) => await invokeExecuteRequest({ body });
|
|
47
65
|
|
|
48
|
-
|
|
66
|
+
/** The digest a row must hold for `token`, computed here independently of the owner. */
|
|
67
|
+
const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
68
|
+
|
|
69
|
+
/** A well-formed token that was never issued to anyone. */
|
|
70
|
+
const unissuedToken = () => sha256Hex(`never issued ${Math.random()}`);
|
|
71
|
+
|
|
72
|
+
/** A user with a token minted by the owner; `expiration` (when given) replaces the expiry the mint stored. */
|
|
73
|
+
const armResetToken = async (email: string, expiration?: Moment | null): Promise<{ user: User; token: string }> => {
|
|
74
|
+
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
75
|
+
const token = await new PasswordResetToken().mint(user);
|
|
76
|
+
if (expiration !== undefined) {
|
|
77
|
+
await getDbAsSystem().update(tables.User, { id: user.id, passwordResetTokenExpiration: expiration });
|
|
78
|
+
}
|
|
79
|
+
return { user, token };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** A user whose token column is written raw — only for the column states the owner never writes. */
|
|
83
|
+
const armRawTokenColumn = async (email: string, stored: string): Promise<User> => {
|
|
49
84
|
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
50
85
|
await getDbAsSystem().update(tables.User, {
|
|
51
86
|
id: user.id,
|
|
52
|
-
passwordResetToken:
|
|
53
|
-
passwordResetTokenExpiration:
|
|
87
|
+
passwordResetToken: stored,
|
|
88
|
+
passwordResetTokenExpiration: moment().add(1, 'hour'),
|
|
54
89
|
});
|
|
55
90
|
return user;
|
|
56
91
|
};
|
|
57
92
|
|
|
58
93
|
const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
|
|
59
94
|
|
|
60
|
-
/** Every password in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
61
|
-
const
|
|
62
|
-
const byEmail: Record<string,
|
|
95
|
+
/** Every password, stored token and expiry in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
96
|
+
const resetStateByEmail = async (): Promise<Record<string, unknown[]>> => {
|
|
97
|
+
const byEmail: Record<string, unknown[]> = {};
|
|
63
98
|
for (const user of await getDbAsSystem().query(tables.User, {})) {
|
|
64
|
-
byEmail[user.email] =
|
|
99
|
+
byEmail[user.email] = [
|
|
100
|
+
user.password,
|
|
101
|
+
user.passwordResetToken ?? null,
|
|
102
|
+
user.passwordResetTokenExpiration ? moment(user.passwordResetTokenExpiration).toISOString() : null,
|
|
103
|
+
];
|
|
65
104
|
}
|
|
66
105
|
return byEmail;
|
|
67
106
|
};
|
|
68
107
|
|
|
108
|
+
/** Which of `candidates` the stored password hash verifies. */
|
|
109
|
+
const verifiedPasswords = async (storedHash: string, candidates: string[]): Promise<string[]> => {
|
|
110
|
+
const verified: string[] = [];
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
if (await new PasswordHasher().verify(storedHash, candidate)) {
|
|
113
|
+
verified.push(candidate);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return verified;
|
|
117
|
+
};
|
|
118
|
+
|
|
69
119
|
type TokenInternals = {
|
|
70
|
-
|
|
120
|
+
digest(token: string): string;
|
|
121
|
+
matches(storedDigest: string | null | undefined, presentedDigest: string): boolean;
|
|
71
122
|
isLive(expiration: Moment | null | undefined): boolean;
|
|
72
123
|
};
|
|
73
124
|
|
|
@@ -77,7 +128,7 @@ describe('executePasswordReset route', () => {
|
|
|
77
128
|
// No pending reset — the account a `null` token's `IS NULL` filter would match.
|
|
78
129
|
await testEnv.createUser({ name: 'Idle User', email: 'reset-idle@test.local' });
|
|
79
130
|
// An emptied token column — the account an empty token would match.
|
|
80
|
-
await
|
|
131
|
+
await armRawTokenColumn('reset-emptied@test.local', '');
|
|
81
132
|
});
|
|
82
133
|
|
|
83
134
|
afterAll(async () => {
|
|
@@ -85,28 +136,55 @@ describe('executePasswordReset route', () => {
|
|
|
85
136
|
});
|
|
86
137
|
|
|
87
138
|
describe('a request without a well-formed token is refused before any lookup', () => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
['null', { token: null }],
|
|
91
|
-
['empty', { token: '' }],
|
|
92
|
-
['not a token string', { token: 'tok-valid-1' }],
|
|
93
|
-
['a number', { token: 123 }],
|
|
94
|
-
['an array', { token: ['a', 'b'] }],
|
|
95
|
-
['an object', { token: { passwordResetToken: null } }],
|
|
96
|
-
])('%s: 400, and no password in the table changes', async (_label, tokenField) => {
|
|
97
|
-
const before = await passwordsByEmail();
|
|
98
|
-
|
|
99
|
-
const outcome = await invokeExecute({ ...tokenField, newPassword: 'hijacked' });
|
|
139
|
+
let liveUser: User;
|
|
140
|
+
let liveToken: string;
|
|
100
141
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expect(await passwordsByEmail()).toEqual(before);
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
({ user: liveUser, token: liveToken } = await armResetToken('reset-shapes@test.local'));
|
|
104
144
|
});
|
|
145
|
+
|
|
146
|
+
it.each(MalformedResetTokens.CASES)(
|
|
147
|
+
'%s: 400, nothing in the table changes, and nothing reaches the database',
|
|
148
|
+
async (_label, tokenField) => {
|
|
149
|
+
const before = await resetStateByEmail();
|
|
150
|
+
|
|
151
|
+
const { result: outcome, traffic } = await DbTraffic.during(
|
|
152
|
+
testEnv.spannerDriver,
|
|
153
|
+
async () => await invokeExecute({ ...tokenField(liveToken), newPassword: 'hijacked' })
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(outcome.status).toBe(400);
|
|
157
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
158
|
+
expect(traffic).toEqual(DbTraffic.NONE);
|
|
159
|
+
expect(await resetStateByEmail()).toEqual(before);
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it('after every refusal the live token still resets the password', async () => {
|
|
164
|
+
const outcome = await invokeExecute({ token: liveToken, newPassword: 'still live' });
|
|
165
|
+
|
|
166
|
+
expect(outcome.status).toBe(200);
|
|
167
|
+
const row = await userRow(liveUser.id);
|
|
168
|
+
await expect(new PasswordHasher().verify(row.password, 'still live')).resolves.toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it.each([
|
|
173
|
+
['no body', 'reset-no-body@test.local', {}],
|
|
174
|
+
['a null body', 'reset-null-body@test.local', { body: null }],
|
|
175
|
+
])('a request with %s: 400, and nothing changes', async (_label, email, request) => {
|
|
176
|
+
const { user, token } = await armResetToken(email);
|
|
177
|
+
const before = await resetStateByEmail();
|
|
178
|
+
|
|
179
|
+
const outcome = await invokeExecuteRequest(request);
|
|
180
|
+
|
|
181
|
+
expect(outcome.status).toBe(400);
|
|
182
|
+
expect(await resetStateByEmail()).toEqual(before);
|
|
183
|
+
expect((await userRow(user.id)).passwordResetToken).toBe(sha256Hex(token));
|
|
105
184
|
});
|
|
106
185
|
|
|
107
186
|
it('a live token resets the password once: the row verifies it, the token clears, a re-presentation is refused', async () => {
|
|
108
|
-
const token =
|
|
109
|
-
const user = await armResetToken('reset-live@test.local', token, moment().add(1, 'hour'));
|
|
187
|
+
const { user, token } = await armResetToken('reset-live@test.local');
|
|
110
188
|
|
|
111
189
|
const first = await invokeExecute({ token, newPassword: 'first new password' });
|
|
112
190
|
|
|
@@ -123,9 +201,79 @@ describe('executePasswordReset route', () => {
|
|
|
123
201
|
expect((await userRow(user.id)).password).toBe(afterFirst.password);
|
|
124
202
|
});
|
|
125
203
|
|
|
204
|
+
it("two simultaneous presentations of one token: exactly one 200, and the row verifies only the winner's password", async () => {
|
|
205
|
+
const { user, token } = await armResetToken('reset-race@test.local');
|
|
206
|
+
const candidates = ['racer one password', 'racer two password'];
|
|
207
|
+
// Both requests resolve the token as live and hash their password; the barrier then releases
|
|
208
|
+
// them into the redemption together, so neither has written when the other arrives.
|
|
209
|
+
const hashPassword = PasswordHasher.prototype.hash;
|
|
210
|
+
let release: () => void = () => undefined;
|
|
211
|
+
const bothHashed = new Promise<void>((resolve) => (release = resolve));
|
|
212
|
+
let hashed = 0;
|
|
213
|
+
const hash = jest.spyOn(PasswordHasher.prototype, 'hash').mockImplementation(async function (
|
|
214
|
+
this: PasswordHasher,
|
|
215
|
+
password: string
|
|
216
|
+
) {
|
|
217
|
+
const hashedPassword = await hashPassword.call(this, password);
|
|
218
|
+
if (++hashed === candidates.length) {
|
|
219
|
+
release();
|
|
220
|
+
}
|
|
221
|
+
await bothHashed;
|
|
222
|
+
return hashedPassword;
|
|
223
|
+
});
|
|
224
|
+
let outcomes: RouteOutcome[];
|
|
225
|
+
try {
|
|
226
|
+
outcomes = await Promise.all(candidates.map((newPassword) => invokeExecute({ token, newPassword })));
|
|
227
|
+
} finally {
|
|
228
|
+
hash.mockRestore();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
expect(outcomes.map((outcome) => outcome.status).sort()).toEqual([200, 400]);
|
|
232
|
+
const winner = candidates[outcomes.findIndex((outcome) => outcome.status === 200)];
|
|
233
|
+
const row = await userRow(user.id);
|
|
234
|
+
expect(await verifiedPasswords(row.password, candidates)).toEqual([winner]);
|
|
235
|
+
expect(row.passwordResetToken).toBeNull();
|
|
236
|
+
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('the row stores the SHA-256 digest of the token, never the token — and the digest presented as a token is refused', async () => {
|
|
240
|
+
const { user, token } = await armResetToken('reset-digest@test.local');
|
|
241
|
+
const armed = await userRow(user.id);
|
|
242
|
+
|
|
243
|
+
expect(armed.passwordResetToken).toBe(sha256Hex(token));
|
|
244
|
+
expect(JSON.stringify(armed)).not.toContain(token);
|
|
245
|
+
|
|
246
|
+
// A digest has a token's shape, so this presentation does reach the lookup — which is built
|
|
247
|
+
// from the digest OF the presented value, so what the row stores never equals it.
|
|
248
|
+
const { result: outcome, traffic } = await DbTraffic.during(
|
|
249
|
+
testEnv.spannerDriver,
|
|
250
|
+
async () => await invokeExecute({ token: armed.passwordResetToken, newPassword: 'hijacked' })
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(outcome.status).toBe(400);
|
|
254
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
255
|
+
expect(traffic.lookups).toEqual([{ passwordResetToken: sha256Hex(sha256Hex(token)) }]);
|
|
256
|
+
expect(traffic.statements).toBe(1);
|
|
257
|
+
expect(traffic.writes).toBe(0);
|
|
258
|
+
const row = await userRow(user.id);
|
|
259
|
+
expect(row.password).toBe(armed.password);
|
|
260
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('a token an earlier release stored in clear is refused — it fails for the rest of its hour, no migration', async () => {
|
|
264
|
+
const clearToken = unissuedToken();
|
|
265
|
+
const user = await armRawTokenColumn('reset-stored-in-clear@test.local', clearToken);
|
|
266
|
+
const before = (await userRow(user.id)).password;
|
|
267
|
+
|
|
268
|
+
const outcome = await invokeExecute({ token: clearToken, newPassword: 'hijacked' });
|
|
269
|
+
|
|
270
|
+
expect(outcome.status).toBe(400);
|
|
271
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
272
|
+
expect((await userRow(user.id)).password).toBe(before);
|
|
273
|
+
});
|
|
274
|
+
|
|
126
275
|
it('an expired token is refused and changes nothing', async () => {
|
|
127
|
-
const token =
|
|
128
|
-
const user = await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
|
|
276
|
+
const { user, token } = await armResetToken('reset-expired@test.local', moment().subtract(1, 'minute'));
|
|
129
277
|
const before = (await userRow(user.id)).password;
|
|
130
278
|
|
|
131
279
|
const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
|
|
@@ -134,12 +282,11 @@ describe('executePasswordReset route', () => {
|
|
|
134
282
|
expect(outcome.body).toEqual({ error: 'Reset token has expired' });
|
|
135
283
|
const row = await userRow(user.id);
|
|
136
284
|
expect(row.password).toBe(before);
|
|
137
|
-
expect(row.passwordResetToken).toBe(token);
|
|
285
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
138
286
|
});
|
|
139
287
|
|
|
140
288
|
it('a token whose row carries no expiry is refused — never live by omission', async () => {
|
|
141
|
-
const token =
|
|
142
|
-
const user = await armResetToken('reset-no-expiry@test.local', token, null);
|
|
289
|
+
const { user, token } = await armResetToken('reset-no-expiry@test.local', null);
|
|
143
290
|
const before = (await userRow(user.id)).password;
|
|
144
291
|
|
|
145
292
|
const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
|
|
@@ -149,8 +296,7 @@ describe('executePasswordReset route', () => {
|
|
|
149
296
|
});
|
|
150
297
|
|
|
151
298
|
it('a blank new password is refused without consuming the token', async () => {
|
|
152
|
-
const token =
|
|
153
|
-
const user = await armResetToken('reset-blank@test.local', token, moment().add(1, 'hour'));
|
|
299
|
+
const { user, token } = await armResetToken('reset-blank@test.local');
|
|
154
300
|
const before = (await userRow(user.id)).password;
|
|
155
301
|
|
|
156
302
|
for (const newPassword of ['', undefined, 42]) {
|
|
@@ -160,42 +306,98 @@ describe('executePasswordReset route', () => {
|
|
|
160
306
|
|
|
161
307
|
const row = await userRow(user.id);
|
|
162
308
|
expect(row.password).toBe(before);
|
|
163
|
-
expect(row.passwordResetToken).toBe(token);
|
|
309
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
164
310
|
});
|
|
165
311
|
|
|
166
|
-
it('the
|
|
167
|
-
const token =
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
312
|
+
it('no token, no leading characters of one and no full digest reach the log — every path through the route', async () => {
|
|
313
|
+
const { token: live } = await armResetToken('reset-log-live@test.local');
|
|
314
|
+
const { token: expired } = await armResetToken('reset-log-expired@test.local', moment().subtract(1, 'minute'));
|
|
315
|
+
const unissued = unissuedToken();
|
|
316
|
+
|
|
317
|
+
const log = await LogCapture.during(async () => {
|
|
318
|
+
await invokeExecute({ token: unissued, newPassword: 'hijacked' });
|
|
319
|
+
await invokeExecute({ token: live.toUpperCase(), newPassword: 'hijacked' });
|
|
320
|
+
await invokeExecute({ token: sha256Hex(live), newPassword: 'hijacked' });
|
|
321
|
+
await invokeExecute({ token: expired, newPassword: 'hijacked' });
|
|
322
|
+
await invokeExecute({ token: live, newPassword: 'a logged password' });
|
|
323
|
+
await invokeExecute({ token: live, newPassword: 'a second logged password' });
|
|
324
|
+
});
|
|
171
325
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
326
|
+
// The capture saw the route's lines: each path above logged.
|
|
327
|
+
expect(log.text).toContain('Invalid reset token used');
|
|
328
|
+
expect(log.text).toContain('Expired reset token used');
|
|
329
|
+
expect(log.text).toContain('Password successfully reset');
|
|
330
|
+
expect(log.lines.length).toBeGreaterThanOrEqual(6);
|
|
331
|
+
// The digest presented as a token is covered by `live`: its full digest is what was presented.
|
|
332
|
+
for (const token of [unissued, live, expired]) {
|
|
333
|
+
log.expectFreeOf(token);
|
|
178
334
|
}
|
|
179
335
|
});
|
|
180
336
|
|
|
181
337
|
describe('PasswordResetToken', () => {
|
|
182
338
|
const internals = new PasswordResetToken() as unknown as TokenInternals;
|
|
183
339
|
|
|
184
|
-
it('mints 64 lowercase hex characters, fresh each time', () => {
|
|
185
|
-
const
|
|
340
|
+
it('mints 64 lowercase hex characters, fresh each time, and stores the digest with an expiry an hour out', async () => {
|
|
341
|
+
const user = await testEnv.createUser({ name: 'Reset User', email: 'reset-mint@test.local' });
|
|
342
|
+
|
|
343
|
+
const first = await new PasswordResetToken().mint(user);
|
|
344
|
+
const afterFirst = await userRow(user.id);
|
|
345
|
+
const second = await new PasswordResetToken().mint(user);
|
|
346
|
+
const afterSecond = await userRow(user.id);
|
|
347
|
+
|
|
348
|
+
expect(first).toMatch(/^[0-9a-f]{64}$/);
|
|
349
|
+
expect(second).toMatch(/^[0-9a-f]{64}$/);
|
|
350
|
+
expect(second).not.toBe(first);
|
|
351
|
+
expect(afterFirst.passwordResetToken).toBe(sha256Hex(first));
|
|
352
|
+
expect(afterSecond.passwordResetToken).toBe(sha256Hex(second));
|
|
353
|
+
const minutesOut = moment(afterSecond.passwordResetTokenExpiration).diff(moment(), 'minutes', true);
|
|
354
|
+
expect(minutesOut).toBeGreaterThan(59);
|
|
355
|
+
expect(minutesOut).toBeLessThanOrEqual(60);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it.each(MalformedResetTokens.CASES)(
|
|
359
|
+
'resolve refuses %s as malformed without touching the database, and gives it no log reference',
|
|
360
|
+
async (_label, tokenField) => {
|
|
361
|
+
const presented = tokenField(unissuedToken()).token;
|
|
362
|
+
|
|
363
|
+
const { result: resolution, traffic } = await DbTraffic.during(
|
|
364
|
+
testEnv.spannerDriver,
|
|
365
|
+
async () => await new PasswordResetToken().resolve(presented)
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect(resolution).toEqual({ status: 'malformed' });
|
|
369
|
+
expect(traffic).toEqual(DbTraffic.NONE);
|
|
370
|
+
expect(new PasswordResetToken().fingerprint(presented)).toBeUndefined();
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
it('the log reference to a token is cut from its digest, never from the token: the same token reads the same, another reads differently', () => {
|
|
375
|
+
const token = unissuedToken();
|
|
376
|
+
const another = unissuedToken();
|
|
377
|
+
|
|
378
|
+
const reference = new PasswordResetToken().fingerprint(token);
|
|
186
379
|
|
|
187
|
-
expect(token).
|
|
188
|
-
expect(
|
|
380
|
+
expect(reference).toBe(sha256Hex(token).slice(0, 12));
|
|
381
|
+
expect(reference).not.toBe(token.slice(0, 12));
|
|
382
|
+
expect(new PasswordResetToken().fingerprint(token)).toBe(reference);
|
|
383
|
+
expect(new PasswordResetToken().fingerprint(another)).not.toBe(reference);
|
|
189
384
|
});
|
|
190
385
|
|
|
191
|
-
it('
|
|
192
|
-
const token =
|
|
386
|
+
it('the digest is the SHA-256 of the token, hex-encoded', () => {
|
|
387
|
+
const token = unissuedToken();
|
|
193
388
|
|
|
194
|
-
expect(internals.
|
|
195
|
-
expect(internals.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
389
|
+
expect(internals.digest(token)).toBe(sha256Hex(token));
|
|
390
|
+
expect(internals.digest(token)).not.toBe(token);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('a stored digest matches only the same digest — never an absent or emptied one', () => {
|
|
394
|
+
const digest = sha256Hex('one token');
|
|
395
|
+
|
|
396
|
+
expect(internals.matches(digest, digest)).toBe(true);
|
|
397
|
+
expect(internals.matches(sha256Hex('another token'), digest)).toBe(false);
|
|
398
|
+
expect(internals.matches(null, digest)).toBe(false);
|
|
399
|
+
expect(internals.matches(undefined, digest)).toBe(false);
|
|
400
|
+
expect(internals.matches('', digest)).toBe(false);
|
|
199
401
|
});
|
|
200
402
|
|
|
201
403
|
it('an expiry is live only as a valid timestamp in the future', () => {
|
|
@@ -206,12 +408,12 @@ describe('executePasswordReset route', () => {
|
|
|
206
408
|
expect(internals.isLive(moment.invalid())).toBe(false);
|
|
207
409
|
});
|
|
208
410
|
|
|
209
|
-
it(
|
|
210
|
-
const live =
|
|
211
|
-
const user = await armResetToken('reset-redeem@test.local', live, moment().add(1, 'hour'));
|
|
411
|
+
it("redeem writes only while the row still carries the token's digest", async () => {
|
|
412
|
+
const { user, token: live } = await armResetToken('reset-redeem@test.local');
|
|
212
413
|
const before = (await userRow(user.id)).password;
|
|
213
414
|
|
|
214
|
-
await expect(new PasswordResetToken().redeem(user,
|
|
415
|
+
await expect(new PasswordResetToken().redeem(user, unissuedToken(), 'other-hash')).resolves.toBe(false);
|
|
416
|
+
await expect(new PasswordResetToken().redeem(user, sha256Hex(live), 'other-hash')).resolves.toBe(false);
|
|
215
417
|
expect((await userRow(user.id)).password).toBe(before);
|
|
216
418
|
|
|
217
419
|
await expect(new PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true);
|
|
@@ -220,5 +422,41 @@ describe('executePasswordReset route', () => {
|
|
|
220
422
|
expect(row.passwordResetToken).toBeNull();
|
|
221
423
|
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
222
424
|
});
|
|
425
|
+
|
|
426
|
+
it('concurrent redeems of one token: exactly one resolves true, and the row holds the winner', async () => {
|
|
427
|
+
const { user, token } = await armResetToken('reset-redeem-race@test.local');
|
|
428
|
+
const hashes = ['hash-1', 'hash-2', 'hash-3', 'hash-4', 'hash-5', 'hash-6', 'hash-7', 'hash-8'];
|
|
429
|
+
|
|
430
|
+
const results = await Promise.all(hashes.map((hash) => new PasswordResetToken().redeem(user, token, hash)));
|
|
431
|
+
|
|
432
|
+
expect(results.filter((redeemed) => redeemed)).toHaveLength(1);
|
|
433
|
+
const row = await userRow(user.id);
|
|
434
|
+
expect(row.password).toBe(hashes[results.indexOf(true)]);
|
|
435
|
+
expect(row.passwordResetToken).toBeNull();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('revoke clears the row only while it still carries the token — a newer token is left alone', async () => {
|
|
439
|
+
const { user, token: withdrawn } = await armResetToken('reset-revoke@test.local');
|
|
440
|
+
const newer = await new PasswordResetToken().mint(user);
|
|
441
|
+
|
|
442
|
+
await new PasswordResetToken().revoke(user, withdrawn);
|
|
443
|
+
expect((await userRow(user.id)).passwordResetToken).toBe(sha256Hex(newer));
|
|
444
|
+
|
|
445
|
+
await new PasswordResetToken().revoke(user, newer);
|
|
446
|
+
const row = await userRow(user.id);
|
|
447
|
+
expect(row.passwordResetToken).toBeNull();
|
|
448
|
+
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
449
|
+
expect(row.password).toBe('test');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('mintedAt is the moment the outstanding token was minted — undefined when the row carries none', async () => {
|
|
453
|
+
const idle = await testEnv.createUser({ name: 'Reset User', email: 'reset-minted-at-idle@test.local' });
|
|
454
|
+
const { user } = await armResetToken('reset-minted-at@test.local');
|
|
455
|
+
|
|
456
|
+
expect(new PasswordResetToken().mintedAt(idle)).toBeUndefined();
|
|
457
|
+
const mintedAt = new PasswordResetToken().mintedAt(await userRow(user.id));
|
|
458
|
+
expect(mintedAt).toBeDefined();
|
|
459
|
+
expect(Math.abs(moment().diff(mintedAt, 'seconds'))).toBeLessThan(30);
|
|
460
|
+
});
|
|
223
461
|
});
|
|
224
462
|
});
|