@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.
- package/CHANGELOG.md +24 -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 +10 -2
- 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/PasswordResetToken.d.ts +43 -12
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
- package/dist/src/authentication/PasswordResetToken.js +86 -21
- package/dist/src/authentication/PasswordResetToken.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/routes/devLogin.d.ts +1 -1
- package/dist/src/routes/devLogin.js +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +6 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +16 -7
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +10 -2
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +55 -34
- 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/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/DbTraffic.d.ts +29 -0
- package/dist/test/DbTraffic.d.ts.map +1 -0
- package/dist/test/DbTraffic.js +106 -0
- package/dist/test/DbTraffic.js.map +1 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
- package/dist/test/ExecutePasswordReset.test.js +513 -124
- package/dist/test/ExecutePasswordReset.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
- package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
- package/dist/test/InitiatePasswordReset.test.js +783 -0
- package/dist/test/InitiatePasswordReset.test.js.map +1 -0
- package/dist/test/LogCapture.d.ts +22 -0
- package/dist/test/LogCapture.d.ts.map +1 -0
- package/dist/test/LogCapture.js +127 -0
- package/dist/test/LogCapture.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/MalformedResetTokens.d.ts +11 -0
- package/dist/test/MalformedResetTokens.d.ts.map +1 -0
- package/dist/test/MalformedResetTokens.js +35 -0
- package/dist/test/MalformedResetTokens.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/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/ValidateResetToken.test.js +156 -33
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/dist/test/emulatorLock.test.d.ts +2 -0
- package/dist/test/emulatorLock.test.d.ts.map +1 -0
- package/dist/test/emulatorLock.test.js +32 -0
- package/dist/test/emulatorLock.test.js.map +1 -0
- package/dist/test/emulatorLockName.test.d.ts +2 -0
- package/dist/test/emulatorLockName.test.d.ts.map +1 -0
- package/dist/test/emulatorLockName.test.js +36 -0
- package/dist/test/emulatorLockName.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +6 -1
- package/jest.config.js +2 -2
- package/package.json +3 -3
- package/src/authentication/PasswordHasher.ts +18 -1
- package/src/authentication/PasswordResetToken.ts +74 -20
- package/src/authentication/authenticate.ts +36 -9
- package/src/routes/devLogin.ts +1 -1
- package/src/routes/executePasswordReset.ts +10 -2
- package/src/routes/initiatePasswordReset.ts +46 -25
- package/src/routes/login.ts +39 -8
- 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/DbTraffic.ts +43 -0
- package/test/DevLoginBootstrapAdmin.test.ts +3 -4
- package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
- package/test/ExecutePasswordReset.test.ts +308 -70
- package/test/InitiatePasswordReset.test.ts +456 -0
- package/test/LogCapture.ts +54 -0
- package/test/LoginThrottle.integration.test.ts +339 -0
- package/test/MalformedResetTokens.ts +27 -0
- package/test/RequestDigests.test.ts +81 -0
- package/test/SlidingWindow.test.ts +85 -0
- package/test/ValidateResetToken.test.ts +108 -24
- package/test/emulatorLock.js +5 -6
- package/test/emulatorLock.test.ts +32 -0
- package/test/emulatorLockName.test.ts +33 -0
- 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
|
-
*
|
|
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
|
|
20
|
-
* filter built from a request value that is not a token does not compare the way a token
|
|
21
|
-
* `null` renders as `IS NULL` and would match every account with no pending reset, an
|
|
22
|
-
* string matches an emptied column, and other types reach the driver. The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/routes/devLogin.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
31
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
65
|
-
const passwordResetToken =
|
|
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
|
-
|
|
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:
|
|
86
|
-
|
|
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
|
};
|
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
|
};
|
|
@@ -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();
|