@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +9 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/src/authentication/PasswordHasher.d.ts +9 -0
  9. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
  10. package/dist/src/authentication/PasswordHasher.js +25 -0
  11. package/dist/src/authentication/PasswordHasher.js.map +1 -1
  12. package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
  13. package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
  14. package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
  15. package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
  16. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
  17. package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
  18. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
  19. package/dist/src/authentication/authenticate.d.ts +15 -0
  20. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  21. package/dist/src/authentication/authenticate.js +55 -20
  22. package/dist/src/authentication/authenticate.js.map +1 -1
  23. package/dist/src/authorization/userCache.d.ts.map +1 -1
  24. package/dist/src/authorization/userCache.js +3 -2
  25. package/dist/src/authorization/userCache.js.map +1 -1
  26. package/dist/src/routes/devLogin.d.ts.map +1 -1
  27. package/dist/src/routes/devLogin.js +29 -14
  28. package/dist/src/routes/devLogin.js.map +1 -1
  29. package/dist/src/routes/executePasswordReset.d.ts +4 -0
  30. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  31. package/dist/src/routes/executePasswordReset.js +12 -4
  32. package/dist/src/routes/executePasswordReset.js.map +1 -1
  33. package/dist/src/routes/initiatePasswordReset.d.ts +7 -0
  34. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  35. package/dist/src/routes/initiatePasswordReset.js +28 -10
  36. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  37. package/dist/src/routes/login.d.ts +8 -0
  38. package/dist/src/routes/login.d.ts.map +1 -1
  39. package/dist/src/routes/login.js +63 -22
  40. package/dist/src/routes/login.js.map +1 -1
  41. package/dist/src/routes/signup.d.ts.map +1 -1
  42. package/dist/src/routes/signup.js +5 -2
  43. package/dist/src/routes/signup.js.map +1 -1
  44. package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
  45. package/dist/src/routes/validateResetPasswordToken.js +5 -1
  46. package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
  47. package/dist/src/services/MachineCredentials.d.ts +10 -1
  48. package/dist/src/services/MachineCredentials.d.ts.map +1 -1
  49. package/dist/src/services/MachineCredentials.js +46 -15
  50. package/dist/src/services/MachineCredentials.js.map +1 -1
  51. package/dist/src/services/Roles.d.ts.map +1 -1
  52. package/dist/src/services/Roles.js +6 -2
  53. package/dist/src/services/Roles.js.map +1 -1
  54. package/dist/src/services/Signup.d.ts +5 -0
  55. package/dist/src/services/Signup.d.ts.map +1 -1
  56. package/dist/src/services/Signup.js +28 -6
  57. package/dist/src/services/Signup.js.map +1 -1
  58. package/dist/src/throttle/ClientAddress.d.ts +35 -0
  59. package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
  60. package/dist/src/throttle/ClientAddress.js +48 -0
  61. package/dist/src/throttle/ClientAddress.js.map +1 -0
  62. package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
  63. package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
  64. package/dist/src/throttle/PasswordResetThrottle.js +59 -0
  65. package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
  66. package/dist/src/throttle/RequestDigests.d.ts +42 -0
  67. package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
  68. package/dist/src/throttle/RequestDigests.js +96 -0
  69. package/dist/src/throttle/RequestDigests.js.map +1 -0
  70. package/dist/src/throttle/SignInThrottle.d.ts +67 -0
  71. package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
  72. package/dist/src/throttle/SignInThrottle.js +91 -0
  73. package/dist/src/throttle/SignInThrottle.js.map +1 -0
  74. package/dist/src/throttle/SlidingWindow.d.ts +51 -0
  75. package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
  76. package/dist/src/throttle/SlidingWindow.js +85 -0
  77. package/dist/src/throttle/SlidingWindow.js.map +1 -0
  78. package/dist/test/ClientAddress.test.d.ts +2 -0
  79. package/dist/test/ClientAddress.test.d.ts.map +1 -0
  80. package/dist/test/ClientAddress.test.js +51 -0
  81. package/dist/test/ClientAddress.test.js.map +1 -0
  82. package/dist/test/DevLogin.test.js +68 -0
  83. package/dist/test/DevLogin.test.js.map +1 -1
  84. package/dist/test/InitiatePasswordReset.test.js +373 -5
  85. package/dist/test/InitiatePasswordReset.test.js.map +1 -1
  86. package/dist/test/LogCapture.d.ts +10 -0
  87. package/dist/test/LogCapture.d.ts.map +1 -1
  88. package/dist/test/LogCapture.js +19 -0
  89. package/dist/test/LogCapture.js.map +1 -1
  90. package/dist/test/LogCapture.test.d.ts +2 -0
  91. package/dist/test/LogCapture.test.d.ts.map +1 -0
  92. package/dist/test/LogCapture.test.js +86 -0
  93. package/dist/test/LogCapture.test.js.map +1 -0
  94. package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
  95. package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
  96. package/dist/test/LoginThrottle.integration.test.js +616 -0
  97. package/dist/test/LoginThrottle.integration.test.js.map +1 -0
  98. package/dist/test/MachineAccounts.integration.test.js +156 -28
  99. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  100. package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
  101. package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
  102. package/dist/test/NoAddressesInLogs.integration.test.js +717 -0
  103. package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
  104. package/dist/test/RequestDigests.test.d.ts +2 -0
  105. package/dist/test/RequestDigests.test.d.ts.map +1 -0
  106. package/dist/test/RequestDigests.test.js +73 -0
  107. package/dist/test/RequestDigests.test.js.map +1 -0
  108. package/dist/test/SignupRoute.test.js +80 -1
  109. package/dist/test/SignupRoute.test.js.map +1 -1
  110. package/dist/test/SlidingWindow.test.d.ts +2 -0
  111. package/dist/test/SlidingWindow.test.d.ts.map +1 -0
  112. package/dist/test/SlidingWindow.test.js +72 -0
  113. package/dist/test/SlidingWindow.test.js.map +1 -0
  114. package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
  115. package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
  116. package/dist/test/SocketIOSessionWatcher.test.js +144 -0
  117. package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
  118. package/generated/index.ts +1 -1
  119. package/index.ts +5 -0
  120. package/package.json +6 -5
  121. package/src/authentication/PasswordHasher.ts +18 -1
  122. package/src/authentication/SocketIOSessionWatcher.ts +13 -1
  123. package/src/authentication/UserStatusTableWatcher.ts +2 -1
  124. package/src/authentication/authenticate.ts +36 -9
  125. package/src/authorization/userCache.ts +3 -2
  126. package/src/routes/devLogin.ts +22 -10
  127. package/src/routes/executePasswordReset.ts +11 -3
  128. package/src/routes/initiatePasswordReset.ts +29 -11
  129. package/src/routes/login.ts +39 -8
  130. package/src/routes/signup.ts +5 -2
  131. package/src/routes/validateResetPasswordToken.ts +5 -1
  132. package/src/services/MachineCredentials.ts +44 -9
  133. package/src/services/Roles.ts +6 -2
  134. package/src/services/Signup.ts +30 -4
  135. package/src/throttle/ClientAddress.ts +47 -0
  136. package/src/throttle/PasswordResetThrottle.ts +62 -0
  137. package/src/throttle/RequestDigests.ts +97 -0
  138. package/src/throttle/SignInThrottle.ts +98 -0
  139. package/src/throttle/SlidingWindow.ts +90 -0
  140. package/test/ClientAddress.test.ts +54 -0
  141. package/test/DevLogin.test.ts +35 -1
  142. package/test/InitiatePasswordReset.test.ts +212 -6
  143. package/test/LogCapture.test.ts +29 -0
  144. package/test/LogCapture.ts +16 -0
  145. package/test/LoginThrottle.integration.test.ts +339 -0
  146. package/test/MachineAccounts.integration.test.ts +97 -22
  147. package/test/NoAddressesInLogs.integration.test.ts +409 -0
  148. package/test/RequestDigests.test.ts +81 -0
  149. package/test/SignupRoute.test.ts +54 -1
  150. package/test/SlidingWindow.test.ts +85 -0
  151. package/test/SocketIOSessionWatcher.test.ts +86 -0
  152. 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
