@proteinjs/user-server 1.22.1 → 1.22.3
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 +28 -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 +9 -1
- 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/SocketIOSessionWatcher.d.ts +5 -0
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
- package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
- package/dist/src/authentication/UserStatusTableWatcher.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/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +3 -2
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +29 -14
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +4 -0
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +12 -4
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +7 -0
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +28 -10
- 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/routes/signup.d.ts.map +1 -1
- package/dist/src/routes/signup.js +5 -2
- package/dist/src/routes/signup.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +5 -1
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/src/services/MachineCredentials.d.ts +10 -1
- package/dist/src/services/MachineCredentials.d.ts.map +1 -1
- package/dist/src/services/MachineCredentials.js +46 -15
- package/dist/src/services/MachineCredentials.js.map +1 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +6 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/Signup.d.ts +5 -0
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +28 -6
- package/dist/src/services/Signup.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/DevLogin.test.js +68 -0
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.js +373 -5
- package/dist/test/InitiatePasswordReset.test.js.map +1 -1
- package/dist/test/LogCapture.d.ts +10 -0
- package/dist/test/LogCapture.d.ts.map +1 -1
- package/dist/test/LogCapture.js +19 -0
- package/dist/test/LogCapture.js.map +1 -1
- package/dist/test/LogCapture.test.d.ts +2 -0
- package/dist/test/LogCapture.test.d.ts.map +1 -0
- package/dist/test/LogCapture.test.js +86 -0
- package/dist/test/LogCapture.test.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/MachineAccounts.integration.test.js +156 -28
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
- package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
- package/dist/test/NoAddressesInLogs.integration.test.js +717 -0
- package/dist/test/NoAddressesInLogs.integration.test.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/SignupRoute.test.js +80 -1
- package/dist/test/SignupRoute.test.js.map +1 -1
- 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/SocketIOSessionWatcher.test.d.ts +2 -0
- package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
- package/dist/test/SocketIOSessionWatcher.test.js +144 -0
- package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +5 -0
- package/package.json +6 -5
- package/src/authentication/PasswordHasher.ts +18 -1
- package/src/authentication/SocketIOSessionWatcher.ts +13 -1
- package/src/authentication/UserStatusTableWatcher.ts +2 -1
- package/src/authentication/authenticate.ts +36 -9
- package/src/authorization/userCache.ts +3 -2
- package/src/routes/devLogin.ts +22 -10
- package/src/routes/executePasswordReset.ts +11 -3
- package/src/routes/initiatePasswordReset.ts +29 -11
- package/src/routes/login.ts +39 -8
- package/src/routes/signup.ts +5 -2
- package/src/routes/validateResetPasswordToken.ts +5 -1
- package/src/services/MachineCredentials.ts +44 -9
- package/src/services/Roles.ts +6 -2
- package/src/services/Signup.ts +30 -4
- 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/DevLogin.test.ts +35 -1
- package/test/InitiatePasswordReset.test.ts +212 -6
- package/test/LogCapture.test.ts +29 -0
- package/test/LogCapture.ts +16 -0
- package/test/LoginThrottle.integration.test.ts +339 -0
- package/test/MachineAccounts.integration.test.ts +97 -22
- package/test/NoAddressesInLogs.integration.test.ts +409 -0
- package/test/RequestDigests.test.ts +81 -0
- package/test/SignupRoute.test.ts +54 -1
- package/test/SlidingWindow.test.ts +85 -0
- package/test/SocketIOSessionWatcher.test.ts +86 -0
- package/test/setup.js +2 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
-
import { tables } from '@proteinjs/user';
|
|
2
|
+
import { User, tables } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { DefaultAdminCredentials } from './DefaultAdminCredentials';
|
|
5
6
|
import { PasswordHasher } from './PasswordHasher';
|
|
6
7
|
|
|
@@ -24,15 +25,12 @@ export async function authenticate(email: string, password: string): Promise<tru
|
|
|
24
25
|
return true;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// in-code comparison is what lets the stored format be salted and per-user.
|
|
30
|
-
const db = getDbAsSystem();
|
|
31
|
-
const user = await db.get(tables.User, { email: email.toLowerCase() });
|
|
32
|
-
const hasher = new PasswordHasher();
|
|
33
|
-
if (!user || !(await hasher.verify(user.password, password))) {
|
|
28
|
+
const { user, matches } = await checkPassword(email, password);
|
|
29
|
+
if (!user || !matches) {
|
|
34
30
|
return 'User name or password incorrect';
|
|
35
31
|
}
|
|
32
|
+
const db = getDbAsSystem();
|
|
33
|
+
const hasher = new PasswordHasher();
|
|
36
34
|
|
|
37
35
|
// Verify-then-rehash: the just-proven password re-hashes a legacy sha256 row into the
|
|
38
36
|
// current format in place — the only moment the plaintext is available to migrate with.
|
|
@@ -53,9 +51,38 @@ export async function authenticate(email: string, password: string): Promise<tru
|
|
|
53
51
|
return true;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
logger.warn({
|
|
54
|
+
logger.warn({
|
|
55
|
+
message: 'Refused login for deactivated account',
|
|
56
|
+
obj: { account: new RequestDigests().account(email) },
|
|
57
|
+
});
|
|
57
58
|
return 'This account has been deactivated';
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
return true;
|
|
61
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Looks the account up and verifies the password — the cost every refusal pays, whether or not
|
|
66
|
+
* the address has an account: with none, the password is verified against a stand-in of the
|
|
67
|
+
* same cost (`PasswordHasher.verifyAgainstNothing`), so a refusal's timing says nothing about
|
|
68
|
+
* the address; a human row still in the legacy sha256 format (verified in microseconds) pays the
|
|
69
|
+
* stand-in too, so a refusal's timing says nothing about whether the account has signed in since
|
|
70
|
+
* the format changed either. The login door also runs it on a throttled try and discards the
|
|
71
|
+
* verdict, so a throttled answer takes as long as a refused password. No side effects (no
|
|
72
|
+
* rehash, no log).
|
|
73
|
+
*/
|
|
74
|
+
export async function checkPassword(email: string, password: string): Promise<{ user?: User; matches: boolean }> {
|
|
75
|
+
// Fetch by EMAIL ONLY and compare in code — never query by password hash. Query-by-hash
|
|
76
|
+
// forced every stored credential into one deterministic queryable value (unsalted sha256);
|
|
77
|
+
// in-code comparison is what lets the stored format be salted and per-user.
|
|
78
|
+
const user = await getDbAsSystem().get(tables.User, { email: email.toLowerCase() });
|
|
79
|
+
const hasher = new PasswordHasher();
|
|
80
|
+
if (!user) {
|
|
81
|
+
return { matches: await hasher.verifyAgainstNothing(password) };
|
|
82
|
+
}
|
|
83
|
+
const matches = await hasher.verify(user.password, password);
|
|
84
|
+
if (hasher.needsRehash(user.password, user.isLoadedFromSource === true ? 'machine' : 'human')) {
|
|
85
|
+
await hasher.verifyAgainstNothing(password);
|
|
86
|
+
}
|
|
87
|
+
return { user, matches };
|
|
88
|
+
}
|
|
@@ -2,6 +2,7 @@ import moment from 'moment';
|
|
|
2
2
|
import { SessionDataCache } from '@proteinjs/server-api';
|
|
3
3
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
4
4
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
5
6
|
import { User, tables, guestUser, USER_SESSION_CACHE_KEY } from '@proteinjs/user';
|
|
6
7
|
import { DefaultAdminCredentials } from '../authentication/DefaultAdminCredentials';
|
|
7
8
|
|
|
@@ -42,7 +43,7 @@ export const userCache: SessionDataCache<User> = {
|
|
|
42
43
|
// account is deactivated — every request runs as the unauthenticated guest.
|
|
43
44
|
logger.warn({
|
|
44
45
|
message: `Session references a deactivated account; resolving as unauthenticated`,
|
|
45
|
-
obj: { sessionId, userEmail },
|
|
46
|
+
obj: { sessionId, account: new RequestDigests().account(userEmail) },
|
|
46
47
|
});
|
|
47
48
|
} else if (accountUser) {
|
|
48
49
|
delete (accountUser as any)['password'];
|
|
@@ -54,7 +55,7 @@ export const userCache: SessionDataCache<User> = {
|
|
|
54
55
|
// build as an unhandled rejection and downs the process.
|
|
55
56
|
logger.warn({
|
|
56
57
|
message: `Session references an account that does not exist; resolving as unauthenticated`,
|
|
57
|
-
obj: { sessionId, userEmail },
|
|
58
|
+
obj: { sessionId, account: new RequestDigests().account(userEmail) },
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/routes/devLogin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
3
4
|
import { emailRegex } from '@proteinjs/util';
|
|
4
5
|
import { establishSession } from '../authentication/establishSession';
|
|
5
6
|
import { Roles } from '../services/Roles';
|
|
@@ -88,22 +89,33 @@ export const devLogin: Route = {
|
|
|
88
89
|
email = requested;
|
|
89
90
|
}
|
|
90
91
|
email = email.toLowerCase();
|
|
92
|
+
const digests = new RequestDigests();
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
let creation: 'created' | 'exists';
|
|
95
|
+
try {
|
|
96
|
+
creation = await new Signup().createAccount({
|
|
97
|
+
name: email.slice(0, email.indexOf('@')),
|
|
98
|
+
email,
|
|
99
|
+
password: 'test',
|
|
100
|
+
emailVerified: false, // same shape an inviteless signup produces
|
|
101
|
+
invitedBy: null,
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// createAccount refuses in plain words (an address a machine-account declaration owns); the
|
|
105
|
+
// dev door answers the way its other refusals do — the words, no session, no row.
|
|
106
|
+
response
|
|
107
|
+
.status(400)
|
|
108
|
+
.send(`/dev/login: ${error instanceof Error ? error.message : 'the account could not be created'}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
99
111
|
if (creation === 'created') {
|
|
100
|
-
logger.info({ message: 'Dev auto-login created missing test account', obj: { email } });
|
|
112
|
+
logger.info({ message: 'Dev auto-login created missing test account', obj: { account: digests.account(email) } });
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
const bootstrapEmail = (process.env.DEV_BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase();
|
|
104
116
|
if (bootstrapEmail && email === bootstrapEmail) {
|
|
105
117
|
const outcome = await new Roles().bootstrapAdmin(email);
|
|
106
|
-
logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { email } });
|
|
118
|
+
logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { account: digests.account(email) } });
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
const bootstrapRoles = DevBootstrapRoles.rolesFor(email);
|
|
@@ -115,7 +127,7 @@ export const devLogin: Route = {
|
|
|
115
127
|
// establishSession commits the session row before the redirect — the redirected GET / must
|
|
116
128
|
// never read the store ahead of the write (observed: first /dev/login load landed on /login).
|
|
117
129
|
await establishSession(request, email);
|
|
118
|
-
logger.info({ message: 'Dev auto-login session established', obj: { email } });
|
|
130
|
+
logger.info({ message: 'Dev auto-login session established', obj: { account: digests.account(email) } });
|
|
119
131
|
response.redirect('/');
|
|
120
132
|
},
|
|
121
133
|
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { routes } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
5
6
|
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
7
|
+
import { signInThrottle } from '../throttle/SignInThrottle';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Route handler for executing a password reset.
|
|
@@ -13,6 +15,10 @@ import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
|
13
15
|
* The token itself never reaches the log. A request that carries no body at all is refused like
|
|
14
16
|
* one that carries a blank password.
|
|
15
17
|
*
|
|
18
|
+
* A redeemed link also opens the account's sign-in window again (`SignInThrottle`): the person
|
|
19
|
+
* just proved the mailbox, so a reset made while wrong guesses held the door shut lets the new
|
|
20
|
+
* password in at once instead of waiting the window out.
|
|
21
|
+
*
|
|
16
22
|
* @bodyParam {string} token - The password reset token.
|
|
17
23
|
* @bodyParam {string} newPassword - The new password for the user.
|
|
18
24
|
*/
|
|
@@ -21,6 +27,7 @@ export const executePasswordReset: Route = {
|
|
|
21
27
|
method: routes.executePasswordReset.method,
|
|
22
28
|
onRequest: async (request, response): Promise<void> => {
|
|
23
29
|
const logger = new Logger({ name: 'executePasswordReset' });
|
|
30
|
+
const digests = new RequestDigests();
|
|
24
31
|
const { token, newPassword } = request.body ?? {};
|
|
25
32
|
if (typeof newPassword !== 'string' || newPassword.length === 0) {
|
|
26
33
|
response.status(400).send({ error: 'New password cannot be blank' });
|
|
@@ -39,7 +46,7 @@ export const executePasswordReset: Route = {
|
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
if (resolution.status === 'expired') {
|
|
42
|
-
logger.info({ message: `Expired reset token used`, obj: {
|
|
49
|
+
logger.info({ message: `Expired reset token used`, obj: { account: digests.account(resolution.user.email) } });
|
|
43
50
|
response.status(400).send({ error: 'Reset token has expired' });
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
@@ -47,12 +54,13 @@ export const executePasswordReset: Route = {
|
|
|
47
54
|
const { user } = resolution;
|
|
48
55
|
const hashedPassword = await new PasswordHasher().hash(newPassword);
|
|
49
56
|
if (!(await resetToken.redeem(user, resolution.token, hashedPassword))) {
|
|
50
|
-
logger.info({ message: `Reset token already redeemed`, obj: {
|
|
57
|
+
logger.info({ message: `Reset token already redeemed`, obj: { account: digests.account(user.email) } });
|
|
51
58
|
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
52
59
|
return;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
signInThrottle.recordSuccess(digests.account(user.email));
|
|
63
|
+
logger.info({ message: `Password successfully reset`, obj: { account: digests.account(user.email) } });
|
|
56
64
|
response.send({ message: 'Password has been successfully reset' });
|
|
57
65
|
},
|
|
58
66
|
};
|
|
@@ -2,12 +2,21 @@ import { Route } from '@proteinjs/server-api';
|
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import { routes, tables, uiRoutes } from '@proteinjs/user';
|
|
4
4
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
5
6
|
import moment from 'moment';
|
|
6
7
|
import {
|
|
7
8
|
EmailSender,
|
|
8
9
|
getDefaultPasswordResetEmailConfigFactory as getDefaultConfigFactory,
|
|
9
10
|
} from '@proteinjs/email-server';
|
|
10
11
|
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
12
|
+
import { ClientAddress } from '../throttle/ClientAddress';
|
|
13
|
+
import { passwordResetThrottle } from '../throttle/PasswordResetThrottle';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The door's one answer: the same words for an address with an account or without, inside or
|
|
17
|
+
* past a window, mailed or not — the page shows exactly this sentence.
|
|
18
|
+
*/
|
|
19
|
+
const ONE_ANSWER = { message: 'If that address has an account, a reset link is on its way.' };
|
|
11
20
|
|
|
12
21
|
/**
|
|
13
22
|
* Route for initiating a password reset process.
|
|
@@ -16,6 +25,13 @@ import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
|
16
25
|
* its expiry on the user row — and mails the token to the account as a reset link. The token is
|
|
17
26
|
* withdrawn again when the mail fails to send.
|
|
18
27
|
*
|
|
28
|
+
* ONE ANSWER: every request with an address is answered `ONE_ANSWER` — BEFORE the address is
|
|
29
|
+
* looked up — whether it has an account, is throttled (`PasswordResetThrottle`: per client and
|
|
30
|
+
* per address), is inside the account's five-minute gap, or its mail fails; so neither the
|
|
31
|
+
* words, the status nor the timing say whether an address has an account. The work runs after
|
|
32
|
+
* the answer. Every request is one outcome line carrying the account digest and the coarse IP
|
|
33
|
+
* hash (`RequestDigests`) — never the address.
|
|
34
|
+
*
|
|
19
35
|
* Requires an implementation of `DefaultPasswordResetEmailConfigFactory` to build the password reset email with.
|
|
20
36
|
*
|
|
21
37
|
* @bodyParam {string} email - The email address of the user requesting a password reset.
|
|
@@ -34,16 +50,20 @@ export const initiatePasswordReset: Route = {
|
|
|
34
50
|
return;
|
|
35
51
|
}
|
|
36
52
|
|
|
53
|
+
const digests = new RequestDigests();
|
|
54
|
+
const fields = { account: digests.account(requestedEmail), ip: digests.coarseIp(new ClientAddress().of(request)) };
|
|
55
|
+
const window = passwordResetThrottle.admit(fields.ip, fields.account);
|
|
56
|
+
response.send(ONE_ANSWER);
|
|
57
|
+
if (window) {
|
|
58
|
+
logger.warn({ message: `Password reset throttled`, obj: { ...fields, window } });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
37
62
|
const email = requestedEmail.toLowerCase();
|
|
38
63
|
const db = getDbAsSystem();
|
|
39
|
-
|
|
40
|
-
const genericResponse = { message: 'If an account with that email exists, we have sent a password reset link.' };
|
|
41
|
-
|
|
42
64
|
const user = await db.get(tables.User, { email });
|
|
43
65
|
if (!user) {
|
|
44
|
-
logger.info({ message: `Password reset requested for non-existent user`, obj:
|
|
45
|
-
// Don't reveal that the user doesn't exist
|
|
46
|
-
response.send(genericResponse);
|
|
66
|
+
logger.info({ message: `Password reset requested for non-existent user`, obj: fields });
|
|
47
67
|
return;
|
|
48
68
|
}
|
|
49
69
|
|
|
@@ -51,8 +71,7 @@ export const initiatePasswordReset: Route = {
|
|
|
51
71
|
const resetToken = new PasswordResetToken();
|
|
52
72
|
const mintedAt = resetToken.mintedAt(user);
|
|
53
73
|
if (mintedAt && moment().diff(mintedAt, 'minutes') < 5) {
|
|
54
|
-
logger.info({ message: `Password reset requested too soon for user`, obj:
|
|
55
|
-
response.send(genericResponse);
|
|
74
|
+
logger.info({ message: `Password reset requested too soon for user`, obj: fields });
|
|
56
75
|
return;
|
|
57
76
|
}
|
|
58
77
|
|
|
@@ -81,12 +100,11 @@ export const initiatePasswordReset: Route = {
|
|
|
81
100
|
...config.options,
|
|
82
101
|
});
|
|
83
102
|
|
|
84
|
-
|
|
103
|
+
logger.info({ message: `Password reset link mailed`, obj: fields });
|
|
85
104
|
} catch (error: any) {
|
|
86
|
-
logger.error({ message: `Failed to send password reset email`, obj:
|
|
105
|
+
logger.error({ message: `Failed to send password reset email`, obj: fields, error });
|
|
87
106
|
// The link never reached the account: withdraw the token so asking again is not throttled
|
|
88
107
|
await resetToken.revoke(user, passwordResetToken);
|
|
89
|
-
response.status(500).send({ error: 'Failed to send password reset email. Please try again later.' });
|
|
90
108
|
}
|
|
91
109
|
},
|
|
92
110
|
};
|
package/src/routes/login.ts
CHANGED
|
@@ -1,34 +1,65 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { routes } from '@proteinjs/user';
|
|
3
|
-
import {
|
|
3
|
+
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { authenticate, checkPassword } from '../authentication/authenticate';
|
|
4
5
|
import { establishSession } from '../authentication/establishSession';
|
|
5
6
|
import { AccountDeletion } from '../services/AccountDeletion';
|
|
7
|
+
import { ClientAddress } from '../throttle/ClientAddress';
|
|
8
|
+
import { RequestDigests } from '../throttle/RequestDigests';
|
|
9
|
+
import { SignInThrottle, signInThrottle } from '../throttle/SignInThrottle';
|
|
6
10
|
|
|
11
|
+
/**
|
|
12
|
+
* `POST /user/login`. Throttled per client and per account (`SignInThrottle`): a throttled try
|
|
13
|
+
* is told "Too many attempts. Try again in a few minutes." whether or not the address has an
|
|
14
|
+
* account, after the same password check a refusal runs (verdict discarded), so neither the
|
|
15
|
+
* words nor the timing tell. Every refusal is a "Sign-in refused" line and every throttled try
|
|
16
|
+
* its own "Sign-in throttled" line, carrying the account digest and the coarse IP hash
|
|
17
|
+
* (`RequestDigests`) — never the address.
|
|
18
|
+
*/
|
|
7
19
|
export const login: Route = {
|
|
8
20
|
path: routes.login.path,
|
|
9
21
|
method: routes.login.method,
|
|
10
22
|
onRequest: async (request: any, response): Promise<void> => {
|
|
11
|
-
const
|
|
12
|
-
|
|
23
|
+
const logger = new Logger({ name: 'login' });
|
|
24
|
+
const credentials = request.body ?? {};
|
|
25
|
+
// A field that is not text is a blank one — never something to call methods on.
|
|
26
|
+
const email: string = typeof credentials.email === 'string' ? credentials.email : '';
|
|
27
|
+
const password: string = typeof credentials.password === 'string' ? credentials.password : '';
|
|
28
|
+
const digests = new RequestDigests();
|
|
29
|
+
const ip = digests.coarseIp(new ClientAddress().of(request));
|
|
30
|
+
const account = email ? digests.account(email) : undefined;
|
|
31
|
+
const fields = account ? { account, ip } : { ip };
|
|
32
|
+
|
|
33
|
+
// The account is counted only when a password came with the try: a blank one judges nothing.
|
|
34
|
+
const window = signInThrottle.admit(ip, password ? account : undefined);
|
|
35
|
+
if (window) {
|
|
36
|
+
await checkPassword(email, password);
|
|
37
|
+
logger.warn({ message: 'Sign-in throttled', obj: { ...fields, window } });
|
|
38
|
+
response.send({ error: SignInThrottle.ANSWER });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!email || !password || !account) {
|
|
13
43
|
const error = `Email and password cannot be blank`;
|
|
14
|
-
|
|
44
|
+
logger.info({ message: 'Sign-in refused', obj: { ...fields, reason: error } });
|
|
15
45
|
response.send({ error });
|
|
16
46
|
return;
|
|
17
47
|
}
|
|
18
48
|
|
|
19
|
-
const result = await authenticate(
|
|
49
|
+
const result = await authenticate(email, password);
|
|
20
50
|
if (result !== true) {
|
|
21
|
-
|
|
51
|
+
logger.info({ message: 'Sign-in refused', obj: { ...fields, reason: result } });
|
|
22
52
|
response.send({ error: result });
|
|
23
53
|
return;
|
|
24
54
|
}
|
|
55
|
+
signInThrottle.recordSuccess(account, ip);
|
|
25
56
|
|
|
26
57
|
// Cancel-by-login: a pending-deletion account's successful authentication IS the cancel
|
|
27
58
|
// signal. The restore runs synchronously here, BEFORE request.login, so the first
|
|
28
59
|
// authenticated paint sees the fully restored account (no transient).
|
|
29
60
|
let outcome: Awaited<ReturnType<AccountDeletion['cancelPendingDeletion']>>;
|
|
30
61
|
try {
|
|
31
|
-
outcome = await new AccountDeletion().cancelPendingDeletion(
|
|
62
|
+
outcome = await new AccountDeletion().cancelPendingDeletion(email);
|
|
32
63
|
} catch (error) {
|
|
33
64
|
// Security boundary: the login response never carries internal error detail — an
|
|
34
65
|
// attacker probing emails must learn nothing from failure shapes (founder ruling
|
|
@@ -45,7 +76,7 @@ export const login: Route = {
|
|
|
45
76
|
return;
|
|
46
77
|
}
|
|
47
78
|
|
|
48
|
-
await establishSession(request,
|
|
79
|
+
await establishSession(request, email);
|
|
49
80
|
response.send({});
|
|
50
81
|
},
|
|
51
82
|
};
|
package/src/routes/signup.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
3
4
|
import { UserSignup, routes } from '@proteinjs/user';
|
|
4
5
|
import { establishSession } from '../authentication/establishSession';
|
|
5
6
|
import { Signup } from '../services/Signup';
|
|
@@ -40,8 +41,10 @@ export const signup: Route = {
|
|
|
40
41
|
);
|
|
41
42
|
} catch (error: any) {
|
|
42
43
|
// createUser throws plain-words errors deliberately (invite expired / invite required /
|
|
43
|
-
// email missing); the message is the user-facing contract, same as the RPC layer's.
|
|
44
|
-
|
|
44
|
+
// email missing); the message is the user-facing contract, same as the RPC layer's. The log
|
|
45
|
+
// gets the error through the digest door: a concurrent double sign-up's unique-index refusal
|
|
46
|
+
// names the address in the database's own words.
|
|
47
|
+
logger.error({ message: 'Signup failed', error: new RequestDigests().redactError(error) });
|
|
45
48
|
response.send({ error: error instanceof Error ? error.message : 'Sign up failed.' });
|
|
46
49
|
return;
|
|
47
50
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { routes } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
5
6
|
|
|
6
7
|
export const validateResetPasswordToken: Route = {
|
|
@@ -26,7 +27,10 @@ export const validateResetPasswordToken: Route = {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
if (resolution.status === 'expired') {
|
|
29
|
-
logger.info({
|
|
30
|
+
logger.info({
|
|
31
|
+
message: `Expired reset token used`,
|
|
32
|
+
obj: { account: new RequestDigests().account(resolution.user.email) },
|
|
33
|
+
});
|
|
30
34
|
response.status(200).send({ isValid: false, message: 'Token has expired' });
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { randomBytes } from 'crypto';
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import {
|
|
4
|
+
MachineAccountRefusal,
|
|
4
5
|
MachineAccountView,
|
|
5
6
|
MachineCredentialsService,
|
|
6
7
|
MintedMachineCredential,
|
|
7
8
|
USER_PERMISSIONS,
|
|
9
|
+
User,
|
|
8
10
|
getMachineAccounts,
|
|
9
11
|
tables,
|
|
10
12
|
} from '@proteinjs/user';
|
|
11
13
|
import { Logger } from '@proteinjs/logger';
|
|
14
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
12
15
|
import { Service } from '@proteinjs/service';
|
|
13
16
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
14
17
|
|
|
@@ -23,7 +26,9 @@ import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
|
23
26
|
* account's existing sessions (the old
|
|
24
27
|
* credential dies with them), and returns the plaintext ONCE for pasting into the declaration's
|
|
25
28
|
* Secret Manager secret. The same call rotates. Machine rows only (`is_loaded_from_source`);
|
|
26
|
-
* human credentials go through the password-reset flow.
|
|
29
|
+
* human credentials go through the password-reset flow. A declared address held by a row the
|
|
30
|
+
* boot sync does not own (a person's signup, a hand-made row) has no machine account at all —
|
|
31
|
+
* the sync refused the declaration — and both the list and the mint say so.
|
|
27
32
|
*/
|
|
28
33
|
export class MachineCredentials implements MachineCredentialsService {
|
|
29
34
|
public serviceMetadata: Service['serviceMetadata'] = {
|
|
@@ -36,15 +41,22 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
36
41
|
const db = getDbAsSystem();
|
|
37
42
|
const views: MachineAccountView[] = [];
|
|
38
43
|
for (const declaration of getMachineAccounts()) {
|
|
39
|
-
const row = await db.get(tables.User, { email: declaration.email });
|
|
40
|
-
const
|
|
44
|
+
const row: User | undefined = await db.get(tables.User, { email: declaration.email });
|
|
45
|
+
const refusal = row && this.refusal(row);
|
|
41
46
|
views.push({
|
|
42
47
|
email: declaration.email,
|
|
43
48
|
accountName: declaration.accountName,
|
|
44
49
|
roles: [...declaration.roles],
|
|
45
50
|
secretName: declaration.secretName,
|
|
46
|
-
status:
|
|
47
|
-
|
|
51
|
+
status: !row
|
|
52
|
+
? 'pending first boot'
|
|
53
|
+
: refusal
|
|
54
|
+
? 'declaration refused'
|
|
55
|
+
: row.status === 'deactivated'
|
|
56
|
+
? 'deactivated'
|
|
57
|
+
: 'active',
|
|
58
|
+
...(refusal ? { refusal } : {}),
|
|
59
|
+
hasCredential: !!row && !refusal && !!row.password,
|
|
48
60
|
});
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -63,11 +75,20 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
const db = getDbAsSystem();
|
|
66
|
-
const user = await db.get(tables.User, { email: normalizedEmail });
|
|
67
|
-
if (!user
|
|
78
|
+
const user: User | undefined = await db.get(tables.User, { email: normalizedEmail });
|
|
79
|
+
if (!user) {
|
|
68
80
|
throw new Error(
|
|
69
81
|
`The machine account row for '${normalizedEmail}' has not been loaded from source yet — ` +
|
|
70
|
-
`the boot sync creates
|
|
82
|
+
`the boot sync creates it. Boot the server with the declaration, then mint.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const refusal = this.refusal(user);
|
|
87
|
+
if (refusal) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`The declaration for '${normalizedEmail}' was refused at boot: ${refusal} — the boot sync ` +
|
|
90
|
+
`never takes over a row it does not own, so there is no machine account to mint for. The ` +
|
|
91
|
+
`first boot after that row is gone creates it.`
|
|
71
92
|
);
|
|
72
93
|
}
|
|
73
94
|
|
|
@@ -79,7 +100,7 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
79
100
|
await db.delete(tables.Session, { userEmail: normalizedEmail });
|
|
80
101
|
logger.info({
|
|
81
102
|
message: `Minted machine credential`,
|
|
82
|
-
obj: {
|
|
103
|
+
obj: { account: new RequestDigests().account(normalizedEmail), secretName: declaration.secretName },
|
|
83
104
|
});
|
|
84
105
|
|
|
85
106
|
return {
|
|
@@ -91,4 +112,18 @@ export class MachineCredentials implements MachineCredentialsService {
|
|
|
91
112
|
`secret, then restart the service that reads it — the previous credential is already invalid.`,
|
|
92
113
|
};
|
|
93
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Why the boot sync refused a declaration whose address this row holds — undefined for the
|
|
118
|
+
* account's own row. The sync owns a row only when it created it (`isLoadedFromSource`); any
|
|
119
|
+
* other row under a declared address is never taken over (@proteinjs/db's natural-key refusal),
|
|
120
|
+
* and the one machine marker says whose row it is.
|
|
121
|
+
*/
|
|
122
|
+
private refusal(row: User): MachineAccountRefusal | undefined {
|
|
123
|
+
if (row.isLoadedFromSource === true) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return row.machine === true ? 'a hand-made machine row holds this address' : 'a person holds this address';
|
|
128
|
+
}
|
|
94
129
|
}
|
package/src/services/Roles.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDbAsSystem, QueryBuilderFactory } from '@proteinjs/db';
|
|
2
2
|
import { RolesService, RolesCatalog, UserAuth, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
4
5
|
import { Service } from '@proteinjs/service';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -67,7 +68,10 @@ export class Roles implements RolesService {
|
|
|
67
68
|
await db.update(tables.User, { id: user.id, roles: [...roles, 'admin'] });
|
|
68
69
|
await db.insert(tables.RoleGrantEvent, { actor: user.id, target: user.id, role: 'admin', action: 'grant' });
|
|
69
70
|
});
|
|
70
|
-
logger.info({
|
|
71
|
+
logger.info({
|
|
72
|
+
message: 'Break-glass admin granted by the dev first-admin door',
|
|
73
|
+
obj: { target: user.id, account: new RequestDigests().account(email) },
|
|
74
|
+
});
|
|
71
75
|
return 'granted';
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -122,7 +126,7 @@ export class Roles implements RolesService {
|
|
|
122
126
|
});
|
|
123
127
|
logger.info({
|
|
124
128
|
message: 'Roles granted by the dev role-bootstrap door',
|
|
125
|
-
obj: { target: user.id, email, granted: outcome.granted },
|
|
129
|
+
obj: { target: user.id, account: new RequestDigests().account(email), granted: outcome.granted },
|
|
126
130
|
});
|
|
127
131
|
return outcome;
|
|
128
132
|
}
|
package/src/services/Signup.ts
CHANGED
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
User,
|
|
12
12
|
UserSignup,
|
|
13
13
|
USER_PERMISSIONS,
|
|
14
|
+
getMachineAccounts,
|
|
14
15
|
} from '@proteinjs/user';
|
|
15
16
|
import moment from 'moment';
|
|
16
17
|
import { lib } from 'crypto-js';
|
|
17
18
|
import { Logger } from '@proteinjs/logger';
|
|
19
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
18
20
|
import {
|
|
19
21
|
EmailSender,
|
|
20
22
|
getDefaultInviteEmailConfigFactory,
|
|
@@ -124,7 +126,10 @@ export class Signup implements SignupService {
|
|
|
124
126
|
invitedBy: invite ? invite.invitedBy : null,
|
|
125
127
|
});
|
|
126
128
|
if (creation === 'exists') {
|
|
127
|
-
logger.error({
|
|
129
|
+
logger.error({
|
|
130
|
+
message: `User with this email already exists`,
|
|
131
|
+
obj: { account: new RequestDigests().account(email) },
|
|
132
|
+
});
|
|
128
133
|
if (config.getExistingUserEmailContent) {
|
|
129
134
|
const { text, html } = config.getExistingUserEmailContent();
|
|
130
135
|
await emailSender.sendEmail({
|
|
@@ -146,7 +151,7 @@ export class Signup implements SignupService {
|
|
|
146
151
|
html,
|
|
147
152
|
...config.options,
|
|
148
153
|
});
|
|
149
|
-
logger.info({ message: `Created user`, obj: { email } });
|
|
154
|
+
logger.info({ message: `Created user`, obj: { account: new RequestDigests().account(email) } });
|
|
150
155
|
return { outcome: 'created', email };
|
|
151
156
|
}
|
|
152
157
|
|
|
@@ -183,7 +188,14 @@ export class Signup implements SignupService {
|
|
|
183
188
|
await this.emailInvite(caseInsensitiveEmail, token, config);
|
|
184
189
|
return { sent: true };
|
|
185
190
|
} catch (error: any) {
|
|
186
|
-
|
|
191
|
+
// The try block covers the Invite row's write and the mail: a refusal's own words may name
|
|
192
|
+
// the address, so the error reaches the log through the digest door too.
|
|
193
|
+
const digests = new RequestDigests();
|
|
194
|
+
logger.error({
|
|
195
|
+
message: 'Error sending invite',
|
|
196
|
+
obj: { invitee: digests.address(caseInsensitiveEmail) },
|
|
197
|
+
error: digests.redactError(error),
|
|
198
|
+
});
|
|
187
199
|
return {
|
|
188
200
|
sent: false,
|
|
189
201
|
error: 'Error occurred.',
|
|
@@ -218,7 +230,12 @@ export class Signup implements SignupService {
|
|
|
218
230
|
await this.emailInvite(caseInsensitiveEmail, token, config);
|
|
219
231
|
return { sent: true };
|
|
220
232
|
} catch (error: any) {
|
|
221
|
-
|
|
233
|
+
const digests = new RequestDigests();
|
|
234
|
+
logger.error({
|
|
235
|
+
message: 'Error re-sending invite',
|
|
236
|
+
obj: { invitee: digests.address(caseInsensitiveEmail) },
|
|
237
|
+
error: digests.redactError(error),
|
|
238
|
+
});
|
|
222
239
|
return {
|
|
223
240
|
sent: false,
|
|
224
241
|
error: 'Error occurred.',
|
|
@@ -300,6 +317,11 @@ export class Signup implements SignupService {
|
|
|
300
317
|
* (`devLogin`, which auto-creates missing same-domain test accounts). Not exposed as an RPC:
|
|
301
318
|
* the service surface is the `SignupService` INTERFACE (ServiceRouter walks its declared
|
|
302
319
|
* methods), so extra class methods stay server-internal.
|
|
320
|
+
*
|
|
321
|
+
* An address a `MachineAccount` declaration owns is never registered, whatever its row state:
|
|
322
|
+
* a person's row under it would make the boot sync refuse the declaration (it never takes over
|
|
323
|
+
* a row it does not own), so the address is refused here in plain words — the declared machine
|
|
324
|
+
* accounts of this build are the list, nothing else.
|
|
303
325
|
*/
|
|
304
326
|
async createAccount(account: {
|
|
305
327
|
name: string;
|
|
@@ -310,6 +332,10 @@ export class Signup implements SignupService {
|
|
|
310
332
|
}): Promise<'created' | 'exists'> {
|
|
311
333
|
const db = getDbAsSystem();
|
|
312
334
|
const email = account.email.toLowerCase();
|
|
335
|
+
if (getMachineAccounts().some((machineAccount) => machineAccount.email === email)) {
|
|
336
|
+
throw new Error(`This address can't be registered.`);
|
|
337
|
+
}
|
|
338
|
+
|
|
313
339
|
const existingUser = await db.get(tables.User, { email });
|
|
314
340
|
if (existingUser) {
|
|
315
341
|
return 'exists';
|