@proteinjs/user-server 1.22.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +24 -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 +10 -2
  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/PasswordResetToken.d.ts +43 -12
  13. package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
  14. package/dist/src/authentication/PasswordResetToken.js +86 -21
  15. package/dist/src/authentication/PasswordResetToken.js.map +1 -1
  16. package/dist/src/authentication/authenticate.d.ts +15 -0
  17. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  18. package/dist/src/authentication/authenticate.js +55 -20
  19. package/dist/src/authentication/authenticate.js.map +1 -1
  20. package/dist/src/routes/devLogin.d.ts +1 -1
  21. package/dist/src/routes/devLogin.js +1 -1
  22. package/dist/src/routes/executePasswordReset.d.ts +6 -1
  23. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  24. package/dist/src/routes/executePasswordReset.js +16 -7
  25. package/dist/src/routes/executePasswordReset.js.map +1 -1
  26. package/dist/src/routes/initiatePasswordReset.d.ts +10 -2
  27. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  28. package/dist/src/routes/initiatePasswordReset.js +55 -34
  29. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  30. package/dist/src/routes/login.d.ts +8 -0
  31. package/dist/src/routes/login.d.ts.map +1 -1
  32. package/dist/src/routes/login.js +63 -22
  33. package/dist/src/routes/login.js.map +1 -1
  34. package/dist/src/throttle/ClientAddress.d.ts +35 -0
  35. package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
  36. package/dist/src/throttle/ClientAddress.js +48 -0
  37. package/dist/src/throttle/ClientAddress.js.map +1 -0
  38. package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
  39. package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
  40. package/dist/src/throttle/PasswordResetThrottle.js +59 -0
  41. package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
  42. package/dist/src/throttle/RequestDigests.d.ts +42 -0
  43. package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
  44. package/dist/src/throttle/RequestDigests.js +96 -0
  45. package/dist/src/throttle/RequestDigests.js.map +1 -0
  46. package/dist/src/throttle/SignInThrottle.d.ts +67 -0
  47. package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
  48. package/dist/src/throttle/SignInThrottle.js +91 -0
  49. package/dist/src/throttle/SignInThrottle.js.map +1 -0
  50. package/dist/src/throttle/SlidingWindow.d.ts +51 -0
  51. package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
  52. package/dist/src/throttle/SlidingWindow.js +85 -0
  53. package/dist/src/throttle/SlidingWindow.js.map +1 -0
  54. package/dist/test/ClientAddress.test.d.ts +2 -0
  55. package/dist/test/ClientAddress.test.d.ts.map +1 -0
  56. package/dist/test/ClientAddress.test.js +51 -0
  57. package/dist/test/ClientAddress.test.js.map +1 -0
  58. package/dist/test/DbTraffic.d.ts +29 -0
  59. package/dist/test/DbTraffic.d.ts.map +1 -0
  60. package/dist/test/DbTraffic.js +106 -0
  61. package/dist/test/DbTraffic.js.map +1 -0
  62. package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
  63. package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
  64. package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
  65. package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
  66. package/dist/test/ExecutePasswordReset.test.js +513 -124
  67. package/dist/test/ExecutePasswordReset.test.js.map +1 -1
  68. package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
  69. package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
  70. package/dist/test/InitiatePasswordReset.test.js +783 -0
  71. package/dist/test/InitiatePasswordReset.test.js.map +1 -0
  72. package/dist/test/LogCapture.d.ts +22 -0
  73. package/dist/test/LogCapture.d.ts.map +1 -0
  74. package/dist/test/LogCapture.js +127 -0
  75. package/dist/test/LogCapture.js.map +1 -0
  76. package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
  77. package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
  78. package/dist/test/LoginThrottle.integration.test.js +616 -0
  79. package/dist/test/LoginThrottle.integration.test.js.map +1 -0
  80. package/dist/test/MalformedResetTokens.d.ts +11 -0
  81. package/dist/test/MalformedResetTokens.d.ts.map +1 -0
  82. package/dist/test/MalformedResetTokens.js +35 -0
  83. package/dist/test/MalformedResetTokens.js.map +1 -0
  84. package/dist/test/RequestDigests.test.d.ts +2 -0
  85. package/dist/test/RequestDigests.test.d.ts.map +1 -0
  86. package/dist/test/RequestDigests.test.js +73 -0
  87. package/dist/test/RequestDigests.test.js.map +1 -0
  88. package/dist/test/SlidingWindow.test.d.ts +2 -0
  89. package/dist/test/SlidingWindow.test.d.ts.map +1 -0
  90. package/dist/test/SlidingWindow.test.js +72 -0
  91. package/dist/test/SlidingWindow.test.js.map +1 -0
  92. package/dist/test/ValidateResetToken.test.js +156 -33
  93. package/dist/test/ValidateResetToken.test.js.map +1 -1
  94. package/dist/test/emulatorLock.test.d.ts +2 -0
  95. package/dist/test/emulatorLock.test.d.ts.map +1 -0
  96. package/dist/test/emulatorLock.test.js +32 -0
  97. package/dist/test/emulatorLock.test.js.map +1 -0
  98. package/dist/test/emulatorLockName.test.d.ts +2 -0
  99. package/dist/test/emulatorLockName.test.d.ts.map +1 -0
  100. package/dist/test/emulatorLockName.test.js +36 -0
  101. package/dist/test/emulatorLockName.test.js.map +1 -0
  102. package/generated/index.ts +1 -1
  103. package/index.ts +6 -1
  104. package/jest.config.js +2 -2
  105. package/package.json +3 -3
  106. package/src/authentication/PasswordHasher.ts +18 -1
  107. package/src/authentication/PasswordResetToken.ts +74 -20
  108. package/src/authentication/authenticate.ts +36 -9
  109. package/src/routes/devLogin.ts +1 -1
  110. package/src/routes/executePasswordReset.ts +10 -2
  111. package/src/routes/initiatePasswordReset.ts +46 -25
  112. package/src/routes/login.ts +39 -8
  113. package/src/throttle/ClientAddress.ts +47 -0
  114. package/src/throttle/PasswordResetThrottle.ts +62 -0
  115. package/src/throttle/RequestDigests.ts +97 -0
  116. package/src/throttle/SignInThrottle.ts +98 -0
  117. package/src/throttle/SlidingWindow.ts +90 -0
  118. package/test/ClientAddress.test.ts +54 -0
  119. package/test/DbTraffic.ts +43 -0
  120. package/test/DevLoginBootstrapAdmin.test.ts +3 -4
  121. package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
  122. package/test/ExecutePasswordReset.test.ts +308 -70
  123. package/test/InitiatePasswordReset.test.ts +456 -0
  124. package/test/LogCapture.ts +54 -0
  125. package/test/LoginThrottle.integration.test.ts +339 -0
  126. package/test/MalformedResetTokens.ts +27 -0
  127. package/test/RequestDigests.test.ts +81 -0
  128. package/test/SlidingWindow.test.ts +85 -0
  129. package/test/ValidateResetToken.test.ts +108 -24
  130. package/test/emulatorLock.js +5 -6
  131. package/test/emulatorLock.test.ts +32 -0
  132. package/test/emulatorLockName.test.ts +33 -0
  133. package/test/setup.js +2 -0
