@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.
@@ -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"}
@@ -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