@proteinjs/user-server 1.21.0 → 1.22.0
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 +22 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +5 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authentication/PasswordResetToken.d.ts +45 -0
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -0
- package/dist/src/authentication/PasswordResetToken.js +138 -0
- package/dist/src/authentication/PasswordResetToken.js.map +1 -0
- package/dist/src/routes/devMail.d.ts +4 -0
- package/dist/src/routes/devMail.d.ts.map +1 -0
- package/dist/src/routes/devMail.js +119 -0
- package/dist/src/routes/devMail.js.map +1 -0
- package/dist/src/routes/executePasswordReset.d.ts +4 -4
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +27 -26
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +2 -2
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +13 -16
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/test/DevMail.test.d.ts +2 -0
- package/dist/test/DevMail.test.d.ts.map +1 -0
- package/dist/test/DevMail.test.js +250 -0
- package/dist/test/DevMail.test.js.map +1 -0
- package/dist/test/ExecutePasswordReset.test.d.ts +2 -0
- package/dist/test/ExecutePasswordReset.test.d.ts.map +1 -0
- package/dist/test/ExecutePasswordReset.test.js +394 -0
- package/dist/test/ExecutePasswordReset.test.js.map +1 -0
- package/dist/test/ValidateResetToken.test.js +30 -7
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/generated/index.ts +5 -1
- package/package.json +3 -3
- package/src/authentication/PasswordResetToken.ts +96 -0
- package/src/routes/devMail.ts +72 -0
- package/src/routes/executePasswordReset.ts +25 -24
- package/src/routes/initiatePasswordReset.ts +2 -2
- package/src/routes/validateResetPasswordToken.ts +12 -15
- package/test/DevMail.test.ts +151 -0
- package/test/ExecutePasswordReset.test.ts +224 -0
- package/test/ValidateResetToken.test.ts +24 -6
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import moment, { Moment } from 'moment';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { tables, User } from '@proteinjs/user';
|
|
5
|
+
import { executePasswordReset } from '../src/routes/executePasswordReset';
|
|
6
|
+
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
7
|
+
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
8
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
9
|
+
|
|
10
|
+
const testEnv = new UserServerTestEnvironment();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `POST /user/execute-password-reset`. The token is the only credential this route accepts, so
|
|
14
|
+
* the lookup must never be built from anything but a well-formed token: a `null` in the body
|
|
15
|
+
* renders as `IS NULL` and matches every account with no pending reset, an empty string matches
|
|
16
|
+
* 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: 400, and no password in the table changes — an
|
|
19
|
+
* account with no pending reset is never matched, whatever the request carries;
|
|
20
|
+
* - a live token resets the password once: the row verifies the new password, the token and its
|
|
21
|
+
* expiry are cleared, and the same token presented again is refused;
|
|
22
|
+
* - an expired token, or a token whose row carries no expiry, is refused;
|
|
23
|
+
* - a blank new password is refused without consuming the token;
|
|
24
|
+
* - the presented token never reaches the log;
|
|
25
|
+
* - the token owner's own contract: mint shape, the stored-vs-presented match, liveness, and the
|
|
26
|
+
* conditional redemption.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
type RouteOutcome = { status: number; body?: any };
|
|
30
|
+
|
|
31
|
+
const invokeExecute = async (body: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
32
|
+
const outcome: RouteOutcome = { status: 200 };
|
|
33
|
+
const response = {
|
|
34
|
+
status(code: number) {
|
|
35
|
+
outcome.status = code;
|
|
36
|
+
return this;
|
|
37
|
+
},
|
|
38
|
+
send(body?: unknown) {
|
|
39
|
+
outcome.body = body;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
await executePasswordReset.onRequest({ body } as never, response as never);
|
|
43
|
+
return outcome;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const mintToken = () => new PasswordResetToken().mint();
|
|
47
|
+
|
|
48
|
+
const armResetToken = async (email: string, token: string | null, expiration: Moment | null): Promise<User> => {
|
|
49
|
+
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
50
|
+
await getDbAsSystem().update(tables.User, {
|
|
51
|
+
id: user.id,
|
|
52
|
+
passwordResetToken: token,
|
|
53
|
+
passwordResetTokenExpiration: expiration,
|
|
54
|
+
});
|
|
55
|
+
return user;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
|
|
59
|
+
|
|
60
|
+
/** Every password in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
61
|
+
const passwordsByEmail = async (): Promise<Record<string, string>> => {
|
|
62
|
+
const byEmail: Record<string, string> = {};
|
|
63
|
+
for (const user of await getDbAsSystem().query(tables.User, {})) {
|
|
64
|
+
byEmail[user.email] = user.password;
|
|
65
|
+
}
|
|
66
|
+
return byEmail;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type TokenInternals = {
|
|
70
|
+
matches(stored: string | null | undefined, presented: string): boolean;
|
|
71
|
+
isLive(expiration: Moment | null | undefined): boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('executePasswordReset route', () => {
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
await testEnv.beforeAll();
|
|
77
|
+
// No pending reset — the account a `null` token's `IS NULL` filter would match.
|
|
78
|
+
await testEnv.createUser({ name: 'Idle User', email: 'reset-idle@test.local' });
|
|
79
|
+
// An emptied token column — the account an empty token would match.
|
|
80
|
+
await armResetToken('reset-emptied@test.local', '', moment().add(1, 'hour'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await testEnv.afterAll();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('a request without a well-formed token is refused before any lookup', () => {
|
|
88
|
+
it.each([
|
|
89
|
+
['absent', {}],
|
|
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' });
|
|
100
|
+
|
|
101
|
+
expect(outcome.status).toBe(400);
|
|
102
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
103
|
+
expect(await passwordsByEmail()).toEqual(before);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('a live token resets the password once: the row verifies it, the token clears, a re-presentation is refused', async () => {
|
|
108
|
+
const token = mintToken();
|
|
109
|
+
const user = await armResetToken('reset-live@test.local', token, moment().add(1, 'hour'));
|
|
110
|
+
|
|
111
|
+
const first = await invokeExecute({ token, newPassword: 'first new password' });
|
|
112
|
+
|
|
113
|
+
expect(first.status).toBe(200);
|
|
114
|
+
expect(first.body).toEqual({ message: 'Password has been successfully reset' });
|
|
115
|
+
const afterFirst = await userRow(user.id);
|
|
116
|
+
await expect(new PasswordHasher().verify(afterFirst.password, 'first new password')).resolves.toBe(true);
|
|
117
|
+
expect(afterFirst.passwordResetToken).toBeNull();
|
|
118
|
+
expect(afterFirst.passwordResetTokenExpiration).toBeNull();
|
|
119
|
+
|
|
120
|
+
const second = await invokeExecute({ token, newPassword: 'second new password' });
|
|
121
|
+
|
|
122
|
+
expect(second.status).toBe(400);
|
|
123
|
+
expect((await userRow(user.id)).password).toBe(afterFirst.password);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('an expired token is refused and changes nothing', async () => {
|
|
127
|
+
const token = mintToken();
|
|
128
|
+
const user = await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
|
|
129
|
+
const before = (await userRow(user.id)).password;
|
|
130
|
+
|
|
131
|
+
const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
|
|
132
|
+
|
|
133
|
+
expect(outcome.status).toBe(400);
|
|
134
|
+
expect(outcome.body).toEqual({ error: 'Reset token has expired' });
|
|
135
|
+
const row = await userRow(user.id);
|
|
136
|
+
expect(row.password).toBe(before);
|
|
137
|
+
expect(row.passwordResetToken).toBe(token);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('a token whose row carries no expiry is refused — never live by omission', async () => {
|
|
141
|
+
const token = mintToken();
|
|
142
|
+
const user = await armResetToken('reset-no-expiry@test.local', token, null);
|
|
143
|
+
const before = (await userRow(user.id)).password;
|
|
144
|
+
|
|
145
|
+
const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
|
|
146
|
+
|
|
147
|
+
expect(outcome.status).toBe(400);
|
|
148
|
+
expect((await userRow(user.id)).password).toBe(before);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('a blank new password is refused without consuming the token', async () => {
|
|
152
|
+
const token = mintToken();
|
|
153
|
+
const user = await armResetToken('reset-blank@test.local', token, moment().add(1, 'hour'));
|
|
154
|
+
const before = (await userRow(user.id)).password;
|
|
155
|
+
|
|
156
|
+
for (const newPassword of ['', undefined, 42]) {
|
|
157
|
+
const outcome = await invokeExecute({ token, newPassword });
|
|
158
|
+
expect(outcome.status).toBe(400);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const row = await userRow(user.id);
|
|
162
|
+
expect(row.password).toBe(before);
|
|
163
|
+
expect(row.passwordResetToken).toBe(token);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('the presented token never reaches the log', async () => {
|
|
167
|
+
const token = mintToken(); // well-formed, never issued
|
|
168
|
+
const info = jest.spyOn(Logger.prototype, 'info');
|
|
169
|
+
try {
|
|
170
|
+
await invokeExecute({ token, newPassword: 'hijacked' });
|
|
171
|
+
|
|
172
|
+
expect(info).toHaveBeenCalled();
|
|
173
|
+
for (const [entry] of info.mock.calls) {
|
|
174
|
+
expect(JSON.stringify(entry)).not.toContain(token);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
info.mockRestore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('PasswordResetToken', () => {
|
|
182
|
+
const internals = new PasswordResetToken() as unknown as TokenInternals;
|
|
183
|
+
|
|
184
|
+
it('mints 64 lowercase hex characters, fresh each time', () => {
|
|
185
|
+
const token = mintToken();
|
|
186
|
+
|
|
187
|
+
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
188
|
+
expect(mintToken()).not.toBe(token);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('a stored token matches only the same string — never an absent or emptied one', () => {
|
|
192
|
+
const token = mintToken();
|
|
193
|
+
|
|
194
|
+
expect(internals.matches(token, token)).toBe(true);
|
|
195
|
+
expect(internals.matches(mintToken(), token)).toBe(false);
|
|
196
|
+
expect(internals.matches(null, token)).toBe(false);
|
|
197
|
+
expect(internals.matches(undefined, token)).toBe(false);
|
|
198
|
+
expect(internals.matches('', token)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('an expiry is live only as a valid timestamp in the future', () => {
|
|
202
|
+
expect(internals.isLive(moment().add(1, 'minute'))).toBe(true);
|
|
203
|
+
expect(internals.isLive(moment().subtract(1, 'minute'))).toBe(false);
|
|
204
|
+
expect(internals.isLive(null)).toBe(false);
|
|
205
|
+
expect(internals.isLive(undefined)).toBe(false);
|
|
206
|
+
expect(internals.isLive(moment.invalid())).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('redeem writes only while the row still carries the token', async () => {
|
|
210
|
+
const live = mintToken();
|
|
211
|
+
const user = await armResetToken('reset-redeem@test.local', live, moment().add(1, 'hour'));
|
|
212
|
+
const before = (await userRow(user.id)).password;
|
|
213
|
+
|
|
214
|
+
await expect(new PasswordResetToken().redeem(user, mintToken(), 'other-hash')).resolves.toBe(false);
|
|
215
|
+
expect((await userRow(user.id)).password).toBe(before);
|
|
216
|
+
|
|
217
|
+
await expect(new PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true);
|
|
218
|
+
const row = await userRow(user.id);
|
|
219
|
+
expect(row.password).toBe('new-hash');
|
|
220
|
+
expect(row.passwordResetToken).toBeNull();
|
|
221
|
+
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -2,6 +2,7 @@ import moment from 'moment';
|
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import { tables } from '@proteinjs/user';
|
|
4
4
|
import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
|
|
5
|
+
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
5
6
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
6
7
|
|
|
7
8
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -12,7 +13,9 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
12
13
|
* `autocomplete="username"` field so password managers can associate the updated password
|
|
13
14
|
* with the stored credential. The email only ever rides a VALID token's response — the
|
|
14
15
|
* token was delivered to that very inbox, so it reveals nothing the holder doesn't know —
|
|
15
|
-
* while invalid/expired verdicts stay email-free (no account-probing oracle).
|
|
16
|
+
* while invalid/expired verdicts stay email-free (no account-probing oracle). A query value
|
|
17
|
+
* that is not a well-formed token (a parsed object or array, a string of another shape) is
|
|
18
|
+
* invalid without a lookup.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
type RouteOutcome = { status?: number; body?: any };
|
|
@@ -32,6 +35,8 @@ const invokeValidate = async (query: Record<string, unknown>): Promise<RouteOutc
|
|
|
32
35
|
return outcome;
|
|
33
36
|
};
|
|
34
37
|
|
|
38
|
+
const mintToken = () => new PasswordResetToken().mint();
|
|
39
|
+
|
|
35
40
|
const armResetToken = async (email: string, token: string, expiration: moment.Moment) => {
|
|
36
41
|
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
37
42
|
await getDbAsSystem().update(tables.User, {
|
|
@@ -51,18 +56,20 @@ describe('validateResetPasswordToken route', () => {
|
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
it('a valid token resolves isValid WITH the account email (the reset form identifier)', async () => {
|
|
54
|
-
|
|
59
|
+
const token = mintToken();
|
|
60
|
+
await armResetToken('reset-valid@test.local', token, moment().add(1, 'hour'));
|
|
55
61
|
|
|
56
|
-
const outcome = await invokeValidate({ token
|
|
62
|
+
const outcome = await invokeValidate({ token });
|
|
57
63
|
|
|
58
64
|
expect(outcome.status).toBe(200);
|
|
59
65
|
expect(outcome.body).toEqual({ isValid: true, email: 'reset-valid@test.local' });
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
it('an expired token resolves invalid and leaks no email', async () => {
|
|
63
|
-
|
|
69
|
+
const token = mintToken();
|
|
70
|
+
await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
|
|
64
71
|
|
|
65
|
-
const outcome = await invokeValidate({ token
|
|
72
|
+
const outcome = await invokeValidate({ token });
|
|
66
73
|
|
|
67
74
|
expect(outcome.status).toBe(200);
|
|
68
75
|
expect(outcome.body.isValid).toBe(false);
|
|
@@ -70,13 +77,24 @@ describe('validateResetPasswordToken route', () => {
|
|
|
70
77
|
});
|
|
71
78
|
|
|
72
79
|
it('an unknown token resolves invalid and leaks no email', async () => {
|
|
73
|
-
const outcome = await invokeValidate({ token:
|
|
80
|
+
const outcome = await invokeValidate({ token: mintToken() });
|
|
74
81
|
|
|
75
82
|
expect(outcome.status).toBe(200);
|
|
76
83
|
expect(outcome.body.isValid).toBe(false);
|
|
77
84
|
expect(outcome.body.email).toBeUndefined();
|
|
78
85
|
});
|
|
79
86
|
|
|
87
|
+
it.each([
|
|
88
|
+
['a string of another shape', 'tok-never-issued'],
|
|
89
|
+
['a parsed array', ['a', 'b']],
|
|
90
|
+
['a parsed object', { passwordResetToken: null }],
|
|
91
|
+
])('%s resolves invalid without a lookup and leaks no email', async (_label, token) => {
|
|
92
|
+
const outcome = await invokeValidate({ token });
|
|
93
|
+
|
|
94
|
+
expect(outcome.status).toBe(200);
|
|
95
|
+
expect(outcome.body).toEqual({ isValid: false, message: 'Invalid token' });
|
|
96
|
+
});
|
|
97
|
+
|
|
80
98
|
it('a missing token is a 400', async () => {
|
|
81
99
|
const outcome = await invokeValidate({});
|
|
82
100
|
|