@@ -13,22 +13,42 @@ export type PasswordResetResolution =
13
13
  * The password-reset token's one owner: its shape, its mint, the lookup that maps a presented
14
14
  * value back to the user it was issued to, and its single-use redemption.
15
15
  *
16
- * A token is 32 bytes from the platform CSPRNG, hex-encoded (64 lowercase hex characters),
17
- * stored on the user row beside its expiry until it is redeemed or expires.
16
+ * A token is 32 bytes from the platform CSPRNG, hex-encoded (64 lowercase hex characters). The
17
+ * token itself exists only in the link mailed to the account: the user row stores its SHA-256
18
+ * digest (hex) beside the expiry, never the token, so a read of the table yields nothing that
19
+ * can be presented. A digest is not a token — presenting one is hashed again and matches no row.
20
+ * A fast unsalted hash is enough here because the input is 256 bits of CSPRNG output, not a
21
+ * human-chosen secret.
18
22
  *
19
- * Resolution refuses anything that is not a value of that shape BEFORE any lookup. A query
20
- * filter built from a request value that is not a token does not compare the way a token does:
21
- * `null` renders as `IS NULL` and would match every account with no pending reset, an empty
22
- * string matches an emptied column, and other types reach the driver. The row the lookup
23
- * returns is then re-checked in code — its stored token must be a string equal to the presented
24
- * one (compared in constant time) and its expiry a real timestamp still in the future — so the
23
+ * Resolution refuses anything that is not a value of the token's shape BEFORE any lookup. A
24
+ * query filter built from a request value that is not a token does not compare the way a token
25
+ * does: `null` renders as `IS NULL` and would match every account with no pending reset, an
26
+ * empty string matches an emptied column, and other types reach the driver. The presented token
27
+ * is then hashed, the row is looked up by that digest, and the row the lookup returns is
28
+ * re-checked in code — its stored digest must be a string equal to the presented token's digest
29
+ * (compared in constant time) and its expiry a real timestamp still in the future — so the
25
30
  * outcome never rests on how the storage compares.
