@proteinjs/user-server 1.20.2 → 1.21.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 +22 -0
- package/dist/generated/index.js +1 -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/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/src/services/Signup.d.ts +14 -0
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +102 -27
- package/dist/src/services/Signup.js.map +1 -1
- 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/SignupResendInvite.integration.test.d.ts +2 -0
- package/dist/test/SignupResendInvite.integration.test.d.ts.map +1 -0
- package/dist/test/SignupResendInvite.integration.test.js +235 -0
- package/dist/test/SignupResendInvite.integration.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 +1 -1
- package/package.json +3 -3
- package/src/authentication/PasswordResetToken.ts +96 -0
- package/src/routes/executePasswordReset.ts +25 -24
- package/src/routes/initiatePasswordReset.ts +2 -2
- package/src/routes/validateResetPasswordToken.ts +12 -15
- package/src/services/Signup.ts +71 -21
- package/test/ExecutePasswordReset.test.ts +224 -0
- package/test/SignupResendInvite.integration.test.ts +163 -0
- package/test/ValidateResetToken.test.ts +24 -6
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
|
-
import {
|
|
3
|
-
import { routes, tables } from '@proteinjs/user';
|
|
2
|
+
import { routes } from '@proteinjs/user';
|
|
4
3
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
-
import moment from 'moment';
|
|
6
4
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
5
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Route handler for executing a password reset.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Resolves the presented token through `PasswordResetToken` — which refuses anything but a
|
|
11
|
+
* well-formed token before any lookup — checks its expiry, and redeems it: the new password is
|
|
12
|
+
* written and the token cleared in one conditional update, so a token resets a password once.
|
|
13
|
+
* The token itself never reaches the log.
|
|
13
14
|
*
|
|
14
15
|
* @bodyParam {string} token - The password reset token.
|
|
15
16
|
* @bodyParam {string} newPassword - The new password for the user.
|
|
16
|
-
*
|
|
17
|
-
* @throws {Error} If there's an issue with the database operations or if the token is invalid or expired.
|
|
18
17
|
*/
|
|
19
18
|
export const executePasswordReset: Route = {
|
|
20
19
|
path: routes.executePasswordReset.path,
|
|
@@ -22,33 +21,35 @@ export const executePasswordReset: Route = {
|
|
|
22
21
|
onRequest: async (request, response): Promise<void> => {
|
|
23
22
|
const logger = new Logger({ name: 'executePasswordReset' });
|
|
24
23
|
const { token, newPassword } = request.body;
|
|
25
|
-
|
|
24
|
+
if (typeof newPassword !== 'string' || newPassword.length === 0) {
|
|
25
|
+
response.status(400).send({ error: 'New password cannot be blank' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
logger.info({
|
|
29
|
+
const resetToken = new PasswordResetToken();
|
|
30
|
+
const resolution = await resetToken.resolve(token);
|
|
31
|
+
if (resolution.status === 'malformed' || resolution.status === 'unknown') {
|
|
32
|
+
logger.info({
|
|
33
|
+
message: `Invalid reset token used`,
|
|
34
|
+
obj: { reason: resolution.status, token: resetToken.fingerprint(token) },
|
|
35
|
+
});
|
|
31
36
|
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
32
37
|
return;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const tokenExpiration = moment(user.passwordResetTokenExpiration);
|
|
38
|
-
if (currentTime.isAfter(tokenExpiration)) {
|
|
39
|
-
logger.info({ message: `Expired reset token used`, obj: { email: user.email } });
|
|
40
|
+
if (resolution.status === 'expired') {
|
|
41
|
+
logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
|
|
40
42
|
response.status(400).send({ error: 'Reset token has expired' });
|
|
41
43
|
return;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
const { user } = resolution;
|
|
45
47
|
const hashedPassword = await new PasswordHasher().hash(newPassword);
|
|
46
|
-
await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
48
|
+
if (!(await resetToken.redeem(user, resolution.token, hashedPassword))) {
|
|
49
|
+
logger.info({ message: `Reset token already redeemed`, obj: { email: user.email } });
|
|
50
|
+
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
52
53
|
|
|
53
54
|
logger.info({ message: `Password successfully reset`, obj: { email: user.email } });
|
|
54
55
|
response.send({ message: 'Password has been successfully reset' });
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
EmailSender,
|
|
8
8
|
getDefaultPasswordResetEmailConfigFactory as getDefaultConfigFactory,
|
|
9
9
|
} from '@proteinjs/email-server';
|
|
10
|
-
import {
|
|
10
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Route for initiating a password reset process.
|
|
@@ -62,7 +62,7 @@ export const initiatePasswordReset: Route = {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Generate reset token
|
|
65
|
-
const passwordResetToken =
|
|
65
|
+
const passwordResetToken = new PasswordResetToken().mint();
|
|
66
66
|
const passwordResetTokenExpiration = moment().add(1, 'hour');
|
|
67
67
|
|
|
68
68
|
try {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
|
-
import {
|
|
3
|
-
import { routes, tables } from '@proteinjs/user';
|
|
2
|
+
import { routes } from '@proteinjs/user';
|
|
4
3
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
-
import
|
|
4
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
6
5
|
|
|
7
6
|
export const validateResetPasswordToken: Route = {
|
|
8
7
|
path: routes.validateResetToken.path,
|
|
@@ -10,26 +9,24 @@ export const validateResetPasswordToken: Route = {
|
|
|
10
9
|
onRequest: async (request, response): Promise<void> => {
|
|
11
10
|
const logger = new Logger({ name: 'validateResetToken' });
|
|
12
11
|
const { token } = request.query;
|
|
13
|
-
const db = getDbAsSystem();
|
|
14
|
-
|
|
15
12
|
if (!token) {
|
|
16
13
|
response.status(400).send({ isValid: false, message: 'No token provided' });
|
|
17
14
|
return;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
logger.info({
|
|
17
|
+
const resetToken = new PasswordResetToken();
|
|
18
|
+
const resolution = await resetToken.resolve(token);
|
|
19
|
+
if (resolution.status === 'malformed' || resolution.status === 'unknown') {
|
|
20
|
+
logger.info({
|
|
21
|
+
message: `Invalid reset token used`,
|
|
22
|
+
obj: { reason: resolution.status, token: resetToken.fingerprint(token) },
|
|
23
|
+
});
|
|
24
24
|
response.status(200).send({ isValid: false, message: 'Invalid token' });
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const tokenExpiration = moment(user.passwordResetTokenExpiration);
|
|
31
|
-
if (currentTime.isAfter(tokenExpiration)) {
|
|
32
|
-
logger.info({ message: `Expired reset token used`, obj: { email: user.email } });
|
|
28
|
+
if (resolution.status === 'expired') {
|
|
29
|
+
logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
|
|
33
30
|
response.status(200).send({ isValid: false, message: 'Token has expired' });
|
|
34
31
|
return;
|
|
35
32
|
}
|
|
@@ -38,6 +35,6 @@ export const validateResetPasswordToken: Route = {
|
|
|
38
35
|
// read-only `autocomplete="username"` field so password managers associate the updated
|
|
39
36
|
// password with the stored credential. The token was delivered to this very inbox, so a
|
|
40
37
|
// valid-token holder learns nothing new; invalid/expired verdicts stay email-free.
|
|
41
|
-
response.status(200).send({ isValid: true, email: user.email });
|
|
38
|
+
response.status(200).send({ isValid: true, email: resolution.user.email });
|
|
42
39
|
},
|
|
43
40
|
};
|
package/src/services/Signup.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
EmailSender,
|
|
20
20
|
getDefaultInviteEmailConfigFactory,
|
|
21
21
|
getDefaultSignupConfirmationEmailConfigFactory,
|
|
22
|
+
InviteEmailConfig,
|
|
22
23
|
} from '@proteinjs/email-server';
|
|
23
24
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
24
25
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
@@ -75,7 +76,7 @@ export class Signup implements SignupService {
|
|
|
75
76
|
// previously evaluated the check WITHOUT returning it, which made sendInvite/revokeInvite
|
|
76
77
|
// effectively public — any caller (even logged out) could mint themselves a valid signup
|
|
77
78
|
// token and bypass invite-only signup.
|
|
78
|
-
if (methodName === 'sendInvite' || methodName === 'revokeInvite') {
|
|
79
|
+
if (methodName === 'sendInvite' || methodName === 'resendInvite' || methodName === 'revokeInvite') {
|
|
79
80
|
return UserAuth.hasPermission(USER_PERMISSIONS.users);
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -159,17 +160,8 @@ export class Signup implements SignupService {
|
|
|
159
160
|
return { sent: false, error: 'User already exists with that email.' };
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
if (!defaultConfigFactory) {
|
|
165
|
-
throw new Error(
|
|
166
|
-
`Unable to find a @proteinjs/email-server/DefaultInviteEmailConfigFactory implementation when sending invite.`
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
const config = defaultConfigFactory.getConfig();
|
|
170
|
-
|
|
171
|
-
const token = lib.WordArray.random(32).toString();
|
|
172
|
-
const tokenExpiresAt = moment().add(INVITE_TOKEN_TTL_DAYS, 'days');
|
|
163
|
+
const config = this.inviteEmailConfig();
|
|
164
|
+
const { token, tokenExpiresAt } = this.mintInviteToken();
|
|
173
165
|
let invite = await db.get(tables.Invite, { email: caseInsensitiveEmail });
|
|
174
166
|
if (invite) {
|
|
175
167
|
invite = {
|
|
@@ -188,15 +180,7 @@ export class Signup implements SignupService {
|
|
|
188
180
|
});
|
|
189
181
|
}
|
|
190
182
|
|
|
191
|
-
|
|
192
|
-
await emailSender.sendEmail({
|
|
193
|
-
to: caseInsensitiveEmail,
|
|
194
|
-
subject: config.options?.subject || `You're Invited`,
|
|
195
|
-
text,
|
|
196
|
-
html,
|
|
197
|
-
...config.options,
|
|
198
|
-
});
|
|
199
|
-
|
|
183
|
+
await this.emailInvite(caseInsensitiveEmail, token, config);
|
|
200
184
|
return { sent: true };
|
|
201
185
|
} catch (error: any) {
|
|
202
186
|
logger.error({ message: 'Error sending invite', obj: { email: caseInsensitiveEmail }, error });
|
|
@@ -207,6 +191,41 @@ export class Signup implements SignupService {
|
|
|
207
191
|
}
|
|
208
192
|
}
|
|
209
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Re-sends a standing invite: the token in the earlier email stops working, a fresh token with
|
|
196
|
+
* a fresh expiry takes its place on the same row (never a second row), and the invite email goes
|
|
197
|
+
* out again through the same config. Refused when no invite stands for the address — there is
|
|
198
|
+
* nothing to re-send — and when the address already has an account (the invite was used).
|
|
199
|
+
* The inviter stays whoever sent it first.
|
|
200
|
+
*/
|
|
201
|
+
async resendInvite(email: string): Promise<SendInviteResponse> {
|
|
202
|
+
const logger = new Logger({ name: 'Signup.resendInvite' });
|
|
203
|
+
const caseInsensitiveEmail = email.toLowerCase();
|
|
204
|
+
try {
|
|
205
|
+
const db = getDbAsSystem();
|
|
206
|
+
const userRecord = await db.get(tables.User, { email: caseInsensitiveEmail });
|
|
207
|
+
if (userRecord) {
|
|
208
|
+
return { sent: false, error: 'User already exists with that email.' };
|
|
209
|
+
}
|
|
210
|
+
const invite = await db.get(tables.Invite, { email: caseInsensitiveEmail });
|
|
211
|
+
if (!invite) {
|
|
212
|
+
return { sent: false, error: 'No invite exists for that email.' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const config = this.inviteEmailConfig();
|
|
216
|
+
const { token, tokenExpiresAt } = this.mintInviteToken();
|
|
217
|
+
await db.update(tables.Invite, { ...invite, token, tokenExpiresAt });
|
|
218
|
+
await this.emailInvite(caseInsensitiveEmail, token, config);
|
|
219
|
+
return { sent: true };
|
|
220
|
+
} catch (error: any) {
|
|
221
|
+
logger.error({ message: 'Error re-sending invite', obj: { email: caseInsensitiveEmail }, error });
|
|
222
|
+
return {
|
|
223
|
+
sent: false,
|
|
224
|
+
error: 'Error occurred.',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
async revokeInvite(email: string): Promise<void> {
|
|
211
230
|
if (!email) {
|
|
212
231
|
throw new Error('No email was provided.');
|
|
@@ -322,4 +341,35 @@ export class Signup implements SignupService {
|
|
|
322
341
|
|
|
323
342
|
return { status: 'valid', invite };
|
|
324
343
|
}
|
|
344
|
+
|
|
345
|
+
/** The invite email content factory the consumer registers; its absence is a misconfiguration, said aloud. */
|
|
346
|
+
private inviteEmailConfig(): InviteEmailConfig {
|
|
347
|
+
const defaultConfigFactory = getDefaultInviteEmailConfigFactory();
|
|
348
|
+
if (!defaultConfigFactory) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Unable to find a @proteinjs/email-server/DefaultInviteEmailConfigFactory implementation when sending invite.`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return defaultConfigFactory.getConfig();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A fresh redeemable token with its expiry from the TTL knob — the one minting path for send and re-send. */
|
|
357
|
+
private mintInviteToken(): { token: string; tokenExpiresAt: moment.Moment } {
|
|
358
|
+
return {
|
|
359
|
+
token: lib.WordArray.random(32).toString(),
|
|
360
|
+
tokenExpiresAt: moment().add(INVITE_TOKEN_TTL_DAYS, 'days'),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The invite email itself — the signup link carrying `token`, rendered by the consumer's config. */
|
|
365
|
+
private async emailInvite(email: string, token: string, config: InviteEmailConfig): Promise<void> {
|
|
366
|
+
const { text, html } = config.getEmailContent(`${uiRoutes.auth.signup}?token=${token}`);
|
|
367
|
+
await new EmailSender().sendEmail({
|
|
368
|
+
to: email,
|
|
369
|
+
subject: config.options?.subject || `You're Invited`,
|
|
370
|
+
text,
|
|
371
|
+
html,
|
|
372
|
+
...config.options,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
325
375
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Re-sending an invite (`SignupService.resendInvite`), against the emulator — outcomes on the row
|
|
5
|
+
* and on the transport fake, never call counts alone:
|
|
6
|
+
*
|
|
7
|
+
* - the token in the earlier email stops resolving, the fresh one resolves to the SAME invite;
|
|
8
|
+
* - the row count for the address stays one, its expiry re-stamped from the TTL knob, its inviter kept;
|
|
9
|
+
* - exactly one email goes out for the re-send, to the address, carrying the fresh signup link;
|
|
10
|
+
* - an address with no standing invite is refused: nothing written, nothing sent;
|
|
11
|
+
* - an address that already has an account is refused the same way (the invite was used).
|
|
12
|
+
*
|
|
13
|
+
* The email transport is stubbed at the module boundary (the SignupInvite.test.ts pattern) — SMTP
|
|
14
|
+
* is an external transport; the config fake echoes the signup path so the link can be asserted.
|
|
15
|
+
* The service door (`serviceMetadata.auth`) is pinned here too: re-send rides the 'users' permission
|
|
16
|
+
* exactly like send and revoke — the earlier gap that left invite minting public must not reopen.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const sendEmail = jest.fn();
|
|
20
|
+
jest.mock('@proteinjs/email-server', () => ({
|
|
21
|
+
EmailSender: jest.fn().mockImplementation(() => ({ sendEmail })),
|
|
22
|
+
getDefaultInviteEmailConfigFactory: () => ({
|
|
23
|
+
getConfig: () => ({
|
|
24
|
+
options: { subject: 'Your invite' },
|
|
25
|
+
getEmailContent: (signupPathWithToken: string) => ({
|
|
26
|
+
text: `Accept: /${signupPathWithToken}`,
|
|
27
|
+
html: `<a href="/${signupPathWithToken}">Accept</a>`,
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
}),
|
|
31
|
+
getDefaultSignupConfirmationEmailConfigFactory: () => ({
|
|
32
|
+
getConfig: () => ({ getNewUserEmailContent: () => ({ text: 'welcome', html: '<p>welcome</p>' }) }),
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
37
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
38
|
+
import { tables, UserAuth, USER_PERMISSIONS, type Invite, type User } from '@proteinjs/user';
|
|
39
|
+
import { INVITE_TOKEN_TTL_DAYS, Signup } from '../src/services/Signup';
|
|
40
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
41
|
+
|
|
42
|
+
const TIMEOUT = 60_000;
|
|
43
|
+
|
|
44
|
+
/** Tolerance for the clock advancing between the stamp and the assertion. */
|
|
45
|
+
const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
|
|
46
|
+
|
|
47
|
+
const inviteRows = async (email: string): Promise<Invite[]> =>
|
|
48
|
+
(await getDbAsSystem().query(tables.Invite, { email })) as Invite[];
|
|
49
|
+
|
|
50
|
+
/** The re-send's own transport call: the one addressed to `email` after the earlier `before` calls. */
|
|
51
|
+
const emailsTo = (email: string) =>
|
|
52
|
+
sendEmail.mock.calls.map(([message]) => message).filter((message) => message.to === email);
|
|
53
|
+
|
|
54
|
+
describe('SignupService.resendInvite — against the emulator', () => {
|
|
55
|
+
const env = new UserServerTestEnvironment();
|
|
56
|
+
let inviter: User;
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
await env.beforeAll();
|
|
60
|
+
// The invite-mode factory the token lookup consults (`initializeSignup`) — seeded like the
|
|
61
|
+
// environment seeds its own loadables (the suites load src, never the generated source
|
|
62
|
+
// graph, so an unseeded lookup has no graph to walk); invite-optional, the library default.
|
|
63
|
+
(SourceRepository.get() as unknown as { objectCache: Record<string, unknown[]> }).objectCache[
|
|
64
|
+
'@proteinjs/user-server/DefaultInviteConfigFactory'
|
|
65
|
+
] = [{ getConfig: () => ({ isInviteOnly: false }) }];
|
|
66
|
+
inviter = await env.createUser({ name: 'Inviter', email: 'inviter@example.com', roles: ['admin'] });
|
|
67
|
+
env.actAs(inviter);
|
|
68
|
+
}, TIMEOUT);
|
|
69
|
+
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
await env.afterAll();
|
|
72
|
+
}, TIMEOUT);
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
sendEmail.mockClear();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it(
|
|
79
|
+
'retires the earlier token, mints a fresh one on the SAME row, and emails the fresh link exactly once',
|
|
80
|
+
async () => {
|
|
81
|
+
const email = 'guest@example.com';
|
|
82
|
+
const sent = await new Signup().sendInvite(email);
|
|
83
|
+
expect(sent).toEqual({ sent: true });
|
|
84
|
+
const [standing] = await inviteRows(email);
|
|
85
|
+
const earlierToken = standing.token as string;
|
|
86
|
+
const earlierExpiry = moment(standing.tokenExpiresAt);
|
|
87
|
+
expect(emailsTo(email)).toHaveLength(1);
|
|
88
|
+
|
|
89
|
+
const resent = await new Signup().resendInvite('Guest@Example.com');
|
|
90
|
+
expect(resent).toEqual({ sent: true });
|
|
91
|
+
|
|
92
|
+
// One row — the same record, a fresh token, the inviter kept.
|
|
93
|
+
const rows = await inviteRows(email);
|
|
94
|
+
expect(rows).toHaveLength(1);
|
|
95
|
+
const [refreshed] = rows;
|
|
96
|
+
expect(refreshed.id).toBe(standing.id);
|
|
97
|
+
expect(refreshed.token).not.toBe(earlierToken);
|
|
98
|
+
expect(refreshed.invitedBy?._id).toBe(inviter.id);
|
|
99
|
+
// The expiry is re-stamped from the knob (fresh window), never left at the earlier stamp.
|
|
100
|
+
expect(moment(refreshed.tokenExpiresAt).isSameOrAfter(earlierExpiry)).toBe(true);
|
|
101
|
+
expect(
|
|
102
|
+
Math.abs(moment(refreshed.tokenExpiresAt).diff(moment().add(INVITE_TOKEN_TTL_DAYS, 'days'), 'seconds'))
|
|
103
|
+
).toBeLessThan(CLOCK_SKEW_TOLERANCE_SECONDS);
|
|
104
|
+
|
|
105
|
+
// The earlier link is dead; the fresh one opens signup for this invite.
|
|
106
|
+
const earlier = await new Signup().initializeSignup(earlierToken);
|
|
107
|
+
expect(earlier.isReady).toBe(false);
|
|
108
|
+
expect(earlier.error).toMatch(/no longer valid/i);
|
|
109
|
+
const fresh = await new Signup().initializeSignup(refreshed.token as string);
|
|
110
|
+
expect(fresh.isReady).toBe(true);
|
|
111
|
+
expect(fresh.invite?.email).toBe(email);
|
|
112
|
+
|
|
113
|
+
// Exactly one more email, to the address, carrying the fresh token and none of the earlier one.
|
|
114
|
+
const messages = emailsTo(email);
|
|
115
|
+
expect(messages).toHaveLength(2);
|
|
116
|
+
const resend = messages[1];
|
|
117
|
+
expect(resend.subject).toBe('Your invite');
|
|
118
|
+
expect(resend.text).toContain(`signup?token=${refreshed.token}`);
|
|
119
|
+
expect(resend.html).toContain(`signup?token=${refreshed.token}`);
|
|
120
|
+
expect(resend.text).not.toContain(earlierToken);
|
|
121
|
+
},
|
|
122
|
+
TIMEOUT
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
it(
|
|
126
|
+
'refuses an address with no standing invite — nothing written, nothing sent',
|
|
127
|
+
async () => {
|
|
128
|
+
const email = 'nobody@example.com';
|
|
129
|
+
const response = await new Signup().resendInvite(email);
|
|
130
|
+
expect(response.sent).toBe(false);
|
|
131
|
+
expect(response.error).toMatch(/no invite/i);
|
|
132
|
+
expect(await inviteRows(email)).toHaveLength(0);
|
|
133
|
+
expect(emailsTo(email)).toHaveLength(0);
|
|
134
|
+
},
|
|
135
|
+
TIMEOUT
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
it(
|
|
139
|
+
'refuses an address that already has an account — the invite was used',
|
|
140
|
+
async () => {
|
|
141
|
+
const email = 'member@example.com';
|
|
142
|
+
await env.createUser({ name: 'Member', email });
|
|
143
|
+
const response = await new Signup().resendInvite(email);
|
|
144
|
+
expect(response.sent).toBe(false);
|
|
145
|
+
expect(response.error).toMatch(/already exists/i);
|
|
146
|
+
expect(emailsTo(email)).toHaveLength(0);
|
|
147
|
+
},
|
|
148
|
+
TIMEOUT
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
it('rides the users-permission door like send and revoke (never public)', () => {
|
|
152
|
+
const hasPermission = jest.spyOn(UserAuth, 'hasPermission');
|
|
153
|
+
try {
|
|
154
|
+
hasPermission.mockReturnValue(false);
|
|
155
|
+
expect(new Signup().serviceMetadata.auth.canAccess('resendInvite', ['x@example.com'])).toBe(false);
|
|
156
|
+
hasPermission.mockReturnValue(true);
|
|
157
|
+
expect(new Signup().serviceMetadata.auth.canAccess('resendInvite', ['x@example.com'])).toBe(true);
|
|
158
|
+
expect(hasPermission).toHaveBeenCalledWith(USER_PERMISSIONS.users);
|
|
159
|
+
} finally {
|
|
160
|
+
hasPermission.mockRestore();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|