- // Fetch by EMAIL ONLY and compare in code — never query by password hash. Query-by-hash
28
- // forced every stored credential into one deterministic queryable value (unsalted sha256);
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({ message: 'Refused login for deactivated account', obj: { email: email.toLowerCase() } });
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
  }
@@ -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
- const creation = await new Signup().createAccount({
93
- name: email.slice(0, email.indexOf('@')),
94
- email,
95
- password: 'test',
96
- emailVerified: false, // same shape an inviteless signup produces
97
- invitedBy: null,
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: { email: resolution.user.email } });
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: { email: user.email } });
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
- logger.info({ message: `Password successfully reset`, obj: { email: user.email } });
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: { email } });
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: { email } });
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
- response.send(genericResponse);
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: { email }, error });
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
  };
@@ -1,34 +1,65 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { routes } from '@proteinjs/user';
3
- import { authenticate } from '../authentication/authenticate';
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 credentials: { email: string; password: string } = request.body;
12
- if (!credentials.email || !credentials.password) {
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
- console.error(error);
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(credentials.email, credentials.password);
49
+ const result = await authenticate(email, password);
20
50
  if (result !== true) {
21
- console.error(result);
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(credentials.email);
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, credentials.email);
79
+ await establishSession(request, email);
49
80
  response.send({});
50
81
  },
51
82
  };
@@ -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
- logger.error({ message: 'Signup failed', error });
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({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
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 booted = !!row && row.isLoadedFromSource === true;
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: booted ? (row.status === 'deactivated' ? 'deactivated' : 'active') : 'pending first boot',
47
- hasCredential: booted && !!row.password,
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 || user.isLoadedFromSource !== true) {
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 (or adopts) it. Boot the server with the declaration, then mint.`
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: { email: normalizedEmail, secretName: declaration.secretName },
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
  }
@@ -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({ message: 'Break-glass admin granted by the dev first-admin door', obj: { target: user.id, email } });
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
  }
@@ -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({ message: `User with this email already exists`, obj: { email } });
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
- logger.error({ message: 'Error sending invite', obj: { email: caseInsensitiveEmail }, error });
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
- logger.error({ message: 'Error re-sending invite', obj: { email: caseInsensitiveEmail }, error });
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';