31
+ *
32
+ * No migration accompanies the move from a stored token to a stored digest: a row that still
33
+ * holds a token minted before it never equals the digest of anything presented, so that token
34
+ * fails validation for what remains of its hour and the person asks for a new link.
26
35
  */
27
36
  export class PasswordResetToken {
28
37
  private static readonly SHAPE = /^[0-9a-f]{64}$/;
38
+ private static readonly LIFETIME_MINUTES = 60;
29
39
 
30
- mint(): string {
31
- return randomBytes(32).toString('hex');
40
+ /**
41
+ * Issue a token to `user`: store its digest and its expiry on the row, replacing any token
42
+ * outstanding, and return the token — the only time it exists outside the mailed link.
43
+ */
44
+ async mint(user: User): Promise<string> {
45
+ const token = randomBytes(32).toString('hex');
46
+ await getDbAsSystem().update(tables.User, {
47
+ id: user.id,
48
+ passwordResetToken: this.digest(token),
49
+ passwordResetTokenExpiration: moment().add(PasswordResetToken.LIFETIME_MINUTES, 'minutes'),
50
+ });
51
+ return token;
32
52
  }
33
53
 
34
54
  async resolve(presented: unknown): Promise<PasswordResetResolution> {
@@ -37,8 +57,9 @@ export class PasswordResetToken {
37
57
  return { status: 'malformed' };
38
58
  }
39
59
 
40
- const user = await getDbAsSystem().get(tables.User, { passwordResetToken: token });
41
- if (!user || !this.matches(user.passwordResetToken, token)) {
60
+ const digest = this.digest(token);
61
+ const user = await getDbAsSystem().get(tables.User, { passwordResetToken: digest });
62
+ if (!user || !this.matches(user.passwordResetToken, digest)) {
42
63
  return { status: 'unknown' };
43
64
  }
44
65
 
@@ -51,38 +72,71 @@ export class PasswordResetToken {
51
72
 
52
73
  /**
53
74
  * Write the new credential and clear the token in one conditional update: the row must still
54
- * carry this token at write time, so two presentations of the same token cannot both succeed.
55
- * Resolves false when the token was already redeemed.
75
+ * carry this token's digest at write time, so two presentations of the same token cannot both
76
+ * succeed. Resolves false when the token was already redeemed.
56
77
  */
57
78
  async redeem(user: User, token: string, hashedPassword: string): Promise<boolean> {
58
79
  const updated = await getDbAsSystem().update(
59
80
  tables.User,
60
81
  { password: hashedPassword, passwordResetToken: null, passwordResetTokenExpiration: null },
61
- { id: user.id, passwordResetToken: token }
82
+ { id: user.id, passwordResetToken: this.digest(token) }
62
83
  );
63
84
  return updated === 1;
64
85
  }
65
86
 
66
- /** A log-safe reference to a presented token: a short digest of a well-formed one, never the value itself. */
87
+ /**
88
+ * Withdraw a token that never reached its owner (the mail carrying it failed to send): the row
89
+ * is cleared only while it still carries this token's digest, so a newer token is left alone.
90
+ */
91
+ async revoke(user: User, token: string): Promise<void> {
92
+ await getDbAsSystem().update(
93
+ tables.User,
94
+ { passwordResetToken: null, passwordResetTokenExpiration: null },
95
+ { id: user.id, passwordResetToken: this.digest(token) }
96
+ );
97
+ }
98
+
99
+ /** When the token outstanding on `user` was minted, or undefined when the row carries none. */
100
+ mintedAt(user: User): Moment | undefined {
101
+ if (!user.passwordResetToken || !user.passwordResetTokenExpiration) {
102
+ return undefined;
103
+ }
104
+
105
+ return moment(user.passwordResetTokenExpiration).subtract(PasswordResetToken.LIFETIME_MINUTES, 'minutes');
106
+ }
107
+
108
+ /**
109
+ * A log-safe reference to a presented token: a short prefix of a well-formed one's digest,
110
+ * never the value itself and never a part of it. The routes log it only for a token that
111
+ * resolved `unknown` — one no row carries — so it never lines up with a digest a row holds.
112
+ * What it is for is telling refusals apart in the log: one stale link presented again reads
113
+ * the same each time, a run of different values reads differently. 48 bits of the SHA-256 of
114
+ * 256 random bits identify nothing and can be presented nowhere.
115
+ */
67
116
  fingerprint(presented: unknown): string | undefined {
68
117
  const token = this.parse(presented);
69
118
  if (token === undefined) {
70
119
  return undefined;
71
120
  }
72
121
 
73
- return createHash('sha256').update(token).digest('hex').slice(0, 12);
122
+ return this.digest(token).slice(0, 12);
74
123
  }
75
124
 
76
125
  private parse(presented: unknown): string | undefined {
77
126
  return typeof presented === 'string' && PasswordResetToken.SHAPE.test(presented) ? presented : undefined;
78
127
  }
79
128
 
80
- private matches(stored: string | null | undefined, presented: string): boolean {
81
- if (typeof stored !== 'string' || stored.length !== presented.length) {
129
+ /** What the row stores in place of a token: its SHA-256, hex-encoded. */
130
+ private digest(token: string): string {
131
+ return createHash('sha256').update(token).digest('hex');
132
+ }
133
+
134
+ private matches(storedDigest: string | null | undefined, presentedDigest: string): boolean {
135
+ if (typeof storedDigest !== 'string' || storedDigest.length !== presentedDigest.length) {
82
136
  return false;
83
137
  }
84
138
 
85
- return timingSafeEqual(Buffer.from(stored), Buffer.from(presented));
139
+ return timingSafeEqual(Buffer.from(storedDigest), Buffer.from(presentedDigest));
86
140
  }
87
141
 
88
142
  private isLive(expiration: Moment | null | undefined): boolean {
@@ -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 '../throttle/RequestDigests';
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
+ }
@@ -45,7 +45,7 @@ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@')
45
45
  * call is unchanged; the variable absent = nothing changes; the gates closed = 404 regardless.
46
46
  * Test and prod never set it — the omission is the safety, the same idiom as the gates. The
47
47
  * outcome is logged as ONE marker line, `Dev bootstrap admin door: <granted|admin-exists>`,
48
- * which the n3xa compose-estate boot proof reads from the server log to PROVE the grant landed.
48
+ * which a consumer's boot proof can read from the server log to PROVE the grant landed.
49
49
  *
50
50
  * Role-bootstrap door (`DEV_BOOTSTRAP_ROLES='email:role[,role];email:role…'`, the grammar in
51
51
  * DevBootstrapRoles.ts): the first-admin door leaves every OTHER account role-less, and a
@@ -3,6 +3,8 @@ import { routes } from '@proteinjs/user';
3
3
  import { Logger } from '@proteinjs/logger';
4
4
  import { PasswordHasher } from '../authentication/PasswordHasher';
5
5
  import { PasswordResetToken } from '../authentication/PasswordResetToken';
6
+ import { RequestDigests } from '../throttle/RequestDigests';
7
+ import { signInThrottle } from '../throttle/SignInThrottle';
6
8
 
7
9
  /**
8
10
  * Route handler for executing a password reset.
@@ -10,7 +12,12 @@ import { PasswordResetToken } from '../authentication/PasswordResetToken';
10
12
  * Resolves the presented token through `PasswordResetToken` — which refuses anything but a
11
13
  * well-formed token before any lookup — checks its expiry, and redeems it: the new password is
12
14
  * written and the token cleared in one conditional update, so a token resets a password once.
13
- * The token itself never reaches the log.
15
+ * The token itself never reaches the log. A request that carries no body at all is refused like
16
+ * one that carries a blank password.
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.
14
21
  *
15
22
  * @bodyParam {string} token - The password reset token.
16
23
  * @bodyParam {string} newPassword - The new password for the user.
@@ -20,7 +27,7 @@ export const executePasswordReset: Route = {
20
27
  method: routes.executePasswordReset.method,
21
28
  onRequest: async (request, response): Promise<void> => {
22
29
  const logger = new Logger({ name: 'executePasswordReset' });
23
- const { token, newPassword } = request.body;
30
+ const { token, newPassword } = request.body ?? {};
24
31
  if (typeof newPassword !== 'string' || newPassword.length === 0) {
25
32
  response.status(400).send({ error: 'New password cannot be blank' });
26
33
  return;
@@ -51,6 +58,7 @@ export const executePasswordReset: Route = {
51
58
  return;
52
59
  }
53
60
 
61
+ signInThrottle.recordSuccess(new RequestDigests().account(user.email));
54
62
  logger.info({ message: `Password successfully reset`, obj: { email: user.email } });
55
63
  response.send({ message: 'Password has been successfully reset' });
56
64
  },
@@ -8,12 +8,29 @@ import {
8
8
  getDefaultPasswordResetEmailConfigFactory as getDefaultConfigFactory,
9
9
  } from '@proteinjs/email-server';
10
10
  import { PasswordResetToken } from '../authentication/PasswordResetToken';
11
+ import { ClientAddress } from '../throttle/ClientAddress';
12
+ import { RequestDigests } from '../throttle/RequestDigests';
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.
14
23
  *
15
- * This route handles the process of generating a password reset token,
16
- * sending a reset email to the user, and storing the token and expiration of the token in the database.
24
+ * Mints a reset token through `PasswordResetToken` — which stores only the token's digest and
25
+ * its expiry on the user row — and mails the token to the account as a reset link. The token is
26
+ * withdrawn again when the mail fails to send.
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.
17
34
  *
18
35
  * Requires an implementation of `DefaultPasswordResetEmailConfigFactory` to build the password reset email with.
19
36
  *
@@ -27,29 +44,35 @@ export const initiatePasswordReset: Route = {
27
44
  method: routes.initiatePasswordReset.method,
28
45
  onRequest: async (request, response): Promise<void> => {
29
46
  const logger = new Logger({ name: 'initiatePasswordReset' });
30
- const email = request.body.email?.toLowerCase();
31
- const db = getDbAsSystem();
47
+ const { email: requestedEmail } = request.body ?? {};
48
+ if (typeof requestedEmail !== 'string' || requestedEmail.length === 0) {
49
+ response.status(400).send({ error: 'Email cannot be blank' });
50
+ return;
51
+ }
32
52
 
33
- const genericResponse = { message: 'If an account with that email exists, we have sent a password reset link.' };
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
+ }
34
61
 
62
+ const email = requestedEmail.toLowerCase();
63
+ const db = getDbAsSystem();
35
64
  const user = await db.get(tables.User, { email });
36
65
  if (!user) {
37
- logger.info({ message: `Password reset requested for non-existent user`, obj: { email } });
38
- // Don't reveal that the user doesn't exist
39
- response.send(genericResponse);
66
+ logger.info({ message: `Password reset requested for non-existent user`, obj: fields });
40
67
  return;
41
68
  }
42
69
 
43
70
  // Check if there's an existing token and it's less than 5 minutes old
44
- if (user.passwordResetToken && user.passwordResetTokenExpiration) {
45
- const currentTime = moment();
46
- const tokenCreationTime = moment(user.passwordResetTokenExpiration).subtract(1, 'hour');
47
- const timeDifference = currentTime.diff(tokenCreationTime, 'minutes');
48
- if (timeDifference < 5) {
49
- logger.info({ message: `Password reset requested too soon for user`, obj: { email } });
50
- response.send(genericResponse);
51
- return;
52
- }
71
+ const resetToken = new PasswordResetToken();
72
+ const mintedAt = resetToken.mintedAt(user);
73
+ if (mintedAt && moment().diff(mintedAt, 'minutes') < 5) {
74
+ logger.info({ message: `Password reset requested too soon for user`, obj: fields });
75
+ return;
53
76
  }
54
77
 
55
78
  const emailSender = new EmailSender();
@@ -61,9 +84,8 @@ export const initiatePasswordReset: Route = {
61
84
  );
62
85
  }
63
86
 
64
- // Generate reset token
65
- const passwordResetToken = new PasswordResetToken().mint();
66
- const passwordResetTokenExpiration = moment().add(1, 'hour');
87
+ // The row now holds the token's digest; the token itself goes only into the mailed link
88
+ const passwordResetToken = await resetToken.mint(user);
67
89
 
68
90
  try {
69
91
  const config = defaultConfigFactory.getConfig();
@@ -78,12 +100,11 @@ export const initiatePasswordReset: Route = {
78
100
  ...config.options,
79
101
  });
80
102
 
81
- // If email is sent successfully, save reset token to user record
82
- await db.update(tables.User, { id: user.id, passwordResetToken, passwordResetTokenExpiration });
83
- response.send(genericResponse);
103
+ logger.info({ message: `Password reset link mailed`, obj: fields });
84
104
  } catch (error: any) {
85
- logger.error({ message: `Failed to send password reset email`, obj: { email }, error });
86
- response.status(500).send({ error: 'Failed to send password reset email. Please try again later.' });
105
+ logger.error({ message: `Failed to send password reset email`, obj: fields, error });
106
+ // The link never reached the account: withdraw the token so asking again is not throttled
107
+ await resetToken.revoke(user, passwordResetToken);
87
108
  }
88
109
  },
89
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
  };
@@ -0,0 +1,47 @@
1
+ /** The request fields the read needs — an express request has all of them. */
2
+ type ClientRequest = {
3
+ app?: { get?: (setting: string) => unknown };
4
+ headers?: Record<string, string | string[] | undefined>;
5
+ socket?: { remoteAddress?: string };
6
+ };
7
+
8
+ /**
9
+ * The client's address, as a throttle should key on it.
10
+ *
11
+ * Behind Google's external Application Load Balancer the `X-Forwarded-For` header ENDS with the
12
+ * two entries the balancer appends — `<client-ip>,<load-balancer-ip>` — and everything before
13
+ * them is whatever the client itself sent. So the client is the second entry from the right:
14
+ * - never the LAST entry: that is the balancer's own forwarding-rule address, the same for every
15
+ * visitor. It is what express's `request.ip` resolves to under the server's `trust proxy 1`
16
+ * (one trusted hop: the Google front end that opened the connection), so a throttle keyed on
17
+ * `request.ip` there is one bucket for the whole world;
18
+ * - never the FIRST entry: a client can send its own header, and the balancer keeps it.
19
+ *
20
+ * Only when the server trusts a proxy (the deployed shape; `trust proxy` is unset in
21
+ * development) is the header read at all. A request that did not come through the balancer —
22
+ * no appended pair: an in-cluster call, a probe — and every request in development key on the
23
+ * connection's own address.
24
+ */
25
+ export class ClientAddress {
26
+ /** How many entries the load balancer appends to the header: the client, then itself. */
27
+ private static readonly BALANCER_APPENDED_ENTRIES = 2;
28
+
29
+ of(request: ClientRequest): string {
30
+ if (request.app?.get?.('trust proxy')) {
31
+ const entries = this.forwardedFor(request);
32
+ if (entries.length >= ClientAddress.BALANCER_APPENDED_ENTRIES) {
33
+ return entries[entries.length - ClientAddress.BALANCER_APPENDED_ENTRIES];
34
+ }
35
+ }
36
+ return request.socket?.remoteAddress ?? '';
37
+ }
38
+
39
+ private forwardedFor(request: ClientRequest): string[] {
40
+ const header = request.headers?.['x-forwarded-for'];
41
+ const value = Array.isArray(header) ? header.join(',') : header ?? '';
42
+ return value
43
+ .split(',')
44
+ .map((entry) => entry.trim())
45
+ .filter((entry) => entry.length > 0);
46
+ }
47
+ }
@@ -0,0 +1,62 @@
1
+ import { SlidingWindow } from './SlidingWindow';
2
+ import { ThrottleWindow } from './SignInThrottle';
3
+
4
+ /**
5
+ * The password-reset door's two windows (`POST /user/initiate-password-reset`), keyed by digests
6
+ * (`RequestDigests`), never by an address. Both count every request, whether or not the address
7
+ * has an account — a window that only counted real accounts would itself say which addresses
8
+ * have one. Past either window nothing is looked up, minted or mailed; the door's answer is the
9
+ * same sentence as always (see the route).
10
+ *
11
+ * These sit on top of the account row's own five-minute gap between links, which spans replicas
12
+ * (it is stored); the windows are in process memory (`SlidingWindow`): per replica. A
13
+ * deployment of three replicas behind a load balancer (up to ten under load) keeps three sets
14
+ * of windows, so its effective ceiling is ~3× the numbers below (up to ~10×) — for one address
15
+ * still at most one mail per five minutes. Friction, not the wall — a shared store is a
16
+ * separate step.
17
+ */
18
+ export class PasswordResetThrottle {
19
+ /**
20
+ * Per client: 10 requests an hour. Someone asking for their own address again, or a household
21
+ * asking for a few addresses, passes; a script sweeping addresses stops at ten.
22
+ */
23
+ private static readonly CLIENT_LIMIT = 10;
24
+ private static readonly CLIENT_WINDOW_MS = 60 * 60 * 1000;
25
+
26
+ /**
27
+ * Per address: 3 requests an hour. A person whose mail is slow asks once or twice more; beyond
28
+ * that it is mail aimed at somebody's inbox.
29
+ */
30
+ private static readonly ACCOUNT_LIMIT = 3;
31
+ private static readonly ACCOUNT_WINDOW_MS = 60 * 60 * 1000;
32
+
33
+ private readonly clients: SlidingWindow;
34
+ private readonly accounts: SlidingWindow;
35
+
36
+ constructor(options?: { now?: () => number }) {
37
+ this.clients = new SlidingWindow({
38
+ windowMs: PasswordResetThrottle.CLIENT_WINDOW_MS,
39
+ limit: PasswordResetThrottle.CLIENT_LIMIT,
40
+ now: options?.now,
41
+ });
42
+ this.accounts = new SlidingWindow({
43
+ windowMs: PasswordResetThrottle.ACCOUNT_WINDOW_MS,
44
+ limit: PasswordResetThrottle.ACCOUNT_LIMIT,
45
+ now: options?.now,
46
+ });
47
+ }
48
+
49
+ /** Count this request and answer which window refuses it, if any. */
50
+ admit(client: string, account: string): ThrottleWindow | undefined {
51
+ if (this.clients.hit(client)) {
52
+ return 'client';
53
+ }
54
+ if (this.accounts.hit(account)) {
55
+ return 'account';
56
+ }
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /** The process-wide windows the reset door shares — they must span requests. */
62
+ export const passwordResetThrottle = new PasswordResetThrottle();