@nacre.work/api 0.17.4 → 0.18.0
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/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -1
- package/dist/login.d.ts +116 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +174 -3
- package/dist/login.js.map +1 -1
- package/dist/main.js +45 -1
- package/dist/main.js.map +1 -1
- package/dist/recovery.d.ts +91 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +199 -0
- package/dist/recovery.js.map +1 -0
- package/dist/second-factor.d.ts +89 -0
- package/dist/second-factor.d.ts.map +1 -0
- package/dist/second-factor.js +267 -0
- package/dist/second-factor.js.map +1 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +538 -42
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type Mailer } from '@nacre.work/core';
|
|
2
|
+
import type { Pool } from 'pg';
|
|
3
|
+
/**
|
|
4
|
+
* Recovering a password, by email, without a `psql` session.
|
|
5
|
+
*
|
|
6
|
+
* ## The hole this closes
|
|
7
|
+
*
|
|
8
|
+
* `POST /v1/users/{id}/password` is an **administrator** setting somebody
|
|
9
|
+
* else's. The person who forgot theirs had no route, and on a
|
|
10
|
+
* single-administrator installation — which the open core mostly is — the
|
|
11
|
+
* administrator who forgot theirs had no route that did not go through the
|
|
12
|
+
* database. That is the model-offers-it-and-the-product-gives-no-route shape
|
|
13
|
+
* this repository keeps closing.
|
|
14
|
+
*
|
|
15
|
+
* ## Why the token carries its organization
|
|
16
|
+
*
|
|
17
|
+
* `<org_id>.<secret>`. Redemption therefore knows the tenant before it reads
|
|
18
|
+
* anything, so this table is read through `withOrg` like every other and the
|
|
19
|
+
* one path a stranger can reach unauthenticated opens no cross-tenant read.
|
|
20
|
+
*
|
|
21
|
+
* Migration 0008 says `users` gets no `authenticating` policy "as a decision
|
|
22
|
+
* rather than an omission"; this keeps that decision rather than arguing with
|
|
23
|
+
* it. The organization id is not a secret from the person holding the link —
|
|
24
|
+
* it is in their own `/v1/me` — and the half beside it is.
|
|
25
|
+
*
|
|
26
|
+
* ## What a reset does not do
|
|
27
|
+
*
|
|
28
|
+
* **It does not touch a second factor.** If it did, an email account would be a
|
|
29
|
+
* way around one, which is the whole thing a second factor exists to not be.
|
|
30
|
+
* Somebody who resets a password still produces a code afterwards.
|
|
31
|
+
*
|
|
32
|
+
* It *does* end every other session: a reset is what somebody does when they
|
|
33
|
+
* think their password is known, and leaving the refresh tokens alive would
|
|
34
|
+
* leave whoever knows it signed in.
|
|
35
|
+
*/
|
|
36
|
+
/** An hour. Long enough to reach an inbox, short enough to be worth stealing. */
|
|
37
|
+
export declare const RESET_TTL_SECONDS = 3600;
|
|
38
|
+
/**
|
|
39
|
+
* The shortest password this will accept from a person choosing their own.
|
|
40
|
+
*
|
|
41
|
+
* Length and nothing else — no composition rule. A rule demanding a digit and a
|
|
42
|
+
* symbol produces `Password1!` and a person who writes it down; length is the
|
|
43
|
+
* only requirement that reliably buys entropy. The passwords this product
|
|
44
|
+
* *generates* are six words and a number, and are longer than this.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MIN_PASSWORD_LENGTH = 12;
|
|
47
|
+
export interface RecoveryDeps {
|
|
48
|
+
readonly pool: Pool;
|
|
49
|
+
readonly mailer: Mailer;
|
|
50
|
+
/** Where the link points. From configuration, never from a request header. */
|
|
51
|
+
readonly consoleBase: string;
|
|
52
|
+
readonly role?: string;
|
|
53
|
+
readonly now?: () => Date;
|
|
54
|
+
}
|
|
55
|
+
export type Redemption = 'reset' | 'refused' | 'too-short';
|
|
56
|
+
export declare class PasswordRecovery {
|
|
57
|
+
private readonly deps;
|
|
58
|
+
constructor(deps: RecoveryDeps);
|
|
59
|
+
private get scope();
|
|
60
|
+
private get clock();
|
|
61
|
+
/**
|
|
62
|
+
* Send a link, or do nothing, and answer the same either way.
|
|
63
|
+
*
|
|
64
|
+
* The caller writes one `204` whatever happened here. An address that has an
|
|
65
|
+
* account and one that does not must be indistinguishable, or this endpoint
|
|
66
|
+
* becomes the account-enumeration oracle that the sign-in path is careful not
|
|
67
|
+
* to be — and it is reachable without a credential.
|
|
68
|
+
*
|
|
69
|
+
* An address in two organizations is the same silence, for the reason the
|
|
70
|
+
* login path gives: telling somebody how many tenants an address appears in
|
|
71
|
+
* is telling them about tenants.
|
|
72
|
+
*/
|
|
73
|
+
request(email: string): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Spend a link and set the password, or refuse.
|
|
76
|
+
*
|
|
77
|
+
* The spend is the UPDATE that finds it, so two requests cannot both succeed
|
|
78
|
+
* — a read followed by a write is a race with a stolen link on the other side
|
|
79
|
+
* of it.
|
|
80
|
+
*/
|
|
81
|
+
redeem(token: string, password: string): Promise<Redemption>;
|
|
82
|
+
/**
|
|
83
|
+
* The one user this address names, or nothing.
|
|
84
|
+
*
|
|
85
|
+
* The same shape the login path uses, and for the same reasons: two matches
|
|
86
|
+
* is silence rather than a choice, and the scan stops at two so the cost does
|
|
87
|
+
* not grow with how many tenants a deployment has.
|
|
88
|
+
*/
|
|
89
|
+
private findUser;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=recovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery.d.ts","sourceRoot":"","sources":["../src/recovery.ts"],"names":[],"mappings":"AAEA,OAAO,EAA4B,KAAK,MAAM,EAAW,MAAM,kBAAkB,CAAA;AACjF,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAIrC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8EAA8E;IAC9E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAC1B;AAED,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,WAAW,CAAA;AAE1D,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,YAAY;IAE/C,OAAO,KAAK,KAAK,GAEhB;IAED,OAAO,KAAK,KAAK,GAEhB;IAED;;;;;;;;;;;OAWG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkD3C;;;;;;OAMG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAuElE;;;;;;OAMG;YACW,QAAQ;CAgCvB"}
|
package/dist/recovery.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { consoleUrl, hashPassword, withOrg } from '@nacre.work/core';
|
|
3
|
+
/**
|
|
4
|
+
* Recovering a password, by email, without a `psql` session.
|
|
5
|
+
*
|
|
6
|
+
* ## The hole this closes
|
|
7
|
+
*
|
|
8
|
+
* `POST /v1/users/{id}/password` is an **administrator** setting somebody
|
|
9
|
+
* else's. The person who forgot theirs had no route, and on a
|
|
10
|
+
* single-administrator installation — which the open core mostly is — the
|
|
11
|
+
* administrator who forgot theirs had no route that did not go through the
|
|
12
|
+
* database. That is the model-offers-it-and-the-product-gives-no-route shape
|
|
13
|
+
* this repository keeps closing.
|
|
14
|
+
*
|
|
15
|
+
* ## Why the token carries its organization
|
|
16
|
+
*
|
|
17
|
+
* `<org_id>.<secret>`. Redemption therefore knows the tenant before it reads
|
|
18
|
+
* anything, so this table is read through `withOrg` like every other and the
|
|
19
|
+
* one path a stranger can reach unauthenticated opens no cross-tenant read.
|
|
20
|
+
*
|
|
21
|
+
* Migration 0008 says `users` gets no `authenticating` policy "as a decision
|
|
22
|
+
* rather than an omission"; this keeps that decision rather than arguing with
|
|
23
|
+
* it. The organization id is not a secret from the person holding the link —
|
|
24
|
+
* it is in their own `/v1/me` — and the half beside it is.
|
|
25
|
+
*
|
|
26
|
+
* ## What a reset does not do
|
|
27
|
+
*
|
|
28
|
+
* **It does not touch a second factor.** If it did, an email account would be a
|
|
29
|
+
* way around one, which is the whole thing a second factor exists to not be.
|
|
30
|
+
* Somebody who resets a password still produces a code afterwards.
|
|
31
|
+
*
|
|
32
|
+
* It *does* end every other session: a reset is what somebody does when they
|
|
33
|
+
* think their password is known, and leaving the refresh tokens alive would
|
|
34
|
+
* leave whoever knows it signed in.
|
|
35
|
+
*/
|
|
36
|
+
/** An hour. Long enough to reach an inbox, short enough to be worth stealing. */
|
|
37
|
+
export const RESET_TTL_SECONDS = 3600;
|
|
38
|
+
/**
|
|
39
|
+
* The shortest password this will accept from a person choosing their own.
|
|
40
|
+
*
|
|
41
|
+
* Length and nothing else — no composition rule. A rule demanding a digit and a
|
|
42
|
+
* symbol produces `Password1!` and a person who writes it down; length is the
|
|
43
|
+
* only requirement that reliably buys entropy. The passwords this product
|
|
44
|
+
* *generates* are six words and a number, and are longer than this.
|
|
45
|
+
*/
|
|
46
|
+
export const MIN_PASSWORD_LENGTH = 12;
|
|
47
|
+
const digest = (token) => createHash('sha256').update(token).digest('hex');
|
|
48
|
+
export class PasswordRecovery {
|
|
49
|
+
deps;
|
|
50
|
+
constructor(deps) {
|
|
51
|
+
this.deps = deps;
|
|
52
|
+
}
|
|
53
|
+
get scope() {
|
|
54
|
+
return this.deps.role === undefined ? {} : { role: this.deps.role };
|
|
55
|
+
}
|
|
56
|
+
get clock() {
|
|
57
|
+
return this.deps.now?.() ?? new Date();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Send a link, or do nothing, and answer the same either way.
|
|
61
|
+
*
|
|
62
|
+
* The caller writes one `204` whatever happened here. An address that has an
|
|
63
|
+
* account and one that does not must be indistinguishable, or this endpoint
|
|
64
|
+
* becomes the account-enumeration oracle that the sign-in path is careful not
|
|
65
|
+
* to be — and it is reachable without a credential.
|
|
66
|
+
*
|
|
67
|
+
* An address in two organizations is the same silence, for the reason the
|
|
68
|
+
* login path gives: telling somebody how many tenants an address appears in
|
|
69
|
+
* is telling them about tenants.
|
|
70
|
+
*/
|
|
71
|
+
async request(email) {
|
|
72
|
+
const address = email.trim().toLowerCase();
|
|
73
|
+
if (address === '')
|
|
74
|
+
return;
|
|
75
|
+
const found = await this.findUser(address);
|
|
76
|
+
if (found === undefined)
|
|
77
|
+
return;
|
|
78
|
+
const secret = randomBytes(32).toString('base64url');
|
|
79
|
+
const token = `${found.orgId}.${secret}`;
|
|
80
|
+
const expiresAt = new Date(this.clock.getTime() + RESET_TTL_SECONDS * 1000);
|
|
81
|
+
await withOrg(this.deps.pool, found.orgId, async (client) => {
|
|
82
|
+
// Any older link this person holds stops working. Two live links is two
|
|
83
|
+
// things to steal for one account, and somebody asking again is
|
|
84
|
+
// somebody who does not have the first.
|
|
85
|
+
await client.query(`UPDATE password_reset_tokens SET used_at = now()
|
|
86
|
+
WHERE org_id = $1 AND user_id = $2 AND used_at IS NULL`, [found.orgId, found.userId]);
|
|
87
|
+
await client.query(`INSERT INTO password_reset_tokens (org_id, user_id, token_hash, expires_at)
|
|
88
|
+
VALUES ($1, $2, $3, $4)`, [found.orgId, found.userId, digest(token), expiresAt]);
|
|
89
|
+
}, this.scope);
|
|
90
|
+
const link = consoleUrl(this.deps.consoleBase, `#/reset?token=${encodeURIComponent(token)}`);
|
|
91
|
+
await this.deps.mailer.send({
|
|
92
|
+
to: address,
|
|
93
|
+
subject: 'Reset your Nacre password',
|
|
94
|
+
text: [
|
|
95
|
+
'Somebody asked to reset the password for this address.',
|
|
96
|
+
'',
|
|
97
|
+
link,
|
|
98
|
+
'',
|
|
99
|
+
`The link works once and expires in ${String(Math.round(RESET_TTL_SECONDS / 60))} minutes.`,
|
|
100
|
+
'If it was not you, nothing has changed and you can ignore this message.',
|
|
101
|
+
'',
|
|
102
|
+
'Resetting a password does not remove a second factor: you will still be',
|
|
103
|
+
'asked for a code afterwards.',
|
|
104
|
+
].join('\n'),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Spend a link and set the password, or refuse.
|
|
109
|
+
*
|
|
110
|
+
* The spend is the UPDATE that finds it, so two requests cannot both succeed
|
|
111
|
+
* — a read followed by a write is a race with a stolen link on the other side
|
|
112
|
+
* of it.
|
|
113
|
+
*/
|
|
114
|
+
async redeem(token, password) {
|
|
115
|
+
if (password.length < MIN_PASSWORD_LENGTH)
|
|
116
|
+
return 'too-short';
|
|
117
|
+
const [orgId, secret] = token.split('.');
|
|
118
|
+
if (orgId === undefined || secret === undefined || !UUID.test(orgId))
|
|
119
|
+
return 'refused';
|
|
120
|
+
const hash = await hashPassword(password);
|
|
121
|
+
const address = await withOrg(this.deps.pool, orgId, async (client) => {
|
|
122
|
+
const { rows } = await client.query(`UPDATE password_reset_tokens SET used_at = now()
|
|
123
|
+
WHERE org_id = $1 AND token_hash = $2 AND used_at IS NULL AND expires_at > now()
|
|
124
|
+
RETURNING user_id`, [orgId, digest(token)]);
|
|
125
|
+
const userId = rows[0]?.user_id;
|
|
126
|
+
if (userId === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
const { rows: people } = await client.query('SELECT email, disabled_at FROM users WHERE org_id = $1 AND id = $2', [orgId, userId]);
|
|
129
|
+
const person = people[0];
|
|
130
|
+
// A disabled account is refused, and the token is spent anyway: it was
|
|
131
|
+
// issued before the account was disabled, and leaving it live would be
|
|
132
|
+
// a link that starts working again if the account is ever re-enabled.
|
|
133
|
+
if (person === undefined || person.disabled_at !== null)
|
|
134
|
+
return undefined;
|
|
135
|
+
await client.query('UPDATE users SET password_hash = $2 WHERE id = $1', [userId, hash]);
|
|
136
|
+
// Every other session ends. A reset is what somebody does when they
|
|
137
|
+
// think their password is known, and leaving the refresh tokens alive
|
|
138
|
+
// would leave whoever knows it signed in.
|
|
139
|
+
await client.query(`UPDATE refresh_tokens SET revoked_at = now()
|
|
140
|
+
WHERE org_id = $1 AND user_id = $2 AND revoked_at IS NULL`, [orgId, userId]);
|
|
141
|
+
return person.email;
|
|
142
|
+
}, this.scope);
|
|
143
|
+
if (address === undefined)
|
|
144
|
+
return 'refused';
|
|
145
|
+
// A notice, not a confirmation: the person who receives this and did not do
|
|
146
|
+
// it is the one who needs to know, and they need to know now.
|
|
147
|
+
await this.deps.mailer
|
|
148
|
+
.send({
|
|
149
|
+
to: address,
|
|
150
|
+
subject: 'Your Nacre password was changed',
|
|
151
|
+
text: [
|
|
152
|
+
'The password for this address has just been changed using a recovery link.',
|
|
153
|
+
'',
|
|
154
|
+
'Every other session was signed out. Any second factor on the account is',
|
|
155
|
+
'untouched and is still required.',
|
|
156
|
+
'',
|
|
157
|
+
'If this was not you, whoever did it can read your mail — change the',
|
|
158
|
+
'password again from a device you trust and tell your administrator.',
|
|
159
|
+
].join('\n'),
|
|
160
|
+
})
|
|
161
|
+
// Dropped rather than raised: the password *is* changed, and refusing the
|
|
162
|
+
// request over a notice would be worse than a notice that did not arrive.
|
|
163
|
+
.catch(() => undefined);
|
|
164
|
+
return 'reset';
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The one user this address names, or nothing.
|
|
168
|
+
*
|
|
169
|
+
* The same shape the login path uses, and for the same reasons: two matches
|
|
170
|
+
* is silence rather than a choice, and the scan stops at two so the cost does
|
|
171
|
+
* not grow with how many tenants a deployment has.
|
|
172
|
+
*/
|
|
173
|
+
async findUser(email) {
|
|
174
|
+
const client = await this.deps.pool.connect();
|
|
175
|
+
let orgIds;
|
|
176
|
+
try {
|
|
177
|
+
const { rows } = await client.query('SELECT id FROM organizations WHERE deleted_at IS NULL');
|
|
178
|
+
orgIds = rows.map((row) => row.id);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
client.release();
|
|
182
|
+
}
|
|
183
|
+
const found = [];
|
|
184
|
+
for (const orgId of orgIds) {
|
|
185
|
+
const row = await withOrg(this.deps.pool, orgId, async (scoped) => {
|
|
186
|
+
const { rows } = await scoped.query(`SELECT id FROM users
|
|
187
|
+
WHERE org_id = $1 AND email = $2 AND disabled_at IS NULL AND password_hash IS NOT NULL`, [orgId, email]);
|
|
188
|
+
return rows[0];
|
|
189
|
+
}, this.scope);
|
|
190
|
+
if (row !== undefined)
|
|
191
|
+
found.push({ orgId, userId: row.id });
|
|
192
|
+
if (found.length > 1)
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return found[0];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
199
|
+
//# sourceMappingURL=recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery.js","sourceRoot":"","sources":["../src/recovery.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
export interface EnrolledFactor {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly kind: 'totp';
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly createdAt: Date;
|
|
7
|
+
readonly lastUsedAt: Date | null;
|
|
8
|
+
}
|
|
9
|
+
export interface BegunEnrolment {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/** Shown once, so a person can type it into an app that will not scan. */
|
|
12
|
+
readonly secret: string;
|
|
13
|
+
readonly otpauthUrl: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SecondFactorDeps {
|
|
16
|
+
readonly pool: Pool;
|
|
17
|
+
/** `undefined` when the deployment configured no key; see the header. */
|
|
18
|
+
readonly key: Buffer | undefined;
|
|
19
|
+
/** What an authenticator shows above the account. */
|
|
20
|
+
readonly issuer: string;
|
|
21
|
+
readonly role?: string;
|
|
22
|
+
readonly now?: () => Date;
|
|
23
|
+
}
|
|
24
|
+
export declare class SecondFactors {
|
|
25
|
+
private readonly deps;
|
|
26
|
+
constructor(deps: SecondFactorDeps);
|
|
27
|
+
/** Whether this installation can hold a second factor at all. */
|
|
28
|
+
get available(): boolean;
|
|
29
|
+
private get scope();
|
|
30
|
+
private get clock();
|
|
31
|
+
/**
|
|
32
|
+
* Does this person have to produce one?
|
|
33
|
+
*
|
|
34
|
+
* Only confirmed factors count. A secret generated and never proved is a
|
|
35
|
+
* secret that did not reach an authenticator, and treating it as live is how
|
|
36
|
+
* somebody locks themselves out at the moment they turn 2FA on.
|
|
37
|
+
*/
|
|
38
|
+
required(orgId: string, userId: string): Promise<boolean>;
|
|
39
|
+
list(orgId: string, userId: string): Promise<readonly EnrolledFactor[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Start enrolling one. The secret is stored sealed and unconfirmed.
|
|
42
|
+
*
|
|
43
|
+
* A second call with the same label replaces the unconfirmed row rather than
|
|
44
|
+
* refusing: somebody who closed the page before scanning has no way to name
|
|
45
|
+
* what they abandoned, and the alternative is a label nobody can reuse.
|
|
46
|
+
*/
|
|
47
|
+
begin(orgId: string, userId: string, label: string): Promise<BegunEnrolment | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* Prove the secret arrived, and hand back the way in without it.
|
|
50
|
+
*
|
|
51
|
+
* The recovery codes are minted here rather than on demand for two reasons:
|
|
52
|
+
* a person who has lost their phone cannot ask for them, and this is the one
|
|
53
|
+
* moment the product has their attention about it.
|
|
54
|
+
*
|
|
55
|
+
* Replacing any codes that already exist — enrolling a second factor is not
|
|
56
|
+
* a reason to invalidate them, so this only runs when there was none.
|
|
57
|
+
*/
|
|
58
|
+
confirm(orgId: string, userId: string, id: string, code: string): Promise<readonly string[] | undefined>;
|
|
59
|
+
/**
|
|
60
|
+
* A code at sign-in: a six-digit one from an authenticator, or a recovery code.
|
|
61
|
+
*
|
|
62
|
+
* Both are accepted here rather than on two endpoints, because from the
|
|
63
|
+
* outside they answer the same question and a separate route would tell an
|
|
64
|
+
* attacker which one a person is using.
|
|
65
|
+
*
|
|
66
|
+
* The brute-force bound is in Postgres and not in Redis: the rate limiter
|
|
67
|
+
* fails **open** by design — it is not an authorization control and a cache
|
|
68
|
+
* restart must not be an outage — and this one is. Six digits is a million,
|
|
69
|
+
* and a limiter that forgets is a limiter an attacker waits out.
|
|
70
|
+
*/
|
|
71
|
+
verify(orgId: string, userId: string, code: string): Promise<boolean>;
|
|
72
|
+
/**
|
|
73
|
+
* Remove one. The caller has already proved they are this person and holds a
|
|
74
|
+
* current code — see the handler; removing a factor is exactly what somebody
|
|
75
|
+
* with a stolen session would do first.
|
|
76
|
+
*/
|
|
77
|
+
remove(orgId: string, userId: string, id: string): Promise<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* The address, for a notice about this account's factors.
|
|
80
|
+
*
|
|
81
|
+
* Here rather than on a users port because this class already reads that
|
|
82
|
+
* column for the authenticator label, and a second reader of one column is a
|
|
83
|
+
* second thing to keep scoped correctly.
|
|
84
|
+
*/
|
|
85
|
+
emailOf(orgId: string, userId: string): Promise<string | undefined>;
|
|
86
|
+
/** How many unspent recovery codes are left, for the screen that says so. */
|
|
87
|
+
recoveryCodesLeft(orgId: string, userId: string): Promise<number>;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=second-factor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"second-factor.d.ts","sourceRoot":"","sources":["../src/second-factor.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AA+B9B,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,IAAI,GAAG,IAAI,CAAA;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAC5B;AAUD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;IACnB,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,qDAAqD;IACrD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAC1B;AAED,qBAAa,aAAa;IACZ,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,gBAAgB;IAEnD,iEAAiE;IACjE,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,OAAO,KAAK,KAAK,GAEhB;IAED,OAAO,KAAK,KAAK,GAEhB;IAED;;;;;;OAMG;IACG,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAiBzD,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,cAAc,EAAE,CAAC;IA+B7E;;;;;;OAMG;IACG,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IA+C9F;;;;;;;;;OASG;IACG,OAAO,CACX,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IAmDzC;;;;;;;;;;;OAWG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAsE3E;;;;OAIG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA+BzE;;;;;;OAMG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAezE,6EAA6E;IACvE,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAexE"}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { generateRecoveryCode, generateTotpSecret, hashRecoveryCode, openTotpSecret, otpauthUrl, RECOVERY_CODE_COUNT, sealTotpSecret, verifyTotp, withOrg, } from '@nacre.work/core';
|
|
2
|
+
/**
|
|
3
|
+
* The second factor, as far as the API is concerned.
|
|
4
|
+
*
|
|
5
|
+
* `packages/core/totp.ts` is the arithmetic and knows nothing about a database;
|
|
6
|
+
* this is the storage, the replay bound and the brute-force bound. The split is
|
|
7
|
+
* the same one `passwords.ts` has: an algorithm that can be checked against a
|
|
8
|
+
* standard's own vectors, and a store that can be checked against a real
|
|
9
|
+
* PostgreSQL.
|
|
10
|
+
*
|
|
11
|
+
* ## What a second factor decides
|
|
12
|
+
*
|
|
13
|
+
* Whether a session starts. Nothing here is read by `authz/`, and a token
|
|
14
|
+
* minted after a correct code reaches exactly what the same token reaches
|
|
15
|
+
* without one — the permitted set is computed per request from `grants`, as it
|
|
16
|
+
* is for every other principal.
|
|
17
|
+
*
|
|
18
|
+
* ## Unconfigured is a supported state
|
|
19
|
+
*
|
|
20
|
+
* With no `NACRE_2FA_KEY` there is no key to seal a secret with, so
|
|
21
|
+
* enrolment is refused and every read answers "no factor". Not a degraded mode
|
|
22
|
+
* that stores secrets in the clear until somebody notices: a product that
|
|
23
|
+
* half-does a second factor is worse than one that does none, because the
|
|
24
|
+
* operator believes something.
|
|
25
|
+
*/
|
|
26
|
+
/** Five wrong codes and this factor stops answering for a while. */
|
|
27
|
+
const MAX_FAILURES = 5;
|
|
28
|
+
const LOCK_SECONDS = 15 * 60;
|
|
29
|
+
export class SecondFactors {
|
|
30
|
+
deps;
|
|
31
|
+
constructor(deps) {
|
|
32
|
+
this.deps = deps;
|
|
33
|
+
}
|
|
34
|
+
/** Whether this installation can hold a second factor at all. */
|
|
35
|
+
get available() {
|
|
36
|
+
return this.deps.key !== undefined;
|
|
37
|
+
}
|
|
38
|
+
get scope() {
|
|
39
|
+
return this.deps.role === undefined ? {} : { role: this.deps.role };
|
|
40
|
+
}
|
|
41
|
+
get clock() {
|
|
42
|
+
return this.deps.now?.() ?? new Date();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Does this person have to produce one?
|
|
46
|
+
*
|
|
47
|
+
* Only confirmed factors count. A secret generated and never proved is a
|
|
48
|
+
* secret that did not reach an authenticator, and treating it as live is how
|
|
49
|
+
* somebody locks themselves out at the moment they turn 2FA on.
|
|
50
|
+
*/
|
|
51
|
+
async required(orgId, userId) {
|
|
52
|
+
if (!this.available)
|
|
53
|
+
return false;
|
|
54
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
55
|
+
const { rows } = await client.query(`SELECT count(*)::text AS count FROM user_second_factors
|
|
56
|
+
WHERE org_id = $1 AND user_id = $2 AND confirmed_at IS NOT NULL`, [orgId, userId]);
|
|
57
|
+
return Number(rows[0]?.count ?? '0') > 0;
|
|
58
|
+
}, this.scope);
|
|
59
|
+
}
|
|
60
|
+
async list(orgId, userId) {
|
|
61
|
+
if (!this.available)
|
|
62
|
+
return [];
|
|
63
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
64
|
+
const { rows } = await client.query(`SELECT id, kind, label, created_at, last_used_at
|
|
65
|
+
FROM user_second_factors
|
|
66
|
+
WHERE org_id = $1 AND user_id = $2 AND confirmed_at IS NOT NULL
|
|
67
|
+
ORDER BY created_at, id`, [orgId, userId]);
|
|
68
|
+
return rows.map((row) => ({
|
|
69
|
+
id: row.id,
|
|
70
|
+
kind: row.kind,
|
|
71
|
+
label: row.label,
|
|
72
|
+
createdAt: row.created_at,
|
|
73
|
+
lastUsedAt: row.last_used_at,
|
|
74
|
+
}));
|
|
75
|
+
}, this.scope);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Start enrolling one. The secret is stored sealed and unconfirmed.
|
|
79
|
+
*
|
|
80
|
+
* A second call with the same label replaces the unconfirmed row rather than
|
|
81
|
+
* refusing: somebody who closed the page before scanning has no way to name
|
|
82
|
+
* what they abandoned, and the alternative is a label nobody can reuse.
|
|
83
|
+
*/
|
|
84
|
+
async begin(orgId, userId, label) {
|
|
85
|
+
const key = this.deps.key;
|
|
86
|
+
if (key === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const secret = generateTotpSecret();
|
|
89
|
+
const sealed = sealTotpSecret(secret, key);
|
|
90
|
+
const begun = await withOrg(this.deps.pool, orgId, async (client) => {
|
|
91
|
+
// The address, for the label an authenticator shows. Read here rather
|
|
92
|
+
// than passed in: `AuthContext` carries the principal's id and not
|
|
93
|
+
// their address, and a handler that had to fetch one would be a second
|
|
94
|
+
// place that knows how to find a user.
|
|
95
|
+
const { rows: people } = await client.query('SELECT email FROM users WHERE org_id = $1 AND id = $2', [orgId, userId]);
|
|
96
|
+
const account = people[0]?.email;
|
|
97
|
+
if (account === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
await client.query(`DELETE FROM user_second_factors
|
|
100
|
+
WHERE org_id = $1 AND user_id = $2 AND label = $3 AND confirmed_at IS NULL`, [orgId, userId, label]);
|
|
101
|
+
const { rows } = await client.query(`INSERT INTO user_second_factors (org_id, user_id, kind, secret, label)
|
|
102
|
+
VALUES ($1, $2, 'totp', $3, $4)
|
|
103
|
+
RETURNING id`, [orgId, userId, sealed, label]);
|
|
104
|
+
const id = rows[0]?.id;
|
|
105
|
+
return id === undefined ? undefined : { id, account };
|
|
106
|
+
}, this.scope);
|
|
107
|
+
if (begun === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
id: begun.id,
|
|
111
|
+
secret,
|
|
112
|
+
otpauthUrl: otpauthUrl({ issuer: this.deps.issuer, account: begun.account, secret }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Prove the secret arrived, and hand back the way in without it.
|
|
117
|
+
*
|
|
118
|
+
* The recovery codes are minted here rather than on demand for two reasons:
|
|
119
|
+
* a person who has lost their phone cannot ask for them, and this is the one
|
|
120
|
+
* moment the product has their attention about it.
|
|
121
|
+
*
|
|
122
|
+
* Replacing any codes that already exist — enrolling a second factor is not
|
|
123
|
+
* a reason to invalidate them, so this only runs when there was none.
|
|
124
|
+
*/
|
|
125
|
+
async confirm(orgId, userId, id, code) {
|
|
126
|
+
const key = this.deps.key;
|
|
127
|
+
if (key === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
130
|
+
const { rows } = await client.query(`SELECT id, secret, last_step, failed_attempts, locked_until
|
|
131
|
+
FROM user_second_factors
|
|
132
|
+
WHERE org_id = $1 AND user_id = $2 AND id = $3 AND confirmed_at IS NULL
|
|
133
|
+
FOR UPDATE`, [orgId, userId, id]);
|
|
134
|
+
const row = rows[0];
|
|
135
|
+
if (row === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
const verified = verifyTotp(openTotpSecret(row.secret, key), code, {
|
|
138
|
+
at: this.clock,
|
|
139
|
+
after: row.last_step === null ? null : Number(row.last_step),
|
|
140
|
+
});
|
|
141
|
+
if (verified === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
await client.query(`UPDATE user_second_factors
|
|
144
|
+
SET confirmed_at = now(), last_step = $2, last_used_at = now(), failed_attempts = 0
|
|
145
|
+
WHERE id = $1`, [row.id, String(verified.step)]);
|
|
146
|
+
const { rows: existing } = await client.query(`SELECT count(*)::text AS count FROM user_recovery_codes
|
|
147
|
+
WHERE org_id = $1 AND user_id = $2 AND used_at IS NULL`, [orgId, userId]);
|
|
148
|
+
if (Number(existing[0]?.count ?? '0') > 0)
|
|
149
|
+
return [];
|
|
150
|
+
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, () => generateRecoveryCode());
|
|
151
|
+
for (const one of codes) {
|
|
152
|
+
await client.query(`INSERT INTO user_recovery_codes (org_id, user_id, code_hash) VALUES ($1, $2, $3)`, [orgId, userId, hashRecoveryCode(one)]);
|
|
153
|
+
}
|
|
154
|
+
return codes;
|
|
155
|
+
}, this.scope);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A code at sign-in: a six-digit one from an authenticator, or a recovery code.
|
|
159
|
+
*
|
|
160
|
+
* Both are accepted here rather than on two endpoints, because from the
|
|
161
|
+
* outside they answer the same question and a separate route would tell an
|
|
162
|
+
* attacker which one a person is using.
|
|
163
|
+
*
|
|
164
|
+
* The brute-force bound is in Postgres and not in Redis: the rate limiter
|
|
165
|
+
* fails **open** by design — it is not an authorization control and a cache
|
|
166
|
+
* restart must not be an outage — and this one is. Six digits is a million,
|
|
167
|
+
* and a limiter that forgets is a limiter an attacker waits out.
|
|
168
|
+
*/
|
|
169
|
+
async verify(orgId, userId, code) {
|
|
170
|
+
const key = this.deps.key;
|
|
171
|
+
if (key === undefined)
|
|
172
|
+
return false;
|
|
173
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
174
|
+
// A recovery code first, and spent by the UPDATE itself: a read then a
|
|
175
|
+
// write would let two requests spend one code.
|
|
176
|
+
const spent = await client.query(`UPDATE user_recovery_codes
|
|
177
|
+
SET used_at = now()
|
|
178
|
+
WHERE org_id = $1 AND user_id = $2 AND code_hash = $3 AND used_at IS NULL`, [orgId, userId, hashRecoveryCode(code)]);
|
|
179
|
+
if ((spent.rowCount ?? 0) > 0)
|
|
180
|
+
return true;
|
|
181
|
+
const { rows } = await client.query(`SELECT id, secret, last_step, failed_attempts, locked_until
|
|
182
|
+
FROM user_second_factors
|
|
183
|
+
WHERE org_id = $1 AND user_id = $2 AND confirmed_at IS NOT NULL
|
|
184
|
+
ORDER BY created_at
|
|
185
|
+
FOR UPDATE`, [orgId, userId]);
|
|
186
|
+
const now = this.clock;
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
if (row.locked_until !== null && row.locked_until > now)
|
|
189
|
+
continue;
|
|
190
|
+
const verified = verifyTotp(openTotpSecret(row.secret, key), code, {
|
|
191
|
+
at: now,
|
|
192
|
+
after: row.last_step === null ? null : Number(row.last_step),
|
|
193
|
+
});
|
|
194
|
+
if (verified === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
await client.query(`UPDATE user_second_factors
|
|
197
|
+
SET last_step = $2, last_used_at = now(), failed_attempts = 0, locked_until = NULL
|
|
198
|
+
WHERE id = $1`, [row.id, String(verified.step)]);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
// Counted against every factor this person holds, because the attacker
|
|
202
|
+
// is guessing at the person and not at a device.
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
const failures = row.failed_attempts + 1;
|
|
205
|
+
// Every parameter cast, because `$2` is read twice — once as the new
|
|
206
|
+
// value and once in the comparison — and Postgres infers a type from
|
|
207
|
+
// each use. Uncast it raises `text versus integer` at run time and at
|
|
208
|
+
// no other time, which is a query that type-checks, passes a mock and
|
|
209
|
+
// fails against the database. Found by running it.
|
|
210
|
+
await client.query(`UPDATE user_second_factors
|
|
211
|
+
SET failed_attempts = $2::int,
|
|
212
|
+
locked_until = CASE
|
|
213
|
+
WHEN $2::int >= $3::int THEN now() + make_interval(secs => $4::int)
|
|
214
|
+
ELSE locked_until
|
|
215
|
+
END
|
|
216
|
+
WHERE id = $1`, [row.id, failures, MAX_FAILURES, LOCK_SECONDS]);
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}, this.scope);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Remove one. The caller has already proved they are this person and holds a
|
|
223
|
+
* current code — see the handler; removing a factor is exactly what somebody
|
|
224
|
+
* with a stolen session would do first.
|
|
225
|
+
*/
|
|
226
|
+
async remove(orgId, userId, id) {
|
|
227
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
228
|
+
const done = await client.query(`DELETE FROM user_second_factors WHERE org_id = $1 AND user_id = $2 AND id = $3`, [orgId, userId, id]);
|
|
229
|
+
if ((done.rowCount ?? 0) === 0)
|
|
230
|
+
return false;
|
|
231
|
+
// The codes go with the last factor. Recovery codes are the way past a
|
|
232
|
+
// second factor, so leaving them behind an account that no longer has
|
|
233
|
+
// one is a set of long-lived credentials nobody remembers holding.
|
|
234
|
+
const { rows } = await client.query(`SELECT count(*)::text AS count FROM user_second_factors
|
|
235
|
+
WHERE org_id = $1 AND user_id = $2 AND confirmed_at IS NOT NULL`, [orgId, userId]);
|
|
236
|
+
if (Number(rows[0]?.count ?? '0') === 0) {
|
|
237
|
+
await client.query(`DELETE FROM user_recovery_codes WHERE org_id = $1 AND user_id = $2`, [
|
|
238
|
+
orgId,
|
|
239
|
+
userId,
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}, this.scope);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The address, for a notice about this account's factors.
|
|
247
|
+
*
|
|
248
|
+
* Here rather than on a users port because this class already reads that
|
|
249
|
+
* column for the authenticator label, and a second reader of one column is a
|
|
250
|
+
* second thing to keep scoped correctly.
|
|
251
|
+
*/
|
|
252
|
+
async emailOf(orgId, userId) {
|
|
253
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
254
|
+
const { rows } = await client.query('SELECT email FROM users WHERE org_id = $1 AND id = $2', [orgId, userId]);
|
|
255
|
+
return rows[0]?.email;
|
|
256
|
+
}, this.scope);
|
|
257
|
+
}
|
|
258
|
+
/** How many unspent recovery codes are left, for the screen that says so. */
|
|
259
|
+
async recoveryCodesLeft(orgId, userId) {
|
|
260
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
261
|
+
const { rows } = await client.query(`SELECT count(*)::text AS count FROM user_recovery_codes
|
|
262
|
+
WHERE org_id = $1 AND user_id = $2 AND used_at IS NULL`, [orgId, userId]);
|
|
263
|
+
return Number(rows[0]?.count ?? '0');
|
|
264
|
+
}, this.scope);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
//# sourceMappingURL=second-factor.js.